diff --git a/.github/workflows/evals-marathon.yml b/.github/workflows/evals-marathon.yml
index 9aa0ba98c..430186927 100644
--- a/.github/workflows/evals-marathon.yml
+++ b/.github/workflows/evals-marathon.yml
@@ -283,25 +283,47 @@ jobs:
# PIPESTATUS[0], NOT $?: the default step shell has no pipefail.
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- # One tracking issue for the whole lane (never one per week).
- - name: Upsert tracking issue on failure
+ # A red marathon run writes its report to the step summary and the
+ # marathon-report artifact on every ref. Only main upserts the lane's ONE
+ # tracking issue (never one per week) and closes it on the next green
+ # run; a branch-dispatched run never touches main's issue.
+ - name: Write the marathon report
if: always() && (steps.reconcile.outputs.exit != '0' || needs.eval-slices.result != 'success')
+ run: |
+ set -euo pipefail
+ {
+ echo "Marathon report (non-blocking lane): branch ${GITHUB_REF_NAME} @ ${GITHUB_SHA}, event ${GITHUB_EVENT_NAME}, run ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ echo
+ echo "A census went red: triage with ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/evals/census-red.md"
+ echo
+ echo "- reconciliation exit: ${{ steps.reconcile.outputs.exit }}"
+ echo "- marathon slices job: ${{ needs.eval-slices.result }}"
+ echo
+ { tail -c 6000 /tmp/report.txt 2>/dev/null || echo "(no reconciliation output)"; } \
+ | bun --no-install -e 'import { publishedFence } from "./scripts/lib/published-text";
+ const lines = (await Bun.stdin.text()).split("\n").filter(Boolean);
+ console.log(publishedFence(lines.length ? lines : ["(none)"]).join("\n"));'
+ } > /tmp/marathon-report.md
+ cat /tmp/marathon-report.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload the marathon report
+ if: always() && (steps.reconcile.outputs.exit != '0' || needs.eval-slices.result != 'success')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: marathon-report-a${{ github.run_attempt }}
+ path: /tmp/marathon-report.md
+ if-no-files-found: ignore
+ retention-days: 90
+
+ - name: Upsert tracking issue on failure
+ if: always() && github.ref == 'refs/heads/main' && (steps.reconcile.outputs.exit != '0' || needs.eval-slices.result != 'success')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TITLE="Weekly marathon evals: red lane needs triage"
- BODY_FILE=/tmp/issue-body.md
- {
- echo "Automated weekly marathon report (non-blocking lane) — run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- echo
- echo "- reconciliation exit: ${{ steps.reconcile.outputs.exit }}"
- echo "- marathon slices job: ${{ needs.eval-slices.result }}"
- echo
- echo '```'
- tail -c 6000 /tmp/report.txt 2>/dev/null || echo "(no reconciliation output)"
- echo '```'
- } > "$BODY_FILE"
+ BODY_FILE=/tmp/marathon-report.md
+ [ -s "$BODY_FILE" ] || echo "Marathon report missing; run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" > "$BODY_FILE"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file "$BODY_FILE"
@@ -310,6 +332,18 @@ jobs:
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file "$BODY_FILE"
fi
+ - name: Close the tracking issue on a green run
+ if: always() && github.ref == 'refs/heads/main' && steps.reconcile.outputs.exit == '0' && needs.eval-slices.result == 'success'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ TITLE="Weekly marathon evals: red lane needs triage"
+ EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty')
+ if [ -n "$EXISTING" ]; then
+ gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --comment "Green marathon run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ fi
+
- name: Fail the workflow when reconciliation failed
if: always() && (steps.reconcile.outputs.exit != '0' || needs.eval-slices.result != 'success')
run: exit 1
diff --git a/.github/workflows/evals-periodic.yml b/.github/workflows/evals-periodic.yml
index 027533301..464b2b45d 100644
--- a/.github/workflows/evals-periodic.yml
+++ b/.github/workflows/evals-periodic.yml
@@ -627,7 +627,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
- bun run eval:pass-rates --gate --runs 10 > /tmp/pass-rates.txt 2>&1
+ bun run eval:pass-rates --gate --runs 10 --branch "$GITHUB_REF_NAME" > /tmp/pass-rates.txt 2>&1
echo "exit=$?" >> "$GITHUB_OUTPUT"
cat /tmp/pass-rates.txt
@@ -652,10 +652,13 @@ jobs:
echo "redispatch=false" >> "$GITHUB_OUTPUT"
fi
- # A red weekly lane nobody must action is waste — upsert ONE tracking
- # issue (never a new issue per week) with the headline and failure block
- # of both lanes, and close it on the next green run.
- - name: Upsert tracking issue on failure
+ # A red census writes one report on every ref: the step summary and the
+ # census-report artifact. Only main (scheduled weekly, or dispatched on
+ # main) upserts ONE tracking issue (never a new issue per week) and
+ # closes it on the next green run; a branch-dispatched census never
+ # comments on or closes main's issue. Every report-derived line goes
+ # through the one published-text sanitizer (scripts/lib/published-text.ts).
+ - name: Write the census report
if: always() && (steps.reconcile.outputs.exit != '0' || steps.gate-reconcile.outputs.exit != '0' || steps.pass-rates.outputs.exit != '0' || needs.eval-slices.result != 'success' || !contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) || needs.gate-census.result != 'success')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -663,17 +666,28 @@ jobs:
REDISPATCH_OF: ${{ inputs.redispatch_of }}
run: |
set -euo pipefail
- TITLE="Weekly periodic evals: red lane needs triage"
- BODY_FILE=/tmp/issue-body.md
+ published() { # $1 fence|line: stdin lines through the published-text sanitizer
+ bun --no-install -e 'import { publishedFence, sanitizePublishedText } from "./scripts/lib/published-text";
+ const lines = (await Bun.stdin.text()).split("\n").filter(Boolean);
+ console.log(process.argv[1] === "line" ? sanitizePublishedText(lines.join(" ")) : publishedFence(lines.length ? lines : ["(none)"]).join("\n"));' "$1"
+ }
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ GUIDE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/evals/census-red.md"
+ bun run eval:pass-rates --reds --runs 10 --branch "$GITHUB_REF_NAME" > /tmp/pass-rates-reds.txt 2>&1 \
+ || echo "history unavailable: eval:pass-rates --reds exited $?" >> /tmp/pass-rates-reds.txt
+ bun run eval:pass-rates --headroom --runs 10 --branch "$GITHUB_REF_NAME" > /tmp/pass-rates-headroom.txt 2>&1 \
+ || echo "history unavailable: eval:pass-rates --headroom exited $?" >> /tmp/pass-rates-headroom.txt
{
- echo "Automated weekly report — run: ${RUN_URL}"
+ echo "Periodic census report: branch ${GITHUB_REF_NAME} @ ${GITHUB_SHA}, event ${GITHUB_EVENT_NAME}, run ${RUN_URL}"
+ echo
+ echo "A census went red: triage with ${GUIDE_URL}"
if [ -n "$REDISPATCH_OF" ]; then echo; echo "This run is the one INFRA/INCOMPLETE re-dispatch of run ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${REDISPATCH_OF}; both runs are reported."; fi
if [ "$REDISPATCH" = "true" ]; then echo; echo "Every red verdict is machine-classified INFRA/INCOMPLETE: re-dispatching once as a new run (EVAL_POLICY.infraRedispatch). This run stays red and reported."; fi
echo
echo "- periodic reconciliation exit: ${{ steps.reconcile.outputs.exit }} (slices job: ${{ needs.eval-slices.result }}, Codex slices job: ${{ needs.eval-codex-slices.result }})"
echo "- gate census reconciliation exit: ${{ steps.gate-reconcile.outputs.exit }} (census job: ${{ needs.gate-census.result }})"
echo "- pass-rate history gate exit: ${{ steps.pass-rates.outputs.exit }}"
+ echo "- $({ grep -E '^all-green probability' /tmp/pass-rates-reds.txt || echo 'all-green probability: unavailable (see the red ledger)'; } | published line)"
echo
echo "### Periodic lane"
cat /tmp/paid-report/report-summary.md 2>/dev/null || echo "(no periodic report summary)"
@@ -682,23 +696,44 @@ jobs:
cat /tmp/gate-census-report/report-summary.md 2>/dev/null || echo "(no gate census report summary)"
echo
echo "### Pass-rate history (ACTION REQUIRED)"
- echo '```'
- { grep -E 'ACTION REQUIRED|history unavailable' /tmp/pass-rates.txt || echo "(no pass-rate alarms)"; } | sed 's/@/@\xe2\x80\x8b/g' | head -c 6000
- echo '```'
+ { awk '/^ACTION REQUIRED/ { on = 1; print; next } on && /^ \[/ { print; next } { on = 0 } /history unavailable/ { print }' /tmp/pass-rates.txt 2>/dev/null \
+ | grep . || echo "(no pass-rate alarms)"; } | head -c 6000 | published fence
+ echo
+ echo "### Red ledger: bun run eval:pass-rates --reds --runs 10 --branch ${GITHUB_REF_NAME}"
+ { head -n 1 /tmp/pass-rates-reds.txt; tail -n +2 /tmp/pass-rates-reds.txt | tail -n 60; } | cut -c1-400 | published fence
+ echo
+ echo "### Session headroom: bun run eval:pass-rates --headroom --runs 10 --branch ${GITHUB_REF_NAME}"
+ head -n 40 /tmp/pass-rates-headroom.txt | cut -c1-400 | published fence
echo
echo "Full reconciliation output"
echo
- echo '```'
- tail -c 6000 /tmp/report.txt 2>/dev/null | sed 's/@/@\xe2\x80\x8b/g' || echo "(no reconciliation output)"
- echo '```'
+ { tail -c 6000 /tmp/report.txt 2>/dev/null || echo "(no reconciliation output)"; } | published fence
echo
- echo '```'
- tail -c 6000 /tmp/gate-report.txt 2>/dev/null | sed 's/@/@\xe2\x80\x8b/g' || echo "(no gate census reconciliation output)"
- echo '```'
+ { tail -c 6000 /tmp/gate-report.txt 2>/dev/null || echo "(no gate census reconciliation output)"; } | published fence
echo ""
echo
echo "Policy: EVAL_POLICY and CASE_QUARANTINE in test/helpers/periodic-exclude-data.ts; history: \`bun run eval:pass-rates\`."
- } > "$BODY_FILE"
+ } > /tmp/census-report.md
+ cat /tmp/census-report.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload the census report
+ if: always() && (steps.reconcile.outputs.exit != '0' || steps.gate-reconcile.outputs.exit != '0' || steps.pass-rates.outputs.exit != '0' || needs.eval-slices.result != 'success' || !contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) || needs.gate-census.result != 'success')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: census-report-a${{ github.run_attempt }}
+ path: /tmp/census-report.md
+ if-no-files-found: ignore
+ retention-days: 90
+
+ - name: Upsert tracking issue on failure
+ if: always() && github.ref == 'refs/heads/main' && (steps.reconcile.outputs.exit != '0' || steps.gate-reconcile.outputs.exit != '0' || steps.pass-rates.outputs.exit != '0' || needs.eval-slices.result != 'success' || !contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) || needs.gate-census.result != 'success')
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ TITLE="Weekly periodic evals: red lane needs triage"
+ BODY_FILE=/tmp/census-report.md
+ [ -s "$BODY_FILE" ] || echo "Periodic census report missing; run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" > "$BODY_FILE"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file "$BODY_FILE"
@@ -708,7 +743,7 @@ jobs:
fi
- name: Close the tracking issue on a green run
- if: always() && steps.reconcile.outputs.exit == '0' && steps.gate-reconcile.outputs.exit == '0' && steps.pass-rates.outputs.exit == '0' && needs.eval-slices.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) && needs.gate-census.result == 'success'
+ if: always() && github.ref == 'refs/heads/main' && steps.reconcile.outputs.exit == '0' && steps.gate-reconcile.outputs.exit == '0' && steps.pass-rates.outputs.exit == '0' && needs.eval-slices.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) && needs.gate-census.result == 'success'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REDISPATCH_OF: ${{ inputs.redispatch_of }}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4ba76804b..bb6ddc4cb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -274,6 +274,13 @@ this policy. Quarantine (`CASE_QUARANTINE`) and history are described in
`docs/TESTING_INTERNALS.md`; `bun run eval:pass-rates --case ` shows a
case's per-trial pass rate with its Wilson interval.
+**A census went red?** Follow [docs/evals/census-red.md](docs/evals/census-red.md):
+read the red line, inspect its evidence with `bun run eval:pass-rates --run `,
+check `--reds` and `--headroom`, repair with a free regression test, and only
+then spend one paid run with the line's `after a repair:` command. A red census
+on `main` is reported on the weekly tracking issue; a census dispatched on a
+branch writes the same report to its run summary and `census-report` artifact.
+
CI enables verified first-attempt reuse for 16 workflow quality judges for
24 hours within the same PR. The cookie workflow's custom input and the other 11
quality cases stay fresh. PR-profile E2E shards that run once (no retry, so the
@@ -446,6 +453,9 @@ bun run eval:list # list all eval runs (turns, duration, cost per run
bun run eval:compare # compare two runs — shows per-test deltas + Takeaway commentary
bun run eval:summary # aggregate stats + per-test efficiency averages across runs
bun run eval:pass-rates # per-case trial pass rates + Wilson intervals from recent weekly runs (--case, --runs, --dir, --backfill, --json, --gate); eval:flake-rank is an alias
+bun run eval:pass-rates --reds # verdict reds per census by failure class and cause, all-green probability
+bun run eval:pass-rates --headroom # slowest session per case vs its armed budget (alarm above 85% in --gate)
+bun run eval:pass-rates --run # one census's reds with their values and fetched transcript evidence
```
**Detached runs for agents and long suites.** When an agent (or you, for a run
diff --git a/README.md b/README.md
index 75e18080d..5f6a7b728 100644
--- a/README.md
+++ b/README.md
@@ -186,8 +186,12 @@ for capture, judge, and benchmark model selection.
The design binary (`$D`) uses OpenAI's `gpt-5.5` by default, with image
generation through `gpt-image-2`. Set `GSTACK_DESIGN_MODEL=` to use
another gpt-5-class model for both image generation and screenshot analysis;
-if OpenAI rejects it, the error names `GSTACK_DESIGN_MODEL`. Check a key
-against the defaults with `bun run design/scripts/live-model-check.ts`.
+if OpenAI rejects it, the error names `GSTACK_DESIGN_MODEL`. Set
+`GSTACK_DESIGN_IMAGE_MODEL=` to change the image tool model; a
+value that is not a gpt-image model name is refused before any request. Check a
+key against the defaults with `bun run design/scripts/live-model-check.ts`,
+which always tests the default models and ignores both overrides; the weekly
+periodic census runs the same check.
**Want to add support for another agent?** See [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md).
Rendering a new agent is one TypeScript config file; installing it also needs a
diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md
index 2b4559e61..80e308ef7 100644
--- a/docs/TESTING_INTERNALS.md
+++ b/docs/TESTING_INTERNALS.md
@@ -96,9 +96,13 @@ 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 v2](OVERLAY_BENCHMARK_CONTRACT.md)
+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.
+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
@@ -365,7 +369,22 @@ 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, plus a tracking-issue UPSERT on red weeks. The CI
+`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
@@ -373,6 +392,7 @@ 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 1 in
`test/helpers/periodic-exclude-data.ts`, pre-registered 2026-09-29). Paid evals
never retry. Each live case has exactly one kind in `E2E_KINDS`
@@ -397,6 +417,11 @@ and the kind fixes its trials before the run:
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 mean 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
@@ -414,6 +439,58 @@ 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.
+
+**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, 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 `: