# Testing internals: env keys, hermetic E2E Moved verbatim from CLAUDE.md (token-load reduction). Read this before writing or debugging E2E tests, passing `env:` to a runner, or touching `test/helpers/hermetic-env.ts`. **Env keys in Conductor workspaces.** The `GSTACK_*` env-shim (v1.39.2.0+, `lib/conductor-env-shim.ts`) promotes `GSTACK_ANTHROPIC_API_KEY` / `GSTACK_OPENAI_API_KEY` to their canonical names inside gstack's TS binaries. Tests run through gstack entrypoints inherit this promotion automatically. Don't echo the key value to stdout, logs, or shell history. The historical "never pass `env:` to `runAgentSdkTest`" rule is retired: the failure was partial-env replacement (the SDK's `Options.env` REPLACES the child's entire environment, so an object without the key broke auth). The runner now always passes a COMPLETE hermetic env with per-test `env:` merged last, so per-test overrides are safe; ambient `process.env.ANTHROPIC_API_KEY` mutation also still works (the env builder reads process.env at call time). **Hermetic local E2E (default).** Every E2E runner (claude -p, PTY, Agent SDK, codex, gemini) spawns children through `test/helpers/hermetic-env.ts`: allowlist-scrubbed env (operator `CONDUCTOR_*`, `CLAUDE_*`, `GSTACK_*`, `MCP_*`, `GBRAIN_*`, and credentials like `GH_TOKEN` never reach children), a fresh seeded `CLAUDE_CONFIG_DIR` (no operator `~/.claude` CLAUDE.md / MCP servers / skills), a temp `GSTACK_HOME`, and `--strict-mcp-config`. Local eval signal matches CI. Debug against real operator state with `EVALS_HERMETIC=0` (restores the legacy env AND drops the strict-MCP flag). Per-test `env:` overrides merge last, so deliberate contamination (`CONDUCTOR_WORKSPACE_PATH`, per-test `GSTACK_HOME`) keeps working. The hermetic config dir seeds NO skills by default; a PTY test that types a `/skill` slash command must pass `seedSkills: true` to the PTY runner, which points the child's `CLAUDE_CONFIG_DIR` at `hermeticSkillsConfigDir()` — a seeded registry that symlinks the LIVE working tree's SKILL.md files (by design: the skills ARE the subject under test; a snapshot would measure stale copies). Wiring is pinned by `test/hermetic-wiring.test.ts` (static tripwire), two gate-tier canaries in `test/skill-e2e-hermetic-canary.test.ts`, and the seeding tripwires in `test/hermetic-skills-seeding.test.ts` / `test/pty-skill-seeding-wiring.test.ts`. Seeded planning sessions also receive an isolated runtime home through `test/helpers/hermetic-skill-runtime.ts`, so absolute lazy-section paths resolve to the working tree under test. Explicit per-test home overrides remain intact. Autoplan resolves each review skill from its own installed host registry. **Interactive planning evidence.** Native plan-review count drivers use `observeScreen: true` and await `currentScreen()` before choosing an input. The existing xterm dependency interprets cursor moves and erases; old menus in the raw stream cannot establish a current prompt. Snapshots preserve `terminal.raw.log`, `terminal.visible.log`, and `terminal.screen.log` separately. Setting `EVALS_RUN_ID` or `GSTACK_EVAL_DIR` retains these snapshots; an output directory without a run ID gets a stable, unique local ID for that writer. Completed native transcript calls establish question counts and phase coverage. Report-aware count tests also require a fresh, complete report and native completion evidence before accepting a completion heading. The periodic first-question matrix uses `test/helpers/auq-native-capture.ts` to match the first public `PreToolUse` AskUserQuestion payload to its current native display. It grades that question's exact public fields without answering it or reading model transcripts. `question_captured` records `workflowCompleted: false`. With `GSTACK_EVAL_DIR`, `EVALS_RUN_ID`, or an explicit run ID, `native-auq//-/capture.json` under the eval directory retains the public payload, bounded current viewport, and capture outcome. CEO mode selection uses the actual SDK `AskUserQuestion` permission callback in `auq-sdk-capture.ts`, with the existing 12-turn and 240-second limits. It captures the public question and stops without submitting an answer; its `question_captured` outcome also records `workflowCompleted: false`. The retained capture survives fixture cleanup. Provider refusals and malformed questions remain failures. Section-loading captures retain their noninteractive contract. Shared-code revalidation fixtures pair public tool calls with their successful results to verify that the current trusted start record was inspected before completion. A discovered path in tool output counts; a path mentioned only in instructions or narration does not. Saved public captures cover absolute and relative paths and discovery followed by a read. The revalidation prompt supplies the path to the trusted start-record directory and declares the existing turn limit. It asks the agent to batch independent reads and retrieve the complete final record; every source, approval, persistence, and completion check still applies. The path-boundary fixtures use this same execution contract for symlinks, submodules, ignored files, index flags, and legacy or filtered evidence. Their skip actor accepts an explicit no-change choice; a preservation word inside an option that also approves changes cannot authorize edits. Captured native questions exercise the actual answer callback, and native turn-limit failures still fail even after a question was answered. The engineering and DX finding fixtures check coverage of their seeded issues rather than cap the total number of review questions. Each decision needs a distinct, completed native question with an offered answer; accepting, rejecting, or deferring a recommendation all count as reviewing it. Engineering's mandatory legacy regression tests also need affirmative plan or public-narration evidence. Additional useful questions are allowed within the existing time limits. Generic question counts remain diagnostic, and a fresh final review report is required. E2E tests stream progress in real-time (tool-by-tool via `--output-format stream-json --verbose`). Results are persisted to `~/.gstack/projects//evals/` (legacy fallback `~/.gstack-dev/evals/`) with auto-comparison against the previous finalized run (in-flight `_partial` files are never used as a baseline, so a run can't compare against itself). The periodic overlay fixtures use a versioned behavior gate with efficacy reported separately. See [Overlay benchmark contract v4](OVERLAY_BENCHMARK_CONTRACT.md) for exact correctness requirements, retired fanout cases, immutable evidence, and the limits of a passing result. Under v4 a completed wrong answer is a measured correctness result in either arm (`taskCorrect: false` with `answerError`); only an overlay-ON wrong answer fails the case, so an overlay-OFF wrong answer stays in the comparison. v3 verdicts keep their original meaning. ## Coverage ownership [The test portfolio audit](TEST_PORTFOLIO.md) separates deterministic harness checks, prompt judges, live first-question captures, completed workflows and platform integrations. Shared setup is reusable; evidence with different scenario or independent-trial requirements is not. It records the preserved coverage and measured component savings from the test-speed refactor. ## Runners: how the suites execute (2026-08 overhaul) **Aside-only E2E tests self-skip without a live Aside; browser-driving tests run on either engine.** Every skill that opens a web page drives the Aside AI browser first (`scripts/resolvers/aside.ts`) and falls back to gstack's own browse engine when Aside is absent (and Chromium bootstrapped). The cases that need Aside itself (`test/skill-e2e-aside.test.ts`, `design-review-fix` in `test/skill-e2e-design.test.ts`) call `asideAvailable()` from `test/helpers/aside-available.ts` (the same probe the skills run in BROWSER SETUP) and skip when the `aside` CLI or the Aside app is absent. CI runners have no Aside, so those run only on macOS dev machines and sit in the periodic tier; set `GSTACK_SKIP_ASIDE=1` to force the skip locally (which also exercises the fallback hand-off). The qa E2E files (`test/skill-e2e-qa-workflow.test.ts`, `test/skill-e2e-qa-bugs.test.ts`) gate on `asideAvailable() || fs.existsSync(browseBin)`: the skill's own BROWSER SETUP picks the engine, so on a Mac they drive Aside and on Linux CI they drive the built browse binary, skipping only when neither exists. The `$B`-driven E2E cases and `browse/test/` run on every platform as before, so Linux CI proves the fallback engine live. **Bootstrap dependency retention is opt-in qualification, not the behavior test.** `qa-bootstrap` still runs its original unpinned Vitest installation and assertions on macOS and Linux, including documented unsharded commands. Only a Linux paid shard runner issues the owned retention scope: it binds each actual fixture and native lifetime, retains locks, package manifests and the installed file/link inventory, and acknowledges capture before deleting the fixture. The outer runner also captures evidence when a callback is killed. Incomplete capture fails qualification and preserves the source fixture as well as partial evidence. Other platforms explicitly report retention as unavailable and still execute the native behavior test. A run without the runner-issued scope earns no retained dependency qualification credit; candidate acceptance requiring that evidence must use the Linux sharded path and verify every attempt's complete capture, acknowledgment and cleanup fallback. A passing unsharded or macOS behavior test does not substitute for that evidence. **The renderer picks the same way, so the render gates are engine-agnostic.** `/make-pdf`, `/diagram`, and design previews print and screenshot their local HTML through `lib/aside-render.ts` / `bin/gstack-render.ts`, which render in Aside when `probeAside()` says `READY` and through the browse engine otherwise. make-pdf's `*-gate.test.ts` and `test/skill-e2e-diagram.test.ts` (periodic, paid) gate on `browserAvailable()` (`test/helpers/browser-available.ts`: `asideAvailable() || resolveBrowseBin() !== null`) — on a Mac they print through Aside, on Linux CI through the browse binary `bun run build:gates` compiles, and they skip only when neither exists. Only `test/aside-render.test.ts`'s two live Aside cases (a full round-trip and a late-readiness `--wait-expr` poll) are Aside-only: its option mapping and generated-script pins run everywhere, and its fake-executable cases drive both engines hermetically (fake `aside` / `browse` scripts on PATH pin probe classification, the stdout contract, loopback-server policy, the timeout kill, engine choice and the mid-run fallback). `test/gstack-render-cli.test.ts` does the same for `bin/gstack-render.ts` with `GSTACK_SKIP_ASIDE=1` and `GSTACK_BROWSE_BIN` pointed at a fake daemon that logs every argv line. A green gate on Linux proves the fallback engine, not Aside; the Mac run is the Aside evidence. The browse-binary leg presumes Chromium bootstrapped: `resolveBrowseBin()` only checks that the binary (or the `find-browse` shim) exists, never that Chromium can launch, so on an install where the best-effort Chromium step was skipped (`GSTACK_SKIP_PLAYWRIGHT=1`) or failed, these gates run and fail at browser launch instead of skipping. Fix the bootstrap (or move the binary aside) before running them locally; CI always installs Chromium first. `test/dom-dump-hygiene.test.ts` is the one free-suite case on the same leg: it runs `lib/dom-dump.js` (the rendered-DOM dump `/design-review` hands the design detector) inside a real Chromium page through the built browse binary, so it self-skips when the binary is absent and, because a cold Chromium launch is load-sensitive on a busy dev box, runs only in CI or on explicit opt-in (`GSTACK_DOM_DUMP_HYGIENE=1`). **Free suite (`bun run test:free`).** `scripts/test-free-shards.ts` runs N concurrent shard processes (serial within each) with strict-output classification per shard. Local defaults use the available CPU affinity (which Bun also bounds by a container's cgroup CPU quota), floored at one and capped at 16 on Linux and six on macOS and Windows; `GSTACK_FREE_JOBS` remains an explicit override. This does not change the separate CI machine count. Full-suite shards are packed by RECORDED PER-FILE DURATIONS (LPT, `packShardsByDuration`) when the committed seed `scripts/free-test-durations.json` exists — refresh it with `bun run test:ubicloud --record-durations`, which times each file in its own child on a VM with the CI lane's environment and copies the seed back; see [Free suite duration seed](#free-suite-duration-seed). Missing seed → silent hash-shard fallback; corrupt seed → one warning + fallback; unknown files get 99th-percentile pessimism, and both full-suite and `--ci-plan` runs name them on stderr so a new slow file cannot silently become the long pole. Packed shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses one duration-packed `--ci-plan`, 20 ordinary `--ci-run` shards plus a separate exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on independent machines without ordering unrelated jobs. The public `TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole entry is `test/bootstrap-retention.test.ts`, whose same-UID nondumpable actors affect host-wide procfs permission checks. Locally, this file runs only after all parallel shards settle, and cancellation prevents that final phase from starting. No selected files, retries, budgets, or receipt requirements are removed. Former generator mutators still render into private output directories; this serial phase protects process visibility, not in-place doc generation. Full child output is retained in private files under `.context/free-test-logs/`, outside each shard's temporary cleanup directory. The runner prints the path at launch and completion. Losing the log fails the run even when the child exits successfully. A redirected log directory is rejected before launching a child. On failure, read that log first: the recovery message distinguishes incomplete capture, unconfirmed cleanup, deadline expiry and a test/module failure. Fix the demonstrated cause before rerunning. A focused `bun test` command is offered only when every failure is attributable to existing selected files; it proves that repair, not completion of the original selection. Preserve failed attempts when sharing results, and inspect logs for private data before sharing them. Before publication, classify new deterministic regressions for quick feedback. Refresh the timing seed with the existing recorder on fixed inputs; do not edit source while tests run. Critical boundary controls belong in `QUICK_CORE` when their feedback cost is justified. Other measured files qualify at two seconds or less; slow and unmeasured files remain outside quick, not outside full tests. Report cold setup separately from warm execution, while retaining failed-attempt, retry and cleanup time in the total cost. **PTY fixture timing.** Plan-count sessions wake on terminal output or exit, with at least 250ms between expensive observations and a 2s fallback for transcript or hook changes that produce no terminal output. New output batches settle for 250ms before observation so split terminal redraws cannot route input from their first chunk. Input debounces, permission guards, and the real CLI's 8s startup grace are unchanged. Synthetic CLIs can pass `startupReadyMarker` to `runPlanSkillCounting` and emit that exact marker after installing their input handlers; a missing marker fails before any command is sent. The marker wait stays inside the existing startup and total-run deadlines. `test/pty-output-wake.test.ts` covers output, silent waits, exit, close, missing readiness, split redraws, and continuous redraws. Close and output waits cancel their losing deadlines so completed workers can exit immediately. The UI-positive design gate commits the supplied plan as `review-input.md` before the first model turn. It requires an acknowledged Design focus/rating question followed by an answered native finding about concrete UI behavior; setup and outside-review choices cannot satisfy that sequence. Its declared board actor submits feedback before acknowledging it. The final proof uses the full native question, not the truncated diagnostic snippet, and proposal text mentioning "no UI scope" is not treated as an exit verdict. Unknown-command failures must name the invoked slash command; a child tool rejecting `--help` is not a skill registration failure. Periodic seeded-finding classifiers separately verify fixture-owned findings. **Paid suite (sharded runner, local AND CI).** `scripts/test-paid-shards.ts` is the single selection engine: 1 file per shard, `EVALS_JOBS` shard processes × `EVALS_CONCURRENCY` within-shard, per-shard `GSTACK_EVAL_DIR`, full-stream spooling to per-shard log files (path printed at START and on failure), never-started/timed-out taxonomy, and parent-computed diff selection propagated to children via `EVALS_SELECTION_JSON` (fail-open: a child that can't parse it recomputes locally with one warning). Paid evals never retry; each case's kind fixes its trials before the run (see "Eval verdict policy" below). Files in `CASE_SHARDED_FILES` run one registered case per process (`#`, an exact `--test-name-pattern`, exactly one executed case), so a long file of short cases spreads across runners and each case gets its own SDK semaphore. `test/carve-section-loading.test.ts` is one of them: it registers one periodic case per carved skill (`carve-section-loading-`, each with its own touchfiles and history). Run one case with `bun run scripts/test-paid-shards.ts --tier periodic --case carve-section-loading-`. Trial telemetry rides the store: every recorded test carries its 1-based `attempt` plus, on an isolated trial shard, its `case_id`, `kind`, `trial`, `panel` and `policy_version`, and each lane's report uploads one `trial-outcomes` JSONL line per trial. `bun run eval:pass-rates` turns that history into per-case pass rates (see "Pass-rate history" below; free-suite flakes are a separate record, see [Flake ledger](#flake-ledger)). Census integrity is enforced from the free suite: every `E2E_TOUCHFILES` / `LLM_JUDGE_TOUCHFILES` key must name a living paid test (`test/touchfiles.test.ts`'s reverse invariant), and `git show :path` fixtures are banned — vendor the bytes instead (`test/git-ref-fixture-tripwire.test.ts`). Functional QA and documentation acceptance require an explicit `EVALS_RUN_ID`; `GSTACK_EVAL_DIR` alone does not satisfy their evidence-ownership guard. For each local invocation, supply a fresh ID to the documented detached runner: ```bash EVALS_RUN_ID="local-$(bun -e 'console.log(crypto.randomUUID())')" bun run eval:bg:pr ``` Neither package scripts nor `gstack-detach` invent this identity. CI's PR/manual slices, periodic slices and weekly gate census supply an ID bound to the workflow run, attempt, job and slice. Existing slice artifacts retain per-shard snapshots; separate always-run `native-captures-` artifacts retain project/legacy `e2e-runs` and `evals/qa-callers` evidence for 90 days. These diagnostic artifacts are not collector results and do not establish that an unfinished test passed. **Fast PR profile and evidence reuse.** `test:pr` selects the changed cases in `scripts/test-pr-profile.ts` plus every changed quality judge. `--profile full` retains the broad census; no case IDs or tier assignments are removed. The plan records both E2E and judge selections and lists deferred coverage. Executors receive those selections separately and the report checks actual executed case counts. Unknown source dependencies restore the broad gate (see [PR paid lane fallback](#pr-paid-lane-fallback)); unmapped prompts without registered coverage require full validation. Known broad-only prompt changes are explicitly deferred, not counted as PR passes. Weekly/manual runs of `evals-periodic.yml` execute both complete censuses fresh; manual `evals.yml` runs the broad gate. This deliberately moves some defect detection later. `scripts/eval-input-cache.ts` accepts only complete, clean, first-attempt passes with matching before/after inputs. The audited workflow-judge adapter hashes the actual expanded prompt, source/fixture/rubric/runner closure, installed SDK, model parameters and runtime. Missing/unknown inputs force execution. Receipts are scoped to the same repository and PR, expire after 24 hours, and contain public scores and provenance rather than prompts or secrets. Of the 17 cases using `runWorkflowJudge`, 16 are eligible; the cookie workflow's custom input does not match the cache adapter and stays fresh, as do the other 11 quality cases. CI supplies the scoped cache/runtime configuration; local runs are fresh by default. Cached scores must pass current assertions; reused records retain their original source and time and cannot renew the receipt. `scripts/e2e-shard-reuse.ts` extends the same receipts to PR-profile E2E shards (paid evals never retry, so a pass is structurally a first attempt): the identity hashes the test's import closure, every tracked file matched by the touchfiles of every case the file registers plus the global touchfiles, the runner/workflow/setup actions, the child's environment pins, the CI image and Claude CLI version, and the shard's case ids, pattern, wall and concurrency. A computed registration, an unmatched touchfile pattern, a retrying file, a preload option or a custom endpoint makes the shard ineligible. A reused shard reports `reused` with its source run and writes `execution: "reused"` collector records; the report rejects reused outcomes outside the fast PR profile. `EVALS_FRESH=1`, periodic, marathon and release validation bypass both lookup and publishing. **Free test timing and isolation.** `test:quick` is an explicitly partial measured subset for edit feedback. `test` remains complete local acceptance with its bounded worker pool. The CI planner inventories every free file and packs them using `scripts/free-test-durations.json`; each machine runs its assigned shard serially. Plans and receipts bind the source revision, complete inventory and strict outcomes; missing, duplicate or mismatched receipts fail the required aggregate. The existing maximum five-file flaky retry allowance applies across the entire lane, not separately to every machine. Refresh the full timing list with `bun run test:ubicloud --record-durations`. Profiling records failures faithfully and is separate from final release acceptance. **CI planner/executor/report.** `--emit-plan --slice-budget S --jobs J` (CI) or `--slices K` (local) computes selection + the slice plan ONCE (killing per-slice selector divergence); `--plan --slice i` executors consume the manifest and write slice-result artifacts; `--report ` reconciles them FAIL-CLOSED (a slice whose artifact never landed, or a planned shard nobody reported, is a failure). Budget mode (`packBySliceBudget`) places shards longest-recorded-first into the fullest slice whose estimated wall on J FIFO workers stays within S seconds, else a new slice; a shard with no recorded wall weighs the whole budget (its own runner), a shard longer than the budget runs alone, and overlays keep one final one-at-a-time slice. The manifest's `plan` records each slice estimate and its own CI job ceiling in `sliceCiTimeoutMinutes`: max(2 × slice budget, the slice's longest shard supervised wall, the serialized overlay envelope) + 20 minutes setup (`CI_SETUP_ALLOWANCE_MINUTES`); `ciTimeoutMinutes` is the largest of them. CI derives the matrix (`[range(1; .sliceCount + 1)]`) from the manifest and indexes each slice's ceiling as its job timeout (`fromJSON(needs.plan-slices.outputs.slice_timeouts)[matrix.slice]`). Executors refuse an `EVALS_JOBS` other than the planned J and run their shards longest first. Each executor's first step records its job start (`GSTACK_SLICE_JOB_STARTED_AT`). The slice deadline is that start + the slice ceiling − a 5-minute upload reserve: after it the executor starts no more shards. A shard that never started reports `not_run` (INFRA, `never-started`); a shard still running is killed and reports `hung` (TIMEOUT, `timed-out`). The executor checkpoints `slice-N.json` after every shard start and finish, so a job killed before its final write reports from the checkpoint (`slice N/M ended before its final result …: TIMEOUT`), never as a missing slice. `--slices K` keeps the supervised round-robin baseline re-packed by recorded times for local runs. Durations are recorded per tier (a file's gate and periodic cases differ); refresh one tier from a downloaded report directory with `--report --write-durations`. Under `EVALS_ALL` the hollow-shard guard marks exit-0 shards with ZERO executed tests `passed-empty` (a failure) — census-health, not just test runs. evals.yml runs the sliced gate lane per PR — the ONLY paid lane since the legacy 17-row matrix (22.6 min/$21 per PR serialized ahead of the slices) was deleted after demonstrated parity; its `KNOWN_MATRIX_GAPS`/`KNOWN_TIER_UNSET` ratchets retired with it and `test/evals-workflow-wiring.test.ts` pins the surviving wiring (slice-count agreement, tier consistency, the shared register-skills composite with its fail-fast verification loop). Tier `marathon` (complete start-to-finish flows) is selected positively: a file enters the marathon plan only when it declares `describeE2ETier('marathon')` or registers a marathon-tier case, and the gate and periodic planners exclude marathon-only files; `evals-marathon.yml` runs them weekly and on dispatch, fresh, one file per runner, with its own fail-closed report and tracking issue, and nothing requires it. evals-periodic.yml runs ALL periodic-tier files weekly (the coverage contract) minus the reasoned exclusions in `test/helpers/periodic-exclude-data.ts` (reason + tracking required per entry; removal re-activates the file), plus a weekly `EVALS_ALL` gate census. A red census writes one report on every ref, to the step summary and the `census-report-a` artifact: the run's branch, sha and event, both lanes' summaries, every pass-rate alarm line, the red ledger and session headroom (`eval:pass-rates --reds` / `--headroom --runs 10 --branch `), the all-green estimate and a link to the red-census guide, with report-derived text passed through `scripts/lib/published-text.ts`. Only a run on `refs/heads/main` comments on (or opens) the one tracking issue and closes it on the next green run; the marathon lane does the same with its own issue and `marathon-report-a` artifact. `test/evals-tracking-issue.test.ts` pins the main-only guard and runs both report scripts. The host-run Codex job also carries the live Codex multi-block case (`codex-multiblock-live`: `./setup --host codex` from the job's checkout into a fresh `CODEX_HOME`, the router in `codex debug prompt-input`, and a later /learn block rooted by its own prelude), and the periodic lane runs the design default-model smoke (`design-model-smoke`), a visible SKIPPED with no coverage credit when `OPENAI_API_KEY` is absent. The CI image pins the claude CLI to an exact version (`.github/docker/Dockerfile.ci`, enforced by `test/ci-image-cli-pin.test.ts` — bumps ride PRs that run the PTY gate), and every eval-store run records `claude --version`, resolved once in the runner parent and handed to shard children as `GSTACK_CLAUDE_CLI_VERSION` (never spawned on a test thread), so a TUI-drift flake hunt is a grep, not archaeology. **Eval verdict policy** (`EVAL_POLICY` version 2 in `test/helpers/periodic-exclude-data.ts`; version 1 pre-registered 2026-09-29, version 2 approved 2026-10-04 with the decision memo in [test-audit-2026-10.md](test-audit-2026-10.md)). Paid evals never retry. Each live case has exactly one kind in `E2E_KINDS` (`test/helpers/touchfiles-data.ts`; `test/eval-kinds.test.ts` enforces coverage), and the kind fixes its trials before the run: - `rule` (default): one trial; any failed assertion fails the verdict. For cases where nothing stochastic decides the verdict, or where it checks a contract the product must meet every run. - `behavior`: a panel of `n = 3` independent trials, launched together as isolated case shards on different slices (key `#~t`). All three always run: no early stop and no conditional extra trial. PASS when at least `k = 2` pass and no trial violated a contract (`expectContract()` stamps `failure_class: 'contract'`). Each behavior case names its tolerated deviation in `BEHAVIOR_WHY` and must have a literal registration so it can run alone. - `judge`: an LLM judge scoring a fixed input. `judgePanel()` (`test/helpers/llm-judge.ts`) draws 3 samples of the same prompt concurrently inside the unchanged `JUDGE_MS`; numeric dimensions gate on the per-dimension median of exactly 3 samples, so at least 2 of 3 must meet the unchanged threshold (`judgePanelMedian()`; no dimension compensates for another, and any other sample count fails closed), booleans on a strict majority. Panel logs show every sample and the mean for information; the median is the gate (EVAL_POLICY v3, approved 2026-10-06). A sample that errors (refusal, truncation, non-JSON, a malformed field) fails the panel and is never resampled; a refusal counts as an unscored panel only when every sample refused. `callJudge`'s 429 backoff happens before any model output and is transport, not a verdict retry. The workflow-judge cache stores whole panels only. The `rule` case auq-matrix uses the same panel inside its one execution: each skill's single native capture has its recommendation substance scored by 3 judge samples, gated on their median against the unchanged minimum of 4, and a failed sample fails the panel without resampling (approved 2026-10-04 for censuses after the change). `panelVerdict()` (`test/helpers/eval-store.ts`) is the single verdict function the report, `collector-outcomes.json`, the PR comment and pass-rates all use. A timed-out, crashed or infrastructure-failed trial is a failed trial recorded with its class; a missing or duplicate trial record makes the verdict INCOMPLETE, which fails the lane; a 2/3 PASS is shown as `PASS 2/3` with the failed trial's cause. A manual re-run adds trials under a new run attempt and never replaces the first attempt's verdict. A red census is never rerun on unchanged inputs: each red is diagnosed as product, test/detector, harness or infra and resolved by a concrete repair and a fresh census, or listed as a named red. The one exception: a census whose every red verdict is machine-classified INFRA or INCOMPLETE (missing slice artifact, runner loss, API error before the first model turn) may be re-dispatched once as a new run, and both runs are reported. Changing any `EVAL_POLICY` constant after seeing census results needs Garry's re-approval, a `version` bump and a fresh census; `test/periodic-exclude-policy.test.ts` pins the approved values. ### Ship measure **Measure-then-fix: diagnostic trials are not verdicts** (`scripts/ship-measure.ts`). When a paid case goes red during /ship, /ship measures that case alone before it reruns the full gate: classify the red line, measure, fix at the cause, re-measure, then run the gate once (`ship/sections/measure.md`). The repeated runs are **diagnostic measurement, not verdict retries**, so they fit the no-retry rule above: they never change the recorded verdict of the run that failed, every trial and measurement file says `"label": "diagnostic"` and `"verdict": null`, and they are written under `.context/ship-measure///t/` (one directory per baseline, repair round and trial; an existing round is never overwritten). The runner refuses an output directory inside the project eval dir, so pass-rate history and CI uploads never read a diagnostic trial as a verdict. The lane verdict still comes only from the full gate run. Each trial runs through gstack's single-case runner (`scripts/test-paid-shards.ts --tier --case --trials 1`; a standalone judge id runs its file with only that judge selected and needs its own passing record) or through the project's documented single-case command (`--command ' {case}'`, split on whitespace, no shell). Counts per kind come from gstack config (`bin/gstack-config`), so a measurement is pre-registered before it runs. Each measurement is decided by the measurement bar (`scripts/lib/measure-bar.ts`, approved 2026-10-06), applied in this order: | Kind | Trials per batch (config key, default) | MEETS | MEETS-qualified | EXTEND, then the pooled decision | |---|---|---|---|---| | `rule` | `ship_measure_rule_trials`, 10 | at least 9 of 10 | 8 of 10, every red qualifying | 7 of 10, or 8 that does not qualify: 10 more trials, then 18 of 20 strict or 16 of 20 qualified | | `behavior` | `ship_measure_behavior_panels`, 4 panels of 3 (12 trials) | at least 11 of 12 | 10 of 12, every red qualifying | 9 of 12, or 10 that does not qualify: 12 more trials, then 22 of 24 strict or 20 of 24 qualified | | `judge` | `ship_measure_judge_outputs`, 10 outputs | at least 9 of 10 outputs | 8 of 10, every red qualifying | 7 of 10, or 8 that does not qualify: 10 more outputs, then 18 of 20 or 16 of 20 qualified | A judge output passes when the median of its 3 samples passes (EVAL_POLICY v3, so at least 2 of 3). Everything below the EXTEND row is **BELOW**: a fix round. - **Qualifying reds** (the only reds MEETS-qualified accepts): `provider`, which is mechanical: the trial's own `failure_cause` is `api_error`, or `pre_turn_infra` whose evidence names a 429 or 5xx, a rate limit, an overload or a connection reset (`ship-measure classify` refuses `provider` otherwise); `judge-noise`, a judge sample at the threshold; and `model-miss`, which counts only with a `path:line` citation of the deviation. Never qualifying: a timeout or hang (`session_timeout`, `observer_timeout` and `provider_stall` are classified so without review and cannot be reclassified into a qualifying class), a regression, a contract violation (BELOW at any count), or any red with a known fixable cause (`fixable`): each goes to a fix round. - **needs-classify.** A batch below the strict bar but at or above the EXTEND floor, with a failed trial nobody classified, returns needs-classify (exit 5) with the trials to classify. `ship-measure classify --trials 2,5 --class --evidence ` appends one immutable record per trial to the round's `classifications.jsonl` (a later record supersedes an earlier one) and re-decides. `unclassified` records "reviewed, no route"; it allows EXTEND but never qualifies. - **EXTEND** (exit 6). `ship-measure extend` runs N more trials only when the round decided EXTEND and only on identical inputs: the measurement's identity (the working tree's frozen git tree, kind, trials, runner, Claude CLI, model, CI image `GSTACK_CI_IMAGE`, Bun, EVAL_POLICY and HARNESS_VERSION) must match field for field, and the refusal names the field that changed. The pooled 2N is decided once and is never extended again: there is no third batch. - **Void batches.** A batch where more than 30% of trials fail with affirmative provider or transport evidence is void (an outage, not a measurement); the runner redispatches it once, both batches are reported, and a second void batch stops with a named red. A crash or module-load failure without provider evidence never voids a batch. `ship_measure_budget_usd` (default 25) is an estimated admission budget per red case across the baseline, an extension or redispatch, every repair round and judge scoring: before each batch the runner reserves the estimated cost of every concurrent trial and admits only what fits beside what was already spent, then reconciles actual costs after the batch. When the next trial no longer fits it stops with a named red. A case with no per-trial estimate, or one above `ship_measure_ask_per_trial_usd` (default 2), asks once before any trial runs; with no estimate it then runs one calibration trial alone. `ship_measure_max_rounds` (default 3) limits repair rounds; an extension or a redispatch is not a round. `ship-measure report` prints the PR-body table: each measurement as "observed k/n" (after the named fix for a repair round, with every batch), the bar, the decision, estimated and actual spend, and qualified MEETS under their own heading. A red the user declares infrastructure is recorded with `ship-measure skip` and shows as `unmeasured`, never as a pass. A case is called fixed only with a named causal change and every failure in the closing measurement explained; the weekly census, not the loop, reports long-term reliability. The same rule covers a failed free-suite shard: `ship-measure free` reruns that shard's exact file list 10 times (rerun 1 alone as the baseline, the rest in parallel at `max(1, floor(cores / shard concurrency))`), each with its own HOME, state root and flake ledger and with `GSTACK_FREE_RETRY_FLAKY=0`, under a 10-minute wall cap that reports a partial count. Browse daemons in a rerun pick free random ports, as in the normal suite. `ship_rerun_backend=ubicloud` runs the reruns on one Ubicloud VM through `bun run test:ubicloud --diagnostic`, which also sets the flaky retry off. The seeded paid case `ship-measure-seeded-flake` (periodic, `behavior`) drives /ship through this loop against a stub eval command that fails trial slots 2, 5 and 9 until its list is sorted. The weekly [off-ship sweep](#ship-measure-sweep) measures main's flakiest gate cases with the same runner and bar. **Failure causes and details** (diagnostic only; verdicts unchanged under v1). Every failed trial record keeps `failure_class` (`assertion`, `contract`, `timeout`, `infra`: the field verdicts, INFRA re-dispatch and pass rates read) and its 300-character `error` exactly as before. `failureCauseOf()` (`test/helpers/eval-store.ts`, called by both record builders, including the JUnit census for PTY and JUnit-only cases) adds an optional `failure_cause` with a one-line `failure_cause_evidence`. It never changes `failure_class`. The highest-precedence cause with evidence wins: | `failure_cause` | Recorded when | First step | |---|---|---| | `contract` | `expectContract()` stamped a contract violation | fix the product; a violation fails a behavior panel outright | | `pre_turn_infra` | the trial failed before the first model turn (`failure_class: 'infra'`) | check the runner and API status; an all-INFRA census is re-dispatched once | | `api_error` | a session ended on an API error (for PTY, the `API Error:` panel on an idle turn) or the runner's exit reason was `error_api` | read the evidence line; a provider outage is a named red | | `refusal` | the structured stop reason was a refusal (phrases only as a last resort; quoted refusal text in a tool result does not count) | check whether the fixture prompt invites the refusal | | `provider_stall` | a session that streamed partial messages saw no event of any kind, thinking deltas included, for `STALL_WINDOW_MS` (120 s) while a model request was in flight and no tool, permission prompt, hook or subagent was outstanding; sessions without partial messages are never stalls | read the session ledger row; still a failed trial under v1 | | `session_timeout` | a runner's armed session timeout fired | `eval:pass-rates --headroom`; cut work, never raise the budget | | `observer_timeout` | the test's observer gave up waiting for an outcome (`outcome=timeout`) | read the transcript tail: a missed detector or a product that never answered | | `assertion` | a check failed after the session ran (`session completed; check failed` when it ended normally) | the Expected/Received values or judge dimensions | | `unknown` | the trial failed with no evidence for any cause above | inspect the shard directory | A detector, harness or product fault is a human diagnosis, never a cause. Readers before this field accept the new records (a frozen v1 reader runs over them in `test/eval-store.test.ts`); an unknown cause from a newer gstack is rejected with `unknown failure_cause "" (written by a newer gstack; update this checkout to read it)`. `failure_detail` carries the values `error` cannot hold: a failed Bun matcher's `{expected, received}`, or for a judge one `{dimension, mean, threshold, samples, rationale}` entry per failing dimension (since EVAL_POLICY v3 `mean` holds the panel's gating median), each part capped at 200 characters. `sessions[]` (at most 32) summarizes the trial's session-ledger rows. `cost_known: false` marks a record or trial whose harness captured no billing: PTY and Codex sessions never bill, and an SDK session bills only with a terminal `total_cost_usd`. An absent field means known. Census and marathon cost lines print the known sum and `N of M trial(s) cost unknown`, never a bare total over unknowns. **Session ledger.** Every runner (session-runner, the Agent SDK runner including `runSharedInteractive`, the PTY session and `runRecordedCodexEval`) appends one best-effort line per armed session to `$GSTACK_EVAL_DIR/session-ledger.jsonl` (`test/helpers/session-ledger.ts`): a stable key `: