Files
gstack/test/helpers/eval-store.ts
Garry TanandClaude Fable 5 e76f65a8da v1.77.0.0 feat: test-infrastructure overhaul wave 1 — matrix deletion, flake telemetry, sync-spawn wedge class extinct (#2746)
* fix: pin the claude CLI to an exact version in the CI image + tripwire

The image installed @anthropic-ai/claude-code UNPINNED and rebuilt weekly
'to pick up CLI updates' — while bun sat carefully pinned at 1.3.13 two RUN
lines above. The PTY harness screen-scrapes this CLI's TUI, and that drift
broke it three separate times (welcome-screen wedge on 2.1.233, skillify
HOME discovery on 2.1.237, guard/freeze hooks on 2.1.162), each debugged as
a flake first. Pin 2.1.251 (current latest), bump deliberately via a PR
that runs the PTY gate, and enforce with test/ci-image-cli-pin.test.ts:
any global npm install in Dockerfile.ci without an exact @X.Y.Z pin fails
the free suite. The weekly ci-image cron stays as a cheap tag self-heal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: stamp the claude CLI version into every eval-store run record

Three harness breakages were traced to claude-CLI TUI drift only after long
flake hunts, because no run record said which CLI it actually exercised.
EvalCollector now stamps claude_cli_version (claude --version, cached once
per process, 'unknown' when the binary is absent) into both partial and
finalized records — schema-additive optional field, no SCHEMA_VERSION bump.
Correlating a flake wave with a CLI release becomes a grep over
~/.gstack/projects/<slug>/evals/ instead of archaeology.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: give the spinning-shard kill test load headroom (30s -> 90s)

The test spawns and group-kills three real children (one a busy-loop
burning a full core) while five sibling shard processes compete for eight
vCPUs. Under full-suite load it blew bun's default 30s per-test ceiling at
30,009ms — while passing in isolation in 1.4s — and red the only required
lane. Every assertion in it is event-based (statuses, group-kill proof,
heartbeat lines); the sole latency claim is the <30s kill-deadline sanity
bound, which stays. Explicit 90s headroom, not a weakened oracle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: green-by-skip census — skip counts in the classifier, all-skipped labeling in the paid runner

bun's 'Ran N tests' line COUNTS skipped tests, so a codex/gemini shard
whose every test self-skipped (binary absent on the runner — true of every
CI runner today) exits 0, dodges the hollow-shard guard, and reads as
coverage in the weekly census. The classifier now parses bun's ' N skip' /
' N pass' recap lines; ShardOutcome carries skippedTests; formatSummary and
the fail-closed slices report label an all-skipped pass explicitly:
'all N tests SKIPPED — verified nothing'. Status stays 'passed' (external
service availability is host state, not a repo regression) but the census
can no longer mistake absence for coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: extract composite actions for eval-lane setup; surviving lanes gain the fail-fast registry verification

'Fix bun temp' x3, 'Restore deps' x5, 'Seed claude interactive config' x3,
and 'Register gstack skills' x3 were byte-near-identical copies across the
legacy matrix, the sliced lane, and the periodic lane — and only the MATRIX
copy of register-skills carried the 19-line dangling-symlink + frontmatter
fail-fast loop written after a silent 'Unknown command' + 35-min-timeout
incident. Extract all four into .github/actions/ composites; the register
composite carries the verification loop (generalized over the skill list),
so the sliced and periodic lanes — the lanes that SURVIVE the matrix
deletion — now inherit the check they had silently dropped. Matrix-job
inline copies are left untouched: that job is deleted next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: delete the legacy 17-row eval matrix — the sliced lane is the only paid lane

Every PR paid twice: the hand-enumerated matrix (18 test files, 22.6 min,
~$21 API measured on run 33263204465) ran serialized AHEAD of the strictly
superior sliced lane via 'needs: evals' — 35.5 min wall and ~2x paid spend
for the same diff. 14 of 17 rows carried no tier:, so periodic Opus
benchmarks leaked into every PR (the e2e-plan row alone: 12/12 tests,
21.7 min, $7.28 — the wall-clock bound of ALL of CI).

Parity receipt (static, pre-deletion): the sliced lane's gate census (49
files, derived from the runner itself) strictly contains all 18 matrix test
files, plus 31 files the matrix never ran. Pure deletion — one revert
restores it. The PR comment moved into slices-report (same '## E2E Evals'
upsert marker, now sourced from slice artifacts + carrying the fail-closed
reconciliation verdict). plan-slices loses the needs edge; the dead
workflow-level EVALS_TIER env goes with it.

test/evals-workflow-matrix.test.ts (and its KNOWN_MATRIX_GAPS /
KNOWN_TIER_UNSET burn-down ratchets — retired: the sliced census makes
'every gate file runs' true by construction) is rewritten as
test/evals-workflow-wiring.test.ts: matrix stays deleted, planner/executor/
report tier + slice-count agreement, both surviving lanes on the shared
register-skills composite with its fail-fast verification loop, PR comment
survival. Expected: PR eval wall 35.5 -> ~13 min, per-PR paid spend ~halved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: provider-runner timeouts kill the whole process GROUP; codex/gemini inherit the orphan-drain hardening

All three provider runners (claude/codex/gemini) killed only the direct
child on timeout: tool subprocesses the CLI spawned survived as orphans
holding our pipes open and burning shared API rate (observed: a 600s
timeout stretching past 1400s; a stalled run once burned a core for 15
hours). gstack-detach's watchdog had the same shape one level up — killpg
SIGTERM, 5s grace, then a direct-child proc.kill() that orphaned
grandchildren.

Fix: spawn provider children via node:child_process with detached (own
process group) and killProcessGroup(SIGKILL) in the timeout handler —
runShardChild's proven pattern, EPERM/ESRCH fallbacks included. The codex
and gemini copies also gain the reader.cancel() + stderr Promise.race
hardening only the claude copy had (they still carried the blocked-drain
hang it fixed). gstack-detach's watchdog now group-SIGKILLs after the
grace.

Regression net: test/session-runner-groupkill.test.ts drives the REAL
runSkillTest against a fake claude shim (PATH override) that spawns a
grandchild and wedges — the run must classify timeout within budget and
leave neither shim nor grandchild alive — plus source pins on all three
runners (detached + killProcessGroup, no bare timeout kill, no Bun.spawn
reversion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: skill-e2e-opus-47 renders SKILL.md fixtures into a mkdtemp — never the live tree

mkEvalRoot ran gen-skill-docs with cwd=ROOT, regenerating every in-repo
SKILL.md mid-run while concurrent paid shards copyFileSync those same files
in their beforeAll (EVALS_JOBS>=4 locally, 2 per CI slice) — a sibling
could capture a half-regenerated or opus-rendered SKILL.md, and a timeout
before afterAll stranded the whole tree at the wrong model for every later
shard. A cross-shard race that could flake ANY concurrent paid test.

Render via the --out-dir flag gen-skill-docs grew for exactly this reason
(mirrors the repo layout, which is all the fixture reads), read the skill
heads from the render dir, delete it, and drop the afterAll restore-regen
entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: claude CLI version resolves in the runner parent, never on a test thread

Eng-review finding: getClaudeCliVersion's fallback is a SYNCHRONOUS
spawnSync on the same thread that polls concurrent PTY/session tests — the
judgePtyState blocking class this overhaul kills elsewhere. The paid runner
parent now resolves it once (cached) and stamps GSTACK_CLAUDE_CLI_VERSION
into every shard's env; eval-store short-circuits on the env var, and the
fallback spawn's budget tightens 10s -> 3s (bounded one-time stall, records
'unknown' on a slow CLI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: wire skippedTests end-to-end through runPaidShard

The census unit tests hand-built outcomes and the classifier tests parsed
strings; nothing proved a real child's ' N skip' recap flows into
outcome.skippedTests and the formatSummary label. A commandFor fake now
prints the recap shape and the test asserts the parsed counts, the
all-skipped predicate, and the 'verified nothing' label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make the setup composites rerun-safe (codex diff-review hardenings)

restore-deps: 'cp -r SRC node_modules' with an existing node_modules NESTS
the copy and leaves stale deps active — rm first. register-gstack-skills:
'ln -snf' hard-errors under set -eu when a REAL directory occupies the
gstack slot — clear a non-symlink leftover first. CI workspaces are fresh
today; a reusable composite must survive dirty reruns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files)

spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process
per-test timeout can never fire while one waits — a hung child (stdin read,
network probe, dead daemon) wedges the whole shard until the runner's
external wall-clock SIGKILL. This exact class reached main: free-tests run
33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2
at the 360s wall while its five siblings finished in ~65s.

Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified
against its call site): default timeout: 30_000 (matches the free runner's
per-test budget), 120_000 for genuinely slow ops (installs, builds,
playwright, provider CLIs), helper wrappers fixed ONCE where call sites
route through them. Sites that only LOOK like calls (string fixtures, grep
needles, comments) were skipped with reasons — the enforcement commit that
follows marks them exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: sync-spawn timeout tripwire — the wedge class stays extinct

Free scanner over all test trees (test/, browse/test/, design/test/,
make-pdf/test/, ios-qa, browser-skills): every spawnSync/execSync/
Bun.spawnSync call site must carry a timeout within a 30-line options
window, or an explicit '// tripwire-exempt: <reason>' marker. Comment
lines are skipped; exemptions are counted and ratcheted shrink-only
(ceiling 6 = the 6 string-fixture/grep-needle sites where the pattern is
CONTENT, not a call — marked in this commit). A scan-sanity test pins that
the scanner still sees >100 real call sites so it can never rot to a
vacuous green. Companion to the 436-site sweep in the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: paid-lane flake telemetry — record-level attempts, flaky_retries, report surfacing

bun --retry leaves a retried pass INVISIBLE in its output: a fail-then-pass
prints the error detail but no (fail) result line and recaps as a clean
pass (probed live on 1.3.10). So attempts are recorded where they cannot
lie: EvalCollector.addTest stamps a 1-based attempt on same-name re-records
(a retried test runs its body again and re-records), finalized runs carry
flaky_retries, printSummary warns loudly, and the fail-closed slices report
lists every passed-only-on-retry test — recorded and ranked, never blocking
and never silent. Cross-model confirmed (codex reached the same don't-parse
-the-stream conclusion independently).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: free-lane flake ledger — retry ON in CI, flaky-passes recorded and uploaded

The runner's attribution-gated flaky-retry pass (cap 5, truncation veto)
was OFF in the required lane and its FLAKY-PASS evidence was console-only —
so a single timing flake red the merge gate while repeat offenders stayed
unenumerable. free-tests.yml now sets GSTACK_FREE_RETRY_FLAKY=1 and points
GSTACK_FLAKE_LEDGER at runner.temp; every flaky-pass appends a JSONL entry
(SINGLE writer: the parent runner — no concurrent-append hazard by
construction; fail-open with a loud warning so a broken ledger can never
red the lane) and the artifact uploads UNCONDITIONALLY — a flaky-pass run
is green, which is exactly when the evidence matters. Wiring pinned by
free-tests-workflow-wiring; ledger behavior unit-tested incl. the fail-open
path. Matches 2026 industry practice (retry for data, quarantine out of
merge-blocking but never out of logging) with the repo's own receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: eval:flake-rank — the flake-telemetry dial

Aggregates per-test series across every finalized eval-store run (shard
dirs included) plus the free flake ledger: runs, fails, RETRIED PASSES
(the flake signature), avg duration — ranked retries-first. This is the
readable dial behind two policies: a flaky pass never blocks a merge but
is always ranked here, and the WS16 required-check promotion needs weeks
of clean flake-rank, not vibes. --json for machines, --dir for downloaded
CI artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: two-phase session timeout — silent APIs die at the startup grace, named

The single spawn-armed timer charged API queue latency to the work budget:
the recurring '0 turns / $0.00 / x3 attempts' failure with four budget-bump
receipts (180->300s, 240->360s, 300->420s, 90->300s). Split: startup phase
(no NDJSON byte yet) kills EARLY at min(grace, timeout) with the distinct
exitReason 'timeout_startup' — an availability verdict, not transcript
archaeology — and the work phase arms on the first byte for the REMAINING
budget, so total wall never exceeds the timeout (tier envelopes are
margin-free: tests pass timeout: CAPTURE_MS and bun-budget the same tier).
Local grace 90s (observed queue latency 60-90s), CI floor 300s (TODOS-filed;
shared runners queue harder), both pinned by the new grace tests with fake
-claude shims covering the late-first-byte and silent-API paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: census integrity — 17 phantom selection keys deleted, reverse invariant added, gitignored dep patterns replaced, local map forks derived

The merge-blocking gate census counted tests that could not run. Deleted
(critic-verified against both quoted-occurrence and dep-registration
liveness): 7 *-prosons-format keys with no declaring test, ship-plan-
completion/-verification, review-plan-completion, design-shotgun-path/
session/full, autoplan-core (dead ~10 months), e2e-harness-audit (its
namesake is a FREE-suite file), plus 2 dead LLM-judge keys and 2 free-file
keys (budget-regression-pty, global-discover) misplaced in the PAID maps.
Census: 191 -> 174 keys, gate 86 -> 78 honest.

The new reverse invariant in touchfiles.test.ts makes the class structurally
impossible: every key must be quoted in a living paid test file OR
registered to an existing paid test file via its dep list (the constructed-
name binding the 2026-08 self-registration sweep established) — zero
exceptions needed today, with a live-file check on any future exception.

Also: '.agents/skills/**' dep patterns replaced with the generator
(scripts/gen-skill-docs.ts) — .agents/ is gitignored, so those patterns
could NEVER match a git diff and review-template edits silently stopped
selecting codex/gemini tests; the codex/gemini local touchfile maps are now
DERIVED from the canonical map (loud throw if a key vanishes) instead of
hand-forked copies that had already drifted. ios-qa-e2e demoted gate ->
periodic: its gate declaration was never executable in CI (hardware
exclusion only applies at tier=periodic), so every Linux PR planned a
hollow shard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: routing journeys lose their answer key and end at the routing decision

The journey tests exist to catch skill-DESCRIPTION regressions (touchfiles:
*/SKILL.md.tmpl), but the fixture CLAUDE.md shipped an explicit
prompt->skill lookup table — with the answer key in context, a badly
regressed frontmatter description still routed correctly, so the tests
could not fail on the exact class they select for. The fixture now carries
only the generic invoke-skills nudge; the frontmatter carries the routing
load. Also capped all 10 journeys at maxTurns 2 / tools [Skill, Read]:
only the FIRST Skill call is asserted, so 5 turns of Read/Bash/Glob/Grep
was pure spend — roughly halves each journey's cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: retire decided A/B experiments; vendor the pre-cut fixture; ban raw-SHA fixtures

Three one-shot decision experiments kept re-running weekly as N=1
stochastic comparisons — flaky by construction with near-zero remaining
information: skill-e2e-auq-repetition-cut-ab (its own header: gate "passed
pre-landing, approved 2026-08-25"), skill-e2e-preamble-script-ab ("demoted
post-Phase-3"), and opus-47's fanout arm-vs-arm (parA >= parB across two
SINGLE stochastic runs — a coin flip). Deleted, with their selection keys;
the SDK overlay-harness stays as the maintained instrument for the next
experiment, and opus-47 keeps its routing-precision cases.

verboseSkill() now reads the VENDORED test/fixtures/auq-pre-cut-...-SKILL.md
instead of `git show ab66193e^:...` — a branch-local ref that dies on
branch prune and already failed on shallow clones. New free tripwire
(test/git-ref-fixture-tripwire.test.ts) bans the raw-SHA fixture class
outright: quoted SHA:path rev-specs and gitRef-style hex defaults in the
test trees fail the suite with the vendor-instead instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: demote plan-ceo-review-expansion-energy to periodic

Opus generator + a subjective 2-axis >=4/5 LLM-judge threshold sat in the
MERGE-BLOCKING gate — the exact class its sibling posture tests were
demoted for, with a receipt (a +21-line preamble change once flipped the
score). CLAUDE.md's own tiering rule: Opus model test -> periodic. The
weekly lane keeps the regression signal; merges stop paying a judge-
temperament tax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: paid shards get per-shard TMPDIR + CHROMIUM_PROFILE isolation and a kill-path cleanup backstop

The free runner treats this isolation as MANDATORY (two concurrent shards
on one Chromium profile kill each other's browser; shared tmp
cross-contaminates) — the paid lane had none of it. Doubly load-bearing
here: a shard that hits its 30-min wall is group-SIGKILLed, so per-test
afterAll cleanup never runs; the rmSync backstop is the only thing keeping
wedged runs from accumulating full git-repo workspaces in the shared
tmpdir forever. This is the DAG prerequisite for raising EVALS_JOBS (next
commit) — more concurrency on shared state amplifies exactly the
shared-tree race class opus-47 exhibited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: paid-runner defaults 4x4 -> 8x2 — halve the local gate worst case

39 of 75 skill-e2e files hold exactly ONE test, so within-shard
concurrency was dead weight for most shards: 4 jobs x 4 concurrency
yielded only ~4-6 real in-flight sessions and a 13-wave local gate worst
case (~6.5h). 8 jobs x 2 gives ~10-13 in-flight — under the
documented-safe ~15 — and ~7 waves (~3.3h worst case). CI lanes keep
their explicit EVALS_JOBS env (2 per slice; 4 for gate-census); this
changes local defaults. Rollback trigger: sustained 429 storms in the WS1
telemetry across 2 PR cycles. test/eval-detach-timeout-floor.test.ts
recomputed green (the raise LOWERS the worst-case floor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: SHA-pin every action in the secrets-bearing eval lanes

evals.yml and evals-periodic.yml execute PR-authored code with three
provider API keys in env, yet rode mutable action tags (@v7/@v8/@v2/@v4)
— while quality-gate.yml, osv-scanner.yml, and dependency-review.yml
already model the SHA-pin pattern. All 30 uses sites across both lanes now
pin the exact commit (tag noted in a trailing comment); dependabot's
github-actions ecosystem keeps them fresh via PRs instead of silent tag
moves. Pulled forward from the plan's endgame on the CEO-review + outside-
voice agreement: supply-chain pins on secret lanes go first, not last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: sweep wave 3 — the execFileSync family gets timeouts (90 sites, 17 files)

The tripwire's regex covered spawnSync/execSync/Bun.spawnSync but not
execFileSync — an entire blocking sync-spawn API family that could
reintroduce the shard-wedge class undetected (ship review army). Same
mechanical recipe as waves 1-2: timeout: 30_000 default, 120_000 for slow
ops, shared wrappers fixed once, string-needle sites skipped with reasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: review-army + adversarial test hardening

- Tripwire scans execFileSync too (ceiling 8: two more grep-needle string
  exemptions); merge-introduced timeout-less spawnSync in
  question-preference-hook fixed — the tripwire caught a site that landed
  on main AFTER the sweep, on its first day.
- gstack-detach gains TWO watchdog kill regression tests: TERM-immune
  grandchild (the killpg-after-grace escalation) and the leader-dies
  variant (the pgid-at-spawn fix — the case the first test cannot see).
- eval-flake-rank gets its unit suite (final-attempt accounting, artifact
  exclusion, shard recursion, recency bound).
- Groupkill/startup-grace shim markers are per-run unique (pid-suffixed
  sleep durations): sibling Conductor worktrees run free suites with no
  machine lock, and fixed markers let one run pgrep/pkill the other's
  shims — a cross-run flake inside the anti-flake tests.
- flake-ledger test pins the project-scoped local default; stale empty
  section headers in touchfiles-data deleted (they invited entries under
  deliberately retired categories).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial-review runtime fixes across the telemetry + kill paths

- session-runner: exit-labeling keys off 'exit', not 'close' — an orphan
  holding the pipes could relabel a REAL exit (auth failure) as
  'timeout_startup' availability noise; the kill path still always
  group-kills and cancels the reader (labeling and unblocking are separate
  concerns). Work phase arms on a flag, not firstResponseMs===0 (a same-ms
  first byte left the startup timer live all run). The CI startup grace is
  now a real FLOOR (Math.max), matching its name and pinning test.
- gstack-detach: pgid captured AT SPAWN (== child pid under
  start_new_session) — resolving it after the grace raised ESRCH once the
  leader died on SIGTERM, orphaning TERM-immune grandchildren forever.
- test-free-shards: ledger entries carry branch + git_sha (rev-parse split:
  '--abbrev-ref HEAD HEAD' printed the branch twice and recorded it as the
  sha); local ledger default is per-PROJECT, not the machine-global tmpdir.
- eval-flake-rank: per-LINE ledger parse (one torn JSONL line vanished the
  whole series), 60-day recency bound (transcript-bearing files are MBs),
  shared isFinalizedEvalResultFile predicate (the artifact-taxonomy rule
  lived in three places); eval-store exports the predicate and finalize
  stops computing flakyRetries twice; paid-shards cleanup uses async rm
  (a SIGKILLed shard's git-workspace teardown blocked every sibling's
  stream classification on the parent event loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: CI trust-boundary + fail-closed repairs (adversarial findings)

- Token/exec separation restored: slices-report (runs PR-authored code:
  bun install + the reconcile runner) drops to contents:read; the PR
  comment moves to a NEW slices-comment job holding the write token with
  ZERO repo code — no checkout, no bun, only downloaded artifacts + jq/gh.
  $GITHUB_ENV/BASH_ENV persistence is job-scoped, so the split is the
  boundary. The matrix-era report job had this property; the consolidation
  had regressed it. Pinned by the wiring test.
- Reconcile exit captured via PIPESTATUS[0] in BOTH lanes: GitHub's default
  run-step shell has no pipefail, so `$?` after `| tee` was tee's exit —
  the fail-closed gate was silently fail-open. Wiring test pins it.
- PR comment: final-attempt accounting restored the dropped COST
  accumulation (the dial read $0 forever), flaky passes render as the
  warning they are (never as failures), and a malformed tests[] artifact
  skips that file instead of aborting the whole comment under bash -e.
- Remaining mutable action tags pinned (free-tests upload-artifact,
  ci-image checkout/docker trio — the image publisher holds packages:write
  and feeds the secret-bearing lanes). restore-deps fallback installs
  --frozen-lockfile; register-gstack-skills validates skill names before
  its rm -rf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.77.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.77.0.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: cross-model doc-review fixes — flake-ledger env knobs, CI retry-on note, stale version comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: correct CHANGELOG receipt numbers to measured values

Gate census keys: 78 -> 77 (bun-imported E2E_TIERS count). Sweep receipt:
586 sites/176 files -> 499 sites/146 files, measured by running this
branch's spawnsync-timeout-tripwire against origin/main (exit 1, 499
violations across 146 unique files; green on this branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: slices-comment creates the PR comment via REST — the write-token job has no git context

The token/exec split gives slices-comment NO checkout by design, and gh's
pr-comment subcommand resolves the repo FROM git — it died with 'not a git
repository' on PR #2746's first run (the update-existing PATCH path was
already explicit-repo REST and worked). Create now posts through
gh api repos/.../issues/N/comments, and the wiring test pins that no
git-context-requiring comment call can creep back into the job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: startup-grace probes clear CI for local semantics; new probe pins the floor clamp

The two shim probes pass explicit 2s/4s graces, but in CI the runner clamps
any explicit grace up to the 300s floor (deliberate adversarial-review fix),
so 'silent API killed at the grace' died at the 30s work cap instead of 2s —
a deterministic red on every CI run, green locally. The probes now pin LOCAL
semantics with CI cleared (same save/restore pattern as their PATH shim),
and a fourth probe pins the clamp itself: CI=1 + 2s grace + 6s timeout must
kill at the 6s cap, still in the startup phase — proof an explicit low grace
cannot bypass the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 08:55:30 -07:00

1010 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Eval result persistence and comparison.
*
* EvalCollector accumulates test results, writes them to
* ~/.gstack/projects/$SLUG/evals/{version}-{branch}-{tier}-{timestamp}.json,
* prints a summary table, and auto-compares with the previous run.
*
* Comparison functions are exported for reuse by the eval:compare CLI.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
// v2: EvalTestEntry.harvest gains optional {insertions, deletions, net} and
// may be explicitly null (arm-benchmark harvest-failure taxonomy). Readers
// stay tolerant of v1 runs: no reader requires the new fields, and
// eval-compare only warns on version mismatch.
const SCHEMA_VERSION = 2;
const LEGACY_EVAL_DIR = path.join(os.homedir(), '.gstack-dev', 'evals');
/**
* Detect project-scoped eval dir via gstack-slug.
* Falls back to legacy ~/.gstack-dev/evals/ if slug detection fails.
*/
export function getProjectEvalDir(): string {
try {
// Try repo-local gstack-slug first, then global install
const localSlug = spawnSync('bash', ['-c', '.claude/skills/gstack/bin/gstack-slug 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null'], {
stdio: 'pipe', timeout: 3000,
});
const output = localSlug.stdout?.toString().trim();
if (output) {
const slugMatch = output.match(/^SLUG=(.+)$/m);
if (slugMatch && slugMatch[1]) {
const dir = path.join(os.homedir(), '.gstack', 'projects', slugMatch[1], 'evals');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
}
} catch { /* fall through */ }
return LEGACY_EVAL_DIR;
}
/**
* Lazy + memoized so importing this module never spawns the gstack-slug
* subprocess. Callers that pass an explicit dir or set GSTACK_EVAL_DIR
* (the sharded paid runner does, per shard) never pay for slug detection.
*/
let memoizedDefaultEvalDir: string | null = null;
function defaultEvalDir(): string {
if (memoizedDefaultEvalDir === null) memoizedDefaultEvalDir = getProjectEvalDir();
return memoizedDefaultEvalDir;
}
// --- Interfaces ---
export interface EvalTestEntry {
name: string;
suite: string;
tier: 'e2e' | 'llm-judge';
passed: boolean;
duration_ms: number;
cost_usd: number;
/** 1-based record attempt for this name in this run. bun's --retry leaves
* retried passes INVISIBLE in its text output (a fail→pass prints no
* (fail) line and recaps as a clean pass — probed on 1.3.10), so the ONLY
* reliable attempt signal is this in-process record: a retried test runs
* its body again and re-records under the same name. Set by addTest. */
attempt?: number;
// E2E
transcript?: any[];
prompt?: string;
output?: string;
turns_used?: number;
tokens_used?: number;
browse_errors?: string[];
// LLM judge
judge_scores?: Record<string, number>;
judge_reasoning?: string;
// Machine-readable diagnostics
exit_reason?: string; // 'success' | 'timeout' | 'error_max_turns' | 'exit_code_N'
timeout_at_turn?: number; // which turn was active when timeout hit
last_tool_call?: string; // e.g. "Write(review-output.md)"
// Model + timing diagnostics (added for Sonnet/Opus split)
model?: string; // e.g. 'claude-sonnet-4-6' or 'claude-opus-4-7'
first_response_ms?: number; // time from spawn to first NDJSON line
max_inter_turn_ms?: number; // peak latency between consecutive tool calls
// Outcome eval
detection_rate?: number;
false_positives?: number;
evidence_quality?: number;
detected_bugs?: string[];
missed_bugs?: string[];
error?: string;
// Diff harvest data. Two writers today:
// - WorktreeManager harvests set {filesChanged, patchPath, isDuplicate}.
// - Arm-benchmark cells (schema v2) set {filesChanged, insertions,
// deletions, net} from `git add -A && git diff --cached --stat`, and
// record an explicit `null` when harvest itself failed (failure
// taxonomy: a failed harvest is never silently dropped).
harvest?: {
filesChanged: number;
patchPath?: string;
isDuplicate?: boolean;
insertions?: number;
deletions?: number;
net?: number;
} | null;
}
export interface EvalResult {
schema_version: number;
version: string;
branch: string;
git_sha: string;
timestamp: string;
hostname: string;
/** `claude --version` first line at run time (schema-additive, optional).
* TUI drift broke the PTY harness three times before runs recorded which
* CLI they actually exercised. */
claude_cli_version?: string;
tier: 'e2e' | 'llm-judge';
total_tests: number;
passed: number;
failed: number;
total_cost_usd: number;
total_duration_ms: number;
wall_clock_ms?: number; // wall-clock from collector creation to finalization (shows parallelism)
tests: EvalTestEntry[];
/** Shard slug when the run was collected under <evalDir>/shards/<slug>/. */
shard?: string;
/** Tests recorded more than once this run — the flake ledger for the paid
* lane. A test passing on attempt 2 every week used to read permanently
* green (the retry's entry was indistinguishable and bun's output hides
* retries entirely). Present only when non-empty. */
flaky_retries?: Array<{ name: string; attempts: number }>;
_partial?: boolean; // true for incremental saves, absent in final
}
export interface TestDelta {
name: string;
before: { passed: boolean; cost_usd: number; turns_used?: number; duration_ms?: number;
detection_rate?: number; tool_summary?: Record<string, number> };
after: { passed: boolean; cost_usd: number; turns_used?: number; duration_ms?: number;
detection_rate?: number; tool_summary?: Record<string, number> };
status_change: 'improved' | 'regressed' | 'unchanged';
}
export interface ComparisonResult {
before_file: string;
after_file: string;
before_branch: string;
after_branch: string;
before_timestamp: string;
after_timestamp: string;
deltas: TestDelta[];
total_cost_delta: number;
total_duration_delta: number;
improved: number;
regressed: number;
unchanged: number;
tool_count_before: number;
tool_count_after: number;
/** After-tests that had a same-named entry in the before run. 0 = nothing was
* actually compared, so no stability claim is warranted. */
matched?: number;
}
// --- Shared helpers ---
/**
* Is this eval file an in-progress accumulator rather than a finalized run?
*
* True on either signal: the `_partial` flag inside the JSON (the authoritative
* role marker) OR a filename starting with `_partial` (catches accumulators
* whose body predates the flag, and flagged files that were renamed keep being
* caught by the flag). Every baseline lookup must exclude these — an
* accumulator carries the current run's tier, branch, and freshest timestamp,
* so treating it as a baseline makes the run compare against itself.
*/
export function isPartialEval(data: unknown, filename: string): boolean {
if (path.basename(filename).startsWith('_partial')) return true;
return Boolean((data as { _partial?: unknown } | null)?._partial);
}
/**
* Is this path a FINALIZED eval-store result file? Single owner of the
* filename taxonomy (manifest.json / slice-N.json are runner artifacts,
* _partial* are in-progress accumulators) — the paid runner's report mode
* and eval-flake-rank both consume this instead of re-encoding the rule
* (review finding: the rule lived in three places).
*/
export function isFinalizedEvalResultFile(relPath: string): boolean {
const base = path.basename(relPath);
if (!base.endsWith('.json')) return false;
if (base === 'manifest.json' || /^slice-\d+\.json$/.test(base)) return false;
if (base.startsWith('_partial')) return false;
return true;
}
/**
* List eval JSON files in `evalDir` plus one level of `<evalDir>/shards/<slug>/`
* subdirectories (where the sharded paid runner points each shard's collector).
* Returns absolute paths. Missing dirs yield [].
*/
export function listEvalJsonFiles(evalDir: string): string[] {
const jsonIn = (dir: string): string[] => {
let names: string[];
try {
names = fs.readdirSync(dir);
} catch {
return [];
}
return names.filter(f => f.endsWith('.json')).map(f => path.join(dir, f));
};
const files = jsonIn(evalDir);
const shardsRoot = path.join(evalDir, 'shards');
let shardDirs: fs.Dirent[];
try {
shardDirs = fs.readdirSync(shardsRoot, { withFileTypes: true });
} catch {
return files;
}
for (const entry of shardDirs) {
if (!entry.isDirectory()) continue;
files.push(...jsonIn(path.join(shardsRoot, entry.name)));
}
return files;
}
/**
* Shard slug for an eval dir: when the dir is directly under a `shards/`
* directory (the sharded paid runner's per-shard GSTACK_EVAL_DIR layout),
* the dir name is the slug; otherwise null.
*/
export function shardSlugOfEvalDir(evalDir: string): string | null {
const normalized = path.resolve(evalDir);
return path.basename(path.dirname(normalized)) === 'shards' ? path.basename(normalized) : null;
}
/**
* Find the most recent finalized (non-partial) eval file for a tier, scanning
* `evalDir` and one level of `shards/<slug>/` subdirs. Shared by the budget
* regression gate and any tooling that needs "the latest real run".
*/
export function findLatestFinalizedRun(
evalDir: string,
tier: 'e2e' | 'llm-judge',
): { filepath: string; result: EvalResult } | null {
let latest: { filepath: string; result: EvalResult; timestamp: string } | null = null;
for (const filepath of listEvalJsonFiles(evalDir)) {
let data: EvalResult;
try {
data = JSON.parse(fs.readFileSync(filepath, 'utf-8')) as EvalResult;
} catch { continue; }
if (isPartialEval(data, filepath)) continue;
if (data.tier !== tier) continue;
const timestamp = data.timestamp ?? '';
if (!latest || timestamp.localeCompare(latest.timestamp) > 0) {
latest = { filepath, result: data, timestamp };
}
}
return latest ? { filepath: latest.filepath, result: latest.result } : null;
}
/**
* Determine if a planted-bug eval passed based on judge results vs ground truth thresholds.
* Centralizes the pass/fail logic so all planted-bug tests use the same criteria.
*/
export function judgePassed(
judgeResult: { detection_rate: number; false_positives: number; evidence_quality: number },
groundTruth: { minimum_detection: number; max_false_positives: number },
): boolean {
return judgeResult.detection_rate >= groundTruth.minimum_detection
&& judgeResult.false_positives <= groundTruth.max_false_positives
&& judgeResult.evidence_quality >= 2;
}
// --- Comparison functions (exported for eval:compare CLI) ---
/**
* Extract tool call counts from a transcript.
* Returns e.g. { Bash: 8, Read: 3, Write: 1 }.
*/
export function extractToolSummary(transcript: any[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const event of transcript) {
if (event.type === 'assistant') {
const content = event.message?.content || [];
for (const item of content) {
if (item.type === 'tool_use') {
const name = item.name || 'unknown';
counts[name] = (counts[name] || 0) + 1;
}
}
}
}
return counts;
}
/**
* Find the most recent prior COMPLETED eval file for comparison.
* Scans the eval dir plus one level of `shards/<slug>/` subdirs. Prefers
* same shard slug (a shard's own history over another shard's or the flat
* dir's), then same branch, then falls back to anything.
*
* In-progress accumulators (`_partial: true`, written by savePartial after every
* test) are never candidates: the current run's own partial carries the current
* tier + branch and the freshest timestamp, so including it made every run
* compare against itself and report "no regressions" unconditionally. The
* exclusion is by role (the `_partial` flag), not by filename.
*/
export function findPreviousRun(
evalDir: string,
tier: string,
branch: string,
excludeFile: string,
): string | null {
// Parse top-level fields from each file (cheap — no full tests array needed)
const entries: Array<{ file: string; branch: string; timestamp: string; shard: string | null }> = [];
for (const fullPath of listEvalJsonFiles(evalDir)) {
if (path.resolve(fullPath) === path.resolve(excludeFile)) continue;
try {
const raw = fs.readFileSync(fullPath, 'utf-8');
// Quick parse — only grab the fields we need
const data = JSON.parse(raw);
if (isPartialEval(data, fullPath)) continue; // in-progress run, not a baseline
if (data.tier !== tier) continue;
entries.push({
file: fullPath,
branch: data.branch || '',
timestamp: data.timestamp || '',
shard: data.shard || shardSlugOfEvalDir(path.dirname(fullPath)),
});
} catch { continue; }
}
if (entries.length === 0) return null;
// Sort by timestamp descending
entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// Prefer same shard slug (null = the flat dir), then same branch, then any.
const targetShard = shardSlugOfEvalDir(path.dirname(excludeFile));
const preferences: Array<(e: typeof entries[number]) => boolean> = [
e => e.shard === targetShard && e.branch === branch,
e => e.shard === targetShard,
e => e.branch === branch,
];
for (const matches of preferences) {
const hit = entries.find(matches);
if (hit) return hit.file;
}
return entries[0].file;
}
/**
* Compare two eval results. Matches tests by name.
*/
export function compareEvalResults(
before: EvalResult,
after: EvalResult,
beforeFile: string,
afterFile: string,
): ComparisonResult {
const deltas: TestDelta[] = [];
let improved = 0, regressed = 0, unchanged = 0;
let toolCountBefore = 0, toolCountAfter = 0;
let matched = 0;
// Index before tests by name
const beforeMap = new Map<string, EvalTestEntry>();
for (const t of before.tests) {
beforeMap.set(t.name, t);
}
// Walk after tests, match by name
for (const afterTest of after.tests) {
const beforeTest = beforeMap.get(afterTest.name);
const beforeToolSummary = beforeTest?.transcript ? extractToolSummary(beforeTest.transcript) : {};
const afterToolSummary = afterTest.transcript ? extractToolSummary(afterTest.transcript) : {};
const beforeToolCount = Object.values(beforeToolSummary).reduce((a, b) => a + b, 0);
const afterToolCount = Object.values(afterToolSummary).reduce((a, b) => a + b, 0);
toolCountBefore += beforeToolCount;
toolCountAfter += afterToolCount;
let statusChange: TestDelta['status_change'] = 'unchanged';
if (beforeTest) {
matched++;
if (!beforeTest.passed && afterTest.passed) { statusChange = 'improved'; improved++; }
else if (beforeTest.passed && !afterTest.passed) { statusChange = 'regressed'; regressed++; }
else { unchanged++; }
} else {
// New test — treat as unchanged (no prior data)
unchanged++;
}
deltas.push({
name: afterTest.name,
before: {
passed: beforeTest?.passed ?? false,
cost_usd: beforeTest?.cost_usd ?? 0,
turns_used: beforeTest?.turns_used,
duration_ms: beforeTest?.duration_ms,
detection_rate: beforeTest?.detection_rate,
tool_summary: beforeToolSummary,
},
after: {
passed: afterTest.passed,
cost_usd: afterTest.cost_usd,
turns_used: afterTest.turns_used,
duration_ms: afterTest.duration_ms,
detection_rate: afterTest.detection_rate,
tool_summary: afterToolSummary,
},
status_change: statusChange,
});
beforeMap.delete(afterTest.name);
}
// Tests that were in before but not in after (removed tests)
for (const [name, beforeTest] of beforeMap) {
const beforeToolSummary = beforeTest.transcript ? extractToolSummary(beforeTest.transcript) : {};
const beforeToolCount = Object.values(beforeToolSummary).reduce((a, b) => a + b, 0);
toolCountBefore += beforeToolCount;
unchanged++;
deltas.push({
name: `${name} (removed)`,
before: {
passed: beforeTest.passed,
cost_usd: beforeTest.cost_usd,
turns_used: beforeTest.turns_used,
duration_ms: beforeTest.duration_ms,
detection_rate: beforeTest.detection_rate,
tool_summary: beforeToolSummary,
},
after: { passed: false, cost_usd: 0, tool_summary: {} },
status_change: 'unchanged',
});
}
return {
before_file: beforeFile,
after_file: afterFile,
before_branch: before.branch,
after_branch: after.branch,
before_timestamp: before.timestamp,
after_timestamp: after.timestamp,
deltas,
total_cost_delta: after.total_cost_usd - before.total_cost_usd,
total_duration_delta: after.total_duration_ms - before.total_duration_ms,
improved,
regressed,
unchanged,
tool_count_before: toolCountBefore,
tool_count_after: toolCountAfter,
matched,
};
}
/**
* Format a ComparisonResult as a readable string.
*/
export function formatComparison(c: ComparisonResult): string {
const lines: string[] = [];
const ts = c.before_timestamp ? c.before_timestamp.replace('T', ' ').slice(0, 16) : 'unknown';
lines.push(`\nvs previous: ${c.before_branch}/${c.deltas.length ? 'eval' : ''} (${ts})`);
lines.push('─'.repeat(70));
// Per-test deltas
for (const d of c.deltas) {
const arrow = d.status_change === 'improved' ? '↑' : d.status_change === 'regressed' ? '↓' : '=';
const beforeStatus = d.before.passed ? 'PASS' : 'FAIL';
const afterStatus = d.after.passed ? 'PASS' : 'FAIL';
// Turns delta
let turnsDelta = '';
if (d.before.turns_used !== undefined && d.after.turns_used !== undefined) {
const td = d.after.turns_used - d.before.turns_used;
turnsDelta = ` ${d.before.turns_used}${d.after.turns_used}t`;
if (td !== 0) turnsDelta += `(${td > 0 ? '+' : ''}${td})`;
} else if (d.after.turns_used !== undefined) {
turnsDelta = ` ${d.after.turns_used}t`;
}
// Duration delta
let durDelta = '';
if (d.before.duration_ms !== undefined && d.after.duration_ms !== undefined) {
const bs = Math.round(d.before.duration_ms / 1000);
const as = Math.round(d.after.duration_ms / 1000);
const dd = as - bs;
durDelta = ` ${bs}${as}s`;
if (dd !== 0) durDelta += `(${dd > 0 ? '+' : ''}${dd})`;
} else if (d.after.duration_ms !== undefined) {
durDelta = ` ${Math.round(d.after.duration_ms / 1000)}s`;
}
let detail = '';
if (d.before.detection_rate !== undefined || d.after.detection_rate !== undefined) {
detail = ` ${d.before.detection_rate ?? '?'}${d.after.detection_rate ?? '?'} det`;
} else {
const costBefore = d.before.cost_usd.toFixed(2);
const costAfter = d.after.cost_usd.toFixed(2);
detail = ` $${costBefore}→$${costAfter}`;
}
const name = d.name.length > 30 ? d.name.slice(0, 27) + '...' : d.name.padEnd(30);
lines.push(` ${name} ${beforeStatus.padEnd(5)}${afterStatus.padEnd(5)} ${arrow}${detail}${turnsDelta}${durDelta}`);
}
lines.push('─'.repeat(70));
// Totals
const parts: string[] = [];
if (c.improved > 0) parts.push(`${c.improved} improved`);
if (c.regressed > 0) parts.push(`${c.regressed} regressed`);
if (c.unchanged > 0) parts.push(`${c.unchanged} unchanged`);
lines.push(` Status: ${parts.join(', ')}`);
const costSign = c.total_cost_delta >= 0 ? '+' : '';
lines.push(` Cost: ${costSign}$${c.total_cost_delta.toFixed(2)}`);
const durDelta = Math.round(c.total_duration_delta / 1000);
const durSign = durDelta >= 0 ? '+' : '';
lines.push(` Duration: ${durSign}${durDelta}s`);
const toolDelta = c.tool_count_after - c.tool_count_before;
const toolSign = toolDelta >= 0 ? '+' : '';
lines.push(` Tool calls: ${c.tool_count_before}${c.tool_count_after} (${toolSign}${toolDelta})`);
// Tool breakdown (show tools that changed)
const allTools = new Set<string>();
for (const d of c.deltas) {
for (const t of Object.keys(d.before.tool_summary || {})) allTools.add(t);
for (const t of Object.keys(d.after.tool_summary || {})) allTools.add(t);
}
if (allTools.size > 0) {
// Aggregate tool counts across all tests
const totalBefore: Record<string, number> = {};
const totalAfter: Record<string, number> = {};
for (const d of c.deltas) {
for (const [t, n] of Object.entries(d.before.tool_summary || {})) {
totalBefore[t] = (totalBefore[t] || 0) + n;
}
for (const [t, n] of Object.entries(d.after.tool_summary || {})) {
totalAfter[t] = (totalAfter[t] || 0) + n;
}
}
for (const tool of [...allTools].sort()) {
const b = totalBefore[tool] || 0;
const a = totalAfter[tool] || 0;
if (b !== a) {
const d = a - b;
lines.push(` ${tool}: ${b}${a} (${d >= 0 ? '+' : ''}${d})`);
}
}
}
// Commentary — interpret what the deltas mean
const commentary = generateCommentary(c);
if (commentary.length > 0) {
lines.push('');
lines.push(' Takeaway:');
for (const line of commentary) {
lines.push(` ${line}`);
}
}
return lines.join('\n');
}
/**
* Generate human-readable commentary interpreting comparison deltas.
* Pure function — analyzes the numbers and explains what they mean.
*/
export function generateCommentary(c: ComparisonResult): string[] {
const notes: string[] = [];
// 1. Regressions are the most important signal — call them out first
const regressions = c.deltas.filter(d => d.status_change === 'regressed');
if (regressions.length > 0) {
for (const d of regressions) {
notes.push(`REGRESSION: "${d.name}" was passing, now fails. Investigate immediately.`);
}
}
// 2. Improvements
const improvements = c.deltas.filter(d => d.status_change === 'improved');
for (const d of improvements) {
notes.push(`Fixed: "${d.name}" now passes.`);
}
// 3. Per-test efficiency changes (only for unchanged-status tests — regressions/improvements are already noted)
const stable = c.deltas.filter(d => d.status_change === 'unchanged' && d.after.passed);
for (const d of stable) {
const insights: string[] = [];
// Turns
if (d.before.turns_used !== undefined && d.after.turns_used !== undefined && d.before.turns_used > 0) {
const turnsDelta = d.after.turns_used - d.before.turns_used;
const turnsPct = Math.round((turnsDelta / d.before.turns_used) * 100);
if (Math.abs(turnsPct) >= 20 && Math.abs(turnsDelta) >= 2) {
if (turnsDelta < 0) {
insights.push(`${Math.abs(turnsDelta)} fewer turns (${Math.abs(turnsPct)}% more efficient)`);
} else {
insights.push(`${turnsDelta} more turns (${turnsPct}% less efficient)`);
}
}
}
// Duration
if (d.before.duration_ms !== undefined && d.after.duration_ms !== undefined && d.before.duration_ms > 0) {
const durDelta = d.after.duration_ms - d.before.duration_ms;
const durPct = Math.round((durDelta / d.before.duration_ms) * 100);
if (Math.abs(durPct) >= 20 && Math.abs(durDelta) >= 5000) {
if (durDelta < 0) {
insights.push(`${Math.round(Math.abs(durDelta) / 1000)}s faster`);
} else {
insights.push(`${Math.round(durDelta / 1000)}s slower`);
}
}
}
// Detection rate
if (d.before.detection_rate !== undefined && d.after.detection_rate !== undefined) {
const detDelta = d.after.detection_rate - d.before.detection_rate;
if (detDelta !== 0) {
if (detDelta > 0) {
insights.push(`detecting ${detDelta} more bug${detDelta > 1 ? 's' : ''}`);
} else {
insights.push(`detecting ${Math.abs(detDelta)} fewer bug${Math.abs(detDelta) > 1 ? 's' : ''} — check prompt quality`);
}
}
}
// Cost
if (d.before.cost_usd > 0) {
const costDelta = d.after.cost_usd - d.before.cost_usd;
const costPct = Math.round((costDelta / d.before.cost_usd) * 100);
if (Math.abs(costPct) >= 30 && Math.abs(costDelta) >= 0.05) {
if (costDelta < 0) {
insights.push(`${Math.abs(costPct)}% cheaper`);
} else {
insights.push(`${costPct}% more expensive`);
}
}
}
if (insights.length > 0) {
notes.push(`"${d.name}": ${insights.join(', ')}.`);
}
}
// 4. No baseline — say so. A run with nothing to compare against must never
// read as "stable"; silence or a false all-clear is worse than no output.
if (c.matched === 0 && c.deltas.length > 0) {
notes.push(
`NO BASELINE: none of these ${c.deltas.length} test(s) appear in ${path.basename(c.before_file)}. ` +
'Nothing was compared, so this run says nothing about regressions.',
);
return notes;
}
// 5. Overall summary
if (c.deltas.length >= 3 && regressions.length === 0) {
const overallParts: string[] = [];
// Total cost
const totalBefore = c.deltas.reduce((s, d) => s + d.before.cost_usd, 0);
if (totalBefore > 0) {
const costPct = Math.round((c.total_cost_delta / totalBefore) * 100);
if (Math.abs(costPct) >= 10) {
overallParts.push(`${Math.abs(costPct)}% ${costPct < 0 ? 'cheaper' : 'more expensive'} overall`);
}
}
// Total duration
const totalDurBefore = c.deltas.reduce((s, d) => s + (d.before.duration_ms || 0), 0);
if (totalDurBefore > 0) {
const durPct = Math.round((c.total_duration_delta / totalDurBefore) * 100);
if (Math.abs(durPct) >= 10) {
overallParts.push(`${Math.abs(durPct)}% ${durPct < 0 ? 'faster' : 'slower'}`);
}
}
// Total turns
const turnsBefore = c.deltas.reduce((s, d) => s + (d.before.turns_used || 0), 0);
const turnsAfter = c.deltas.reduce((s, d) => s + (d.after.turns_used || 0), 0);
if (turnsBefore > 0) {
const turnsPct = Math.round(((turnsAfter - turnsBefore) / turnsBefore) * 100);
if (Math.abs(turnsPct) >= 10) {
overallParts.push(`${Math.abs(turnsPct)}% ${turnsPct < 0 ? 'fewer' : 'more'} turns`);
}
}
if (overallParts.length > 0) {
notes.push(`Overall: ${overallParts.join(', ')}. ${regressions.length === 0 ? 'No regressions.' : ''}`);
} else if (regressions.length === 0) {
notes.push('Stable run — no significant efficiency changes, no regressions.');
}
}
return notes;
}
// --- Budget regression assertion ---
export interface BudgetRegression {
testName: string;
metric: 'tools' | 'turns';
before: number;
after: number;
ratio: number;
}
/**
* Compute budget regressions: tests where tool calls or turns grew by more
* than `ratioCap` between two runs. Pure function — caller decides how to
* surface the result. Used by test/skill-budget-regression.test.ts and any
* future ship gate.
*
* `ratioCap` defaults to 2.0 (>2× growth is a regression). Override via
* `GSTACK_BUDGET_RATIO` env var. New tests with no prior data are skipped.
*/
export function findBudgetRegressions(
comparison: ComparisonResult,
opts?: { ratioCap?: number; minPriorTools?: number; minPriorTurns?: number },
): BudgetRegression[] {
const envRatio = Number(process.env.GSTACK_BUDGET_RATIO);
const cap = opts?.ratioCap ?? (Number.isFinite(envRatio) && envRatio > 0 ? envRatio : 2.0);
// Floors avoid noise on tiny numbers (1 → 3 tools is 3× but meaningless).
const minPriorTools = opts?.minPriorTools ?? 5;
const minPriorTurns = opts?.minPriorTurns ?? 3;
const out: BudgetRegression[] = [];
for (const d of comparison.deltas) {
const beforeTools = Object.values(d.before.tool_summary ?? {}).reduce((a, b) => a + b, 0);
const afterTools = Object.values(d.after.tool_summary ?? {}).reduce((a, b) => a + b, 0);
const beforeTurns = d.before.turns_used ?? 0;
const afterTurns = d.after.turns_used ?? 0;
if (beforeTools >= minPriorTools && afterTools / beforeTools > cap) {
out.push({ testName: d.name, metric: 'tools', before: beforeTools, after: afterTools, ratio: afterTools / beforeTools });
}
if (beforeTurns >= minPriorTurns && afterTurns / beforeTurns > cap) {
out.push({ testName: d.name, metric: 'turns', before: beforeTurns, after: afterTurns, ratio: afterTurns / beforeTurns });
}
}
return out;
}
/**
* Throw if any test in the comparison exceeds the budget cap. Convenience
* wrapper around findBudgetRegressions for use in test assertions.
*/
export function assertNoBudgetRegression(
comparison: ComparisonResult,
opts?: { ratioCap?: number; minPriorTools?: number; minPriorTurns?: number },
): void {
const regressions = findBudgetRegressions(comparison, opts);
if (regressions.length === 0) return;
const cap = opts?.ratioCap ?? (Number(process.env.GSTACK_BUDGET_RATIO) || 2.0);
const lines = regressions.map(
r => ` "${r.testName}" ${r.metric}: ${r.before}${r.after} (${r.ratio.toFixed(2)}× > ${cap.toFixed(2)}× cap)`,
);
throw new Error(
`Budget regression: ${regressions.length} test(s) exceeded ${cap.toFixed(2)}× prior usage:\n` +
lines.join('\n') +
`\n(Override per run: GSTACK_BUDGET_RATIO=<n>. ${comparison.before_file} vs ${comparison.after_file})`,
);
}
// --- EvalCollector ---
function getGitInfo(): { branch: string; sha: string } {
try {
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { stdio: 'pipe', timeout: 5000 });
const sha = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { stdio: 'pipe', timeout: 5000 });
return {
branch: branch.stdout?.toString().trim() || 'unknown',
sha: sha.stdout?.toString().trim() || 'unknown',
};
} catch {
return { branch: 'unknown', sha: 'unknown' };
}
}
function getVersion(): string {
try {
const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return pkg.version || 'unknown';
} catch {
return 'unknown';
}
}
// Cached per process: savePartial runs after EVERY test and must not pay a
// CLI spawn each time. Three separate harness breakages were traced to
// claude-CLI TUI drift only after long flake hunts — stamping the version
// into every run record makes that correlation a grep instead of an
// archaeology dig.
//
// GSTACK_CLAUDE_CLI_VERSION short-circuits the spawn entirely: the paid
// runner's parent resolves the version once and passes it to every shard,
// so test processes never block on it. The fallback spawn is SYNCHRONOUS on
// the same thread that polls PTY sessions — the judgePtyState blocking
// class — so its budget is a tight 3s, not a generous one: a slow/hung CLI
// costs one bounded stall per process and records 'unknown'.
let claudeCliVersionCache: string | null = null;
export function getClaudeCliVersion(): string {
if (claudeCliVersionCache !== null) return claudeCliVersionCache;
const fromEnv = process.env.GSTACK_CLAUDE_CLI_VERSION;
if (fromEnv) {
claudeCliVersionCache = fromEnv;
return claudeCliVersionCache;
}
try {
const result = spawnSync('claude', ['--version'], { stdio: 'pipe', timeout: 3_000 });
claudeCliVersionCache = result.stdout?.toString().split('\n')[0].trim() || 'unknown';
} catch {
claudeCliVersionCache = 'unknown';
}
return claudeCliVersionCache;
}
export class EvalCollector {
private tier: 'e2e' | 'llm-judge';
private tests: EvalTestEntry[] = [];
private finalized = false;
private evalDir: string;
private shard: string | null;
private createdAt = Date.now();
constructor(tier: 'e2e' | 'llm-judge', evalDir?: string) {
this.tier = tier;
this.evalDir = evalDir || process.env.GSTACK_EVAL_DIR || defaultEvalDir();
this.shard = shardSlugOfEvalDir(this.evalDir);
}
addTest(entry: EvalTestEntry): void {
// Same-name re-record = the test body ran again = bun retried it (test
// names are unique by convention). Stamp the 1-based attempt so a
// pass-on-attempt-2 stays visible forever — the stream hides it.
const prior = this.tests.filter((t) => t.name === entry.name).length;
this.tests.push({ ...entry, attempt: prior + 1 });
this.savePartial();
}
/** Names recorded more than once this run, with their attempt counts. */
private flakyRetries(): Array<{ name: string; attempts: number }> {
const counts = new Map<string, number>();
for (const t of this.tests) counts.set(t.name, (counts.get(t.name) ?? 0) + 1);
return [...counts.entries()]
.filter(([, n]) => n > 1)
.map(([name, attempts]) => ({ name, attempts }));
}
/** Write incremental results after each test. Atomic write, non-fatal. */
savePartial(): void {
try {
const git = getGitInfo();
const version = getVersion();
const totalCost = this.tests.reduce((s, t) => s + t.cost_usd, 0);
const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0);
const passed = this.tests.filter(t => t.passed).length;
const partial: EvalResult = {
schema_version: SCHEMA_VERSION,
version,
branch: git.branch,
git_sha: git.sha,
timestamp: new Date().toISOString(),
hostname: os.hostname(),
claude_cli_version: getClaudeCliVersion(),
tier: this.tier,
total_tests: this.tests.length,
passed,
failed: this.tests.length - passed,
total_cost_usd: Math.round(totalCost * 100) / 100,
total_duration_ms: totalDuration,
tests: this.tests,
...(this.shard ? { shard: this.shard } : {}),
_partial: true,
};
fs.mkdirSync(this.evalDir, { recursive: true });
const partialPath = path.join(this.evalDir, '_partial-e2e.json');
const tmp = partialPath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(partial, null, 2) + '\n');
fs.renameSync(tmp, partialPath);
} catch { /* non-fatal — partial saves are best-effort */ }
}
async finalize(): Promise<string> {
if (this.finalized) return '';
this.finalized = true;
const git = getGitInfo();
const version = getVersion();
const timestamp = new Date().toISOString();
const totalCost = this.tests.reduce((s, t) => s + t.cost_usd, 0);
const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0);
const passed = this.tests.filter(t => t.passed).length;
const flaky = this.flakyRetries();
const result: EvalResult = {
schema_version: SCHEMA_VERSION,
version,
branch: git.branch,
git_sha: git.sha,
timestamp,
hostname: os.hostname(),
claude_cli_version: getClaudeCliVersion(),
tier: this.tier,
total_tests: this.tests.length,
passed,
failed: this.tests.length - passed,
total_cost_usd: Math.round(totalCost * 100) / 100,
total_duration_ms: totalDuration,
wall_clock_ms: Date.now() - this.createdAt,
tests: this.tests,
...(this.shard ? { shard: this.shard } : {}),
...(flaky.length > 0 ? { flaky_retries: flaky } : {}),
};
// Write eval file
fs.mkdirSync(this.evalDir, { recursive: true });
const dateStr = timestamp.replace(/[:.]/g, '').replace('T', '-').slice(0, 15);
const safeBranch = git.branch.replace(/[^a-zA-Z0-9._-]/g, '-');
const filename = `${version}-${safeBranch}-${this.tier}-${dateStr}.json`;
const filepath = path.join(this.evalDir, filename);
fs.writeFileSync(filepath, JSON.stringify(result, null, 2) + '\n');
// Print summary table
this.printSummary(result, filepath, git);
// Auto-compare with previous run
try {
const prevFile = findPreviousRun(this.evalDir, this.tier, git.branch, filepath);
if (prevFile) {
const prevResult: EvalResult = JSON.parse(fs.readFileSync(prevFile, 'utf-8'));
const comparison = compareEvalResults(prevResult, result, prevFile, filepath);
process.stderr.write(formatComparison(comparison) + '\n');
} else {
process.stderr.write(
`\nNO BASELINE: no completed prior ${this.tier} run found in ${this.evalDir}` +
' (the in-progress accumulator is not a baseline). Nothing compared —' +
' this run says nothing about regressions.\n',
);
}
} catch (err: any) {
process.stderr.write(`\nCompare error: ${err.message}\n`);
}
return filepath;
}
private printSummary(result: EvalResult, filepath: string, git: { branch: string; sha: string }): void {
const lines: string[] = [];
lines.push('');
lines.push(`Eval Results — v${result.version} @ ${git.branch} (${git.sha}) — ${this.tier}`);
lines.push('═'.repeat(70));
for (const t of this.tests) {
const status = t.passed ? ' PASS ' : ' FAIL ';
const cost = `$${t.cost_usd.toFixed(2)}`;
const dur = t.duration_ms ? `${Math.round(t.duration_ms / 1000)}s` : '';
const turns = t.turns_used !== undefined ? `${t.turns_used}t` : '';
let detail = '';
if (t.detection_rate !== undefined) {
detail = `${t.detection_rate}/${(t.detected_bugs?.length || 0) + (t.missed_bugs?.length || 0)} det`;
} else if (t.judge_scores) {
const scores = Object.entries(t.judge_scores).map(([k, v]) => `${k[0]}:${v}`).join(' ');
detail = scores;
}
const name = t.name.length > 35 ? t.name.slice(0, 32) + '...' : t.name.padEnd(35);
lines.push(` ${name} ${status} ${cost.padStart(6)} ${turns.padStart(4)} ${dur.padStart(5)} ${detail}`);
}
lines.push('─'.repeat(70));
const totalCost = `$${result.total_cost_usd.toFixed(2)}`;
const totalDur = `${Math.round(result.total_duration_ms / 1000)}s`;
lines.push(` Total: ${result.passed}/${result.total_tests} passed${' '.repeat(20)}${totalCost.padStart(6)} ${totalDur}`);
if (result.flaky_retries && result.flaky_retries.length > 0) {
// Loud, never fatal: a flaky pass must not block anyone, but it must
// never be silent either — that invisibility is how flakes calcified.
lines.push(` ⚠ FLAKY: ${result.flaky_retries.length} test(s) recorded multiple attempts this run: `
+ result.flaky_retries.map((f) => `${f.name} (x${f.attempts})`).join(', '));
}
lines.push(`Saved: ${filepath}`);
process.stderr.write(lines.join('\n') + '\n');
}
}