diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci
index b0befb3a0..97b5b6b55 100644
--- a/.github/docker/Dockerfile.ci
+++ b/.github/docker/Dockerfile.ci
@@ -103,11 +103,11 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL "https://nodejs.org
# Hashes come from the release's SHASUMS256.txt. The image builds linux/amd64
# only (the Node.js tarball above is linux-x64); an architecture without a
# verified archive fails here. test/ci-image-cli-pin.test.ts pins this shape.
-ARG BUN_VERSION=1.4.0
+ARG BUN_VERSION=1.4.2
ARG TARGETARCH
ENV BUN_INSTALL="/usr/local"
RUN case "${TARGETARCH:-amd64}" in \
- amd64) bun_target=linux-x64; bun_sha256=2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452 ;; \
+ amd64) bun_target=linux-x64; bun_sha256=36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; \
*) echo "Dockerfile.ci: no verified Bun ${BUN_VERSION} archive for ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL \
diff --git a/.github/scripts/dia-launch-driver.mjs b/.github/scripts/dia-launch-driver.mjs
index 028076148..ef4538f3b 100644
--- a/.github/scripts/dia-launch-driver.mjs
+++ b/.github/scripts/dia-launch-driver.mjs
@@ -46,7 +46,7 @@ export function compareDiaLaunchReceipts(left, right) {
|| qualification.isolation?.sharedRegisteredHome !== true || qualification.artifact?.signatureVerified !== true
|| qualification.artifact?.gatekeeperNotarized !== true || qualification.artifact?.macosCompatibility?.compatible !== true
|| qualification.platform?.os !== 'darwin' || qualification.platform?.architecture !== 'arm64'
- || qualification.platform?.bun !== '1.4.0' || qualification.platform?.playwright !== '1.62.1'
+ || qualification.platform?.bun !== '1.4.2' || qualification.platform?.playwright !== '1.62.1'
|| !/^\d+(?:\.\d+){1,2}$/.test(qualification.artifact.macosCompatibility.hostVersion)
|| qualification.artifact?.architectures?.includes('arm64') !== true
|| ['serviceStopped', 'userDomainStopped', 'userProcessesStopped', 'accountRemoved', 'groupRemoved', 'stagingRemoved'].some(key => receipt.launcherCleanup?.[key] !== true)
@@ -63,7 +63,7 @@ export function compareDiaLaunchReceipts(left, right) {
|| policy?.mockKeychainFlag !== false || policy?.passwordStoreFlag !== false || policy?.firstRunSuppressed !== false
|| policy?.headlessFlag !== true || policy?.expectedProfile !== true || policy?.detached !== true || policy?.shellDisabled !== true
|| policy?.stdioCount !== 5 || policy?.extraPipeDescriptors !== true || policy?.profileArgumentCount !== 1
- || result.driver?.runtime !== runtime || result.driver?.version !== (runtime === 'bun' ? '1.4.0' : '24.18.0')
+ || result.driver?.runtime !== runtime || result.driver?.version !== (runtime === 'bun' ? '1.4.2' : '24.18.0')
|| result.driver?.os !== 'darwin' || result.driver?.architecture !== 'arm64' || result.driver?.playwright !== '1.62.1'
|| result.driver?.release !== qualification.platform.release
|| result.driver?.executableSha256 !== config.executableSha256 || result.driver?.driverSha256 !== config.driverSha256
@@ -176,7 +176,7 @@ async function main() {
const account = readFreshAccountConfiguration(process.argv[2], 'comparison-driver');
const config = account.launchComparison;
const runtimeVersion = process.versions.bun ?? process.versions.node;
- if ((config.runtime === 'bun' ? process.versions.bun !== '1.4.0' : Boolean(process.versions.bun) || runtimeVersion !== '24.18.0')
+ if ((config.runtime === 'bun' ? process.versions.bun !== '1.4.2' : Boolean(process.versions.bun) || runtimeVersion !== '24.18.0')
|| process.arch !== 'arm64' || require('playwright/package.json').version !== '1.62.1') throw new Error('invalid_driver_runtime');
if (await sha256(process.execPath) !== config.executableSha256 || await sha256(import.meta.filename) !== config.driverSha256
|| await sha256(path.join(import.meta.dirname, 'qualify-dia-macos.ts')) !== config.helpersSha256) throw new Error('driver_inputs_changed');
diff --git a/.github/scripts/qualify-dia-macos.ts b/.github/scripts/qualify-dia-macos.ts
index cdaaa3124..b5fc3b9e3 100644
--- a/.github/scripts/qualify-dia-macos.ts
+++ b/.github/scripts/qualify-dia-macos.ts
@@ -179,7 +179,7 @@ export async function runDiaLaunchComparison(account: FreshAccount, purpose: 'co
'argvSha256', 'environmentSha256', 'rootCount', 'launchAttempts', 'rootsBeforeCleanup', 'stderrBeforeCleanup', 'stderrAfterCleanup', 'rootsAfterCleanup', 'driver'];
if (!response || !safeDiaComparisonResponse(response) || Object.keys(response).some(key => !keys.includes(key)) || response.protocol !== 1 || response.purpose !== purpose
|| response.samplingEnabled !== false || response.driver?.runtime !== config.runtime
- || response.driver?.version !== (config.runtime === 'bun' ? '1.4.0' : '24.18.0') || response.driver?.architecture !== 'arm64'
+ || response.driver?.version !== (config.runtime === 'bun' ? '1.4.2' : '24.18.0') || response.driver?.architecture !== 'arm64'
|| response.driver?.os !== 'darwin' || response.driver?.playwright !== '1.62.1'
|| response.driver?.executableSha256 !== config.executableSha256 || response.driver?.driverSha256 !== config.driverSha256
|| response.driver?.helpersSha256 !== config.helpersSha256 || typeof response.ready !== 'boolean'
@@ -931,7 +931,7 @@ export async function qualifyDia(isolation: { root: string; configFile: string }
validateQualificationHost(process.env);
const account = readFreshAccountConfiguration(isolation.configFile);
if (account.guiReadiness || !account.destinationExecutable || !account.destinationSha256) throw new Error('browser_qualification_authority_required');
- if (Bun.version !== '1.4.0' || require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_runtimes_required');
+ if (Bun.version !== '1.4.2' || require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_runtimes_required');
const runnerTemp = realpathSync(process.env.RUNNER_TEMP!);
const output = path.join(runnerTemp, 'dia-native-qualification.json');
if (existsSync(output)) throw new Error('fresh_receipt_path_required');
@@ -1383,7 +1383,7 @@ if (import.meta.main) {
if (process.argv[2] !== '--fresh-account' || !process.argv[3]) throw new Error('fresh_account_configuration_required');
const account = readFreshAccountConfiguration(process.argv[3]);
if (account.guiReadiness) throw new Error('browser_qualification_authority_required');
- if (Bun.version !== '1.4.0' || require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_runtimes_required');
+ if (Bun.version !== '1.4.2' || require('playwright/package.json').version !== '1.62.1') throw new Error('pinned_runtimes_required');
const originalHome = account.home;
const originalHomeEnvironment = process.env.HOME;
const runnerTemp = realpathSync(process.env.RUNNER_TEMP!);
diff --git a/.github/scripts/run-dia-native-qualification.ts b/.github/scripts/run-dia-native-qualification.ts
index f93c76d62..95236b1f9 100644
--- a/.github/scripts/run-dia-native-qualification.ts
+++ b/.github/scripts/run-dia-native-qualification.ts
@@ -406,7 +406,7 @@ async function freshWorker(configFile: string) {
if (await digest(account.bun) !== account.bunSha256 || await digest(account.destinationExecutable) !== account.destinationSha256) throw new Error('staged_executable_changed');
receipt.reason = 'fresh_dependency_install';
run(account.bun, ['install', '--frozen-lockfile', '--ignore-scripts'], 180_000);
- if (Bun.version !== '1.4.0' || require(path.join(account.snapshot, 'node_modules/playwright/package.json')).version !== '1.62.1') throw new Error('pinned_runtime_mismatch');
+ if (Bun.version !== '1.4.2' || require(path.join(account.snapshot, 'node_modules/playwright/package.json')).version !== '1.62.1') throw new Error('pinned_runtime_mismatch');
mkdirSync(probe, { mode: 0o700 });
receipt.reason = 'background_keychain_preflight';
snapshot = captureUserKeychains(env, [account.home, account.temporary]);
@@ -538,7 +538,7 @@ export async function runFreshAccountQualification(comparisonRuntime?: 'bun' | '
validateQualificationHost(process.env);
if (comparisonRuntime !== undefined && !['bun', 'node'].includes(comparisonRuntime)) throw new Error('invalid_comparison_runtime');
if (guiReadinessOnly && comparisonRuntime !== undefined) throw new Error('conflicting_diagnostic_modes');
- if (process.getuid?.() === 0 || Bun.version !== '1.4.0') throw new Error('run_as_unprivileged_pinned_ci_runner');
+ if (process.getuid?.() === 0 || Bun.version !== '1.4.2') throw new Error('run_as_unprivileged_pinned_ci_runner');
const outputRoot = realpathSync(process.env.RUNNER_TEMP!);
const output = path.join(outputRoot, 'dia-native-qualification.json');
if (existsSync(output)) throw new Error('fresh_output_required');
diff --git a/.github/workflows/arm-setup-smoke.yml b/.github/workflows/arm-setup-smoke.yml
index 0c5ec0284..014e9c513 100644
--- a/.github/workflows/arm-setup-smoke.yml
+++ b/.github/workflows/arm-setup-smoke.yml
@@ -30,13 +30,13 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Seal the checked-out source and native toolchain
shell: bash
run: |
set -euo pipefail
test "$(uname -m)" = aarch64
- test "$(bun --version)" = 1.4.0
+ test "$(bun --version)" = 1.4.2
test "$(bun -p 'process.arch')" = arm64
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
mkdir -p "$RUNNER_TEMP/arm-input" "$RUNNER_TEMP/arm-evidence"
@@ -76,8 +76,8 @@ jobs:
apt-get install -y --no-install-recommends ca-certificates git nodejs npm fonts-noto-color-emoji
install -m 0755 /input/bun /usr/local/bin/bun
ln -s bun /usr/local/bin/bunx
- test "$(bun --version)" = 1.4.0
- test "$(bunx --version)" = 1.4.0
+ test "$(bun --version)" = 1.4.2
+ test "$(bunx --version)" = 1.4.2
test "$(bun -p 'process.arch')" = arm64
test "$(node -p 'process.arch')" = arm64
node --version
diff --git a/.github/workflows/cso-runtime-images.yml b/.github/workflows/cso-runtime-images.yml
index e9c3259f0..2e83097e8 100644
--- a/.github/workflows/cso-runtime-images.yml
+++ b/.github/workflows/cso-runtime-images.yml
@@ -36,7 +36,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Validate committed image digests and complete platform matrix
id: inputs
run: |
@@ -69,7 +69,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
- name: Require native Docker and verify reviewed source manifests
env:
@@ -144,7 +144,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Require the declared native architecture and Docker daemon
env:
CSO_ARCH: ${{ matrix.arch }}
@@ -308,7 +308,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: cso-staged-${{ matrix.stack }}-${{ matrix.arch }}
diff --git a/.github/workflows/cso-runtime-promote.yml b/.github/workflows/cso-runtime-promote.yml
index 5a012ec19..63972ccdd 100644
--- a/.github/workflows/cso-runtime-promote.yml
+++ b/.github/workflows/cso-runtime-promote.yml
@@ -41,7 +41,7 @@ jobs:
persist-credentials: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Authenticate the completed qualification run
env:
GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/cso-runtime-qualification.yml b/.github/workflows/cso-runtime-qualification.yml
index 3f90d4ee8..d86c22b23 100644
--- a/.github/workflows/cso-runtime-qualification.yml
+++ b/.github/workflows/cso-runtime-qualification.yml
@@ -67,7 +67,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Require the configured private-evaluator actor
env:
CSO_QUALIFICATION_ACTOR: ${{ vars.CSO_QUALIFICATION_ACTOR }}
diff --git a/.github/workflows/cso-scanner-images.yml b/.github/workflows/cso-scanner-images.yml
index 11613d586..90804b9e2 100644
--- a/.github/workflows/cso-scanner-images.yml
+++ b/.github/workflows/cso-scanner-images.yml
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Prove scanner normalization and fail-closed behavior
run: |
set -eu
@@ -64,7 +64,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Require native Docker and the declared architecture
env:
CSO_ARCH: ${{ matrix.arch }}
@@ -289,7 +289,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: cso-scanner-profile-*
@@ -329,7 +329,7 @@ jobs:
persist-credentials: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: cso-scanner-catalog-proposal
diff --git a/.github/workflows/evals-marathon.yml b/.github/workflows/evals-marathon.yml
index e80e23e8d..565c32d77 100644
--- a/.github/workflows/evals-marathon.yml
+++ b/.github/workflows/evals-marathon.yml
@@ -94,7 +94,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
# One marathon file per runner: a 1-second budget never packs two
# recorded files together.
@@ -268,7 +268,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -289,25 +289,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"
@@ -316,6 +338,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 2fec19a62..af95cb699 100644
--- a/.github/workflows/evals-periodic.yml
+++ b/.github/workflows/evals-periodic.yml
@@ -135,7 +135,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Emit run manifest (ALL periodic tests minus reasoned excludes)
env:
@@ -587,7 +587,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -675,7 +675,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
@@ -700,28 +700,42 @@ 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
- 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')
+ # 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 }}
REDISPATCH: ${{ steps.verdict.outputs.redispatch }}
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)"
@@ -730,23 +744,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"
diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml
index 6f196636b..9263e39ab 100644
--- a/.github/workflows/evals.yml
+++ b/.github/workflows/evals.yml
@@ -191,7 +191,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Restore this PR's verified judge and E2E results
id: store
@@ -267,7 +267,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
# DX-1: a dispatch from scripts/eval-bg.ts names the revision it means
# to test; a different checkout fails here before any paid work.
@@ -526,7 +526,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml
index 66bd911b6..33e04fae5 100644
--- a/.github/workflows/free-tests.yml
+++ b/.github/workflows/free-tests.yml
@@ -52,7 +52,7 @@ jobs:
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- id: plan
name: Inventory and balance the complete free suite
# Warns in the job summary when more than 5 files are unseeded.
@@ -77,7 +77,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install helper dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Build and sign the native macOS startup boundary
@@ -112,7 +112,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Build browser prerequisites
@@ -144,7 +144,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install helper dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Build the native Windows startup boundary from Git Bash
@@ -172,7 +172,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Require local Docker containment prerequisites
# A missing daemon is a failed gate, never a skipped integration test.
run: |
@@ -202,7 +202,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Typecheck product code (zero errors)
@@ -227,7 +227,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -406,7 +406,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: free-plan
diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml
index 75c5992f1..23ae86b66 100644
--- a/.github/workflows/make-pdf-gate.yml
+++ b/.github/workflows/make-pdf-gate.yml
@@ -53,7 +53,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml
index c1739c9db..23c63ef36 100644
--- a/.github/workflows/native-qualification.yml
+++ b/.github/workflows/native-qualification.yml
@@ -41,7 +41,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 24.18.0
@@ -70,7 +70,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 24.18.0
@@ -117,7 +117,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
if: inputs.mode == 'dia-launch-comparison'
with:
diff --git a/.github/workflows/platform-qualification.yml b/.github/workflows/platform-qualification.yml
index e3e1782fb..5b8aa4d15 100644
--- a/.github/workflows/platform-qualification.yml
+++ b/.github/workflows/platform-qualification.yml
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Chromium
@@ -56,7 +56,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install gitleaks
@@ -78,7 +78,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run Swift build invariants
diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml
index 806c81bd8..2fa5475fa 100644
--- a/.github/workflows/quality-gate.yml
+++ b/.github/workflows/quality-gate.yml
@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install frozen dependencies
run: bun install --frozen-lockfile --ignore-scripts
diff --git a/.github/workflows/skill-docs.yml b/.github/workflows/skill-docs.yml
index 9a8124633..b9585bdfb 100644
--- a/.github/workflows/skill-docs.yml
+++ b/.github/workflows/skill-docs.yml
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- run: bun install --frozen-lockfile
# One generation pass for ALL 10 hosts. gen-skill-docs --host all
# hard-fails on any per-host generation error (scripts/gen-skill-docs.ts
diff --git a/.github/workflows/test-health.yml b/.github/workflows/test-health.yml
index f09423cd3..5bb711963 100644
--- a/.github/workflows/test-health.yml
+++ b/.github/workflows/test-health.yml
@@ -38,7 +38,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml
index 3468fbd5f..05aad51f7 100644
--- a/.github/workflows/version-gate.yml
+++ b/.github/workflows/version-gate.yml
@@ -29,7 +29,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- name: Read versions
id: versions
diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml
index f579fec47..0b8adec1d 100644
--- a/.github/workflows/windows-free-tests.yml
+++ b/.github/workflows/windows-free-tests.yml
@@ -51,7 +51,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- id: plan
name: Curate and balance the Windows-safe free suite
# Six shards: ~85 s of Linux-measured work each, ~2.5 min on a
@@ -82,7 +82,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
@@ -190,7 +190,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-plan
@@ -213,7 +213,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 24.18.0
diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml
index 906598346..1216f6624 100644
--- a/.github/workflows/windows-setup-e2e.yml
+++ b/.github/workflows/windows-setup-e2e.yml
@@ -45,7 +45,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- bun-version: 1.4.0
+ bun-version: 1.4.2
# Same lockfile-keyed install cache as windows-free-tests.yml (install
# was 45s of a 64s job, all network).
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4f9345feb..eb7735ac0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -6,7 +6,7 @@ stages:
- check
variables:
- BUN_VERSION: "1.4.0"
+ BUN_VERSION: "1.4.2"
.setup-bun: &setup-bun
- apt-get update -qq && apt-get install -qq -y curl jq git
diff --git a/AGENTS.md b/AGENTS.md
index d2d545325..6c86be265 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -213,8 +213,8 @@ When fixing failures or preparing `/ship`, follow this order:
do not satisfy coverage; preserve every attempt. Paid evals never retry. Each
case's kind (`E2E_KINDS`) fixes its trials before the run: `rule` one trial;
`behavior` a panel of 3 independent trials, PASS at >= 2 with no contract
- violation; `judge` 3 samples on one output, gated on the mean against the
- unchanged threshold. Never add trials, samples or dispatches after seeing a
+ violation; `judge` 3 samples on one output, each dimension gated on its
+ median (at least 2 of 3 samples) against the unchanged threshold. Never add trials, samples or dispatches after seeing a
result, never change a kind to change a verdict without pass-rate evidence,
and report every trial. Quarantine follows `CASE_QUARANTINE`'s entry and exit
rules only (`EVAL_POLICY`, `docs/TESTING_INTERNALS.md`). A census whose every
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9da7ab543..8977338b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,76 @@
# Changelog
+## [1.91.30.0] - 2026-10-05
+
+**A failed eval now says what happened, with the evidence and the next command.**
+**Bun is checked before anything installs, and make-pdf and /review work in Codex.**
+
+This release follows up on the severe fix wave. Its eight census runs had 21 red verdicts, and 27 of the 28 failed trials were labelled `assertion`, including timeouts and provider refusals, so the reports misdescribed their own failures. Now every failed trial records a machine-checked cause beside the unchanged pass/fail class, and each red line in the census report carries the expected and received values (or the judge's failing scores), the artifact holding the transcript, and a command that reruns only that case. For everyone using gstack: setup refuses a Bun too old to keep a project's `.env` away from gstack's tools, auto-update checks that before moving your checkout, and make-pdf and /review work inside Codex.
+
+### The numbers that matter
+
+Each row is a regression test on this release that fails on v1.91.19.0, or a measurement from the 2026-10-03/04 census artifacts named in the row.
+
+| Check | v1.91.19.0 | v1.91.30.0 |
+|---|---|---|
+| make-pdf blocks on Codex that use `$P` without finding it | 7 of 8 | 0 |
+| /review with a read-only `.git` (Codex's sandbox) | prints nothing, "Nothing to review" | reviews against the local base, says `stale at ` |
+| `./setup` with Bun 1.3.2 | installs; compiled tools read the project's `.env` | refused before writing anything |
+| Census cost line when billing was not captured | `$0.00` | known sum plus "N of M trial(s) cost unknown" |
+| plan-mode-no-op recorded wall (census 37151477069) | 346 s (its test times summed) | 176 s (the shard's wall) |
+| Gate census slowest slice, replanned with measured walls (census 37198445662) | ~677 s | <=539 s planned |
+
+The first three rows are the ones users feel. The last three are why the next census reports are believable: costs, times and causes now mean what they say.
+
+### What changes for you
+
+- **Failure reports explain themselves.** A red census line keeps its error and adds `cause : ` (refusal, API error, provider stall, session or observer timeout, assertion), Expected/Received or `dimension mean < threshold (n samples) "rationale"`, `evidence: ` and `after a repair: `. Model-written text is sanitized before it reaches an issue or PR comment.
+- **Bun floor and safe auto-update.** Setup refuses Bun older than 1.3.3 and warns below 1.4.0, the tested version. Team-mode auto-update and `/gstack-upgrade` read the incoming release's Bun requirement first and hold the update, leaving your install untouched, when your Bun is too old. The first update run by an older auto-updater cannot do this check.
+- **make-pdf on Codex** and the other env-var hosts finds its binary in every block, and `MAKE_PDF_BIN` works there too. Install paths with spaces work.
+- **/review in Codex's sandbox.** The working-tree fingerprint writes its git objects to a private temp directory, and a base that cannot be fetched is reported as stale instead of ending the review.
+- **/freeze, /guard, /unfreeze and /investigate's edit boundary work on Codex** and the other env-var hosts. Their scripts used a path a global install does not have, and setup never installed `freeze/bin`; both are fixed, and /benchmark-models, /retro and /diagram find their helpers the same way.
+- **`browse stop` reaps a surviving headless Chromium** again on Playwright 1.62, only for browsers gstack launched.
+- **/ship's documentation gate** records post-audit file hashes with its helper instead of having the model retype them.
+- **/plan-ceo-review** shows its mode line (`Mode: ...` or `Auto-decided review mode → ...`) word for word right after the mode is chosen.
+- **/qa** names where its report filename parts come from and labels timing as probe budget and guarded command time. **/deslop-shared-libs** names a concrete risk for every recommendation.
+- **/office-hours spec review stops when nothing blocking is left.** Each finding is blocking or minor; minor findings are recorded in Reviewer Concerns and never force another round, and the parent fixes only blocking ones between rounds. Rounds 2 and 3 re-review the exact design diff the helper captured since the last round. The reviewer returns a one-line sha256 receipt for its saved verdict instead of echoing it. Callers can lower the 3-round cap with `--max-rounds`.
+- **`GSTACK_DESIGN_IMAGE_MODEL`** switches the design tool's image model (default `gpt-image-2`); a value that is not a gpt-image model name is refused before any request.
+
+### Behavior changes you may notice
+
+- **LLM judges pass a dimension when 2 of their 3 samples meet its threshold** (EVAL_POLICY v3, the per-dimension median; it was the mean, so one low sample beside two passing ones failed the panel). Thresholds, sample count, judge model and prompts are unchanged; panel logs still show every sample and the mean. Policy v3 and HARNESS_VERSION 2 start a new pass-rate series for every case.
+- **Office-hours review verdicts are schema version 2** (`severity` and `changed_text` on every finding). An in-progress review saved by an older version is refused; start a fresh review directory.
+- **Bun below 1.3.3 stops setup** with `gstack needs Bun 1.3.3 or newer`; nothing is installed. Fix: `bun upgrade`, then `./setup`.
+- **Auto-update can say `update held (bun-too-old: ...)`** and `/gstack-upgrade` can stop with `BUN_TOO_OLD`; your current version keeps working. Fix: `bun upgrade`.
+- **/review in a read-only checkout reports `Base coverage: stale at `** instead of stopping with "Nothing to review".
+- **Browser Quick QA has a 3-minute budget** (was 30 seconds, too short for a homepage and five pages with their checkpoints).
+- **/review's plan-completion question is not asked in spawned or non-interactive runs;** they report REQUIREMENTS MISSING and continue without recording anything.
+- **`live-model-check.ts` ignores `GSTACK_DESIGN_MODEL`** and always checks the default models.
+- **A census dispatched on a branch no longer comments on or closes main's tracking issue** (periodic and marathon). Its report goes to the run's step summary and a `census-report` (or `marathon-report`) artifact; main posts as before.
+- For contributors: `bun run eval:pass-rates` rejects unknown flags and case ids (exit 2) and `--gate` also fails when a session ran above 85% of its armed budget; every `claude -p` eval session streams partial messages (only a liveness summary is kept) and writes `session-ledger.jsonl`; overlay comparisons count an overlay-off wrong answer as a measured result (contract v4); the AUQ matrix scores recommendation substance with a 3-sample judge panel.
+
+### Itemized changes
+
+#### Added
+- `bun run eval:pass-rates --headroom` (slowest armed session per case against its budget), `--reds` (verdict reds by class and cause, with the all-green probability and its interval) and `--run ` (one census's reds, downloading only the slices they name); `--help` works offline.
+- The weekly tracking issue shows every pass-rate alarm line (it showed only the header), the red ledger by class and cause, session headroom, the all-green probability, and a link to the new guide `docs/evals/census-red.md` (from a red census line to a diagnosis with free commands). The marathon issue closes itself on the next green main run.
+- Periodic cases `codex-multiblock-live` (installs gstack for Codex into a fresh `CODEX_HOME` and runs a later /learn block that must find gstack on its own) and `design-model-smoke` (the design tool's default OpenAI models; reported as skipped without `OPENAI_API_KEY`, never as a pass).
+- `docs/troubleshooting.md` sections for the Bun floor, held updates, stale /review bases, fingerprint temp-dir errors, the image model override, an unrecorded Chromium PID, and census report lines.
+
+#### Fixed
+- `bun run scripts/test-paid-shards.ts --case ` selects cases registered in a loop and whole-file cases such as plan-mode-no-op; it used to select nothing.
+- The plan-review floor test approves its own plan-file edit when the diff preview soft-wraps unchanged lines.
+- The /plan-ceo-review approach-menu eval no longer asks for "verbatim ... exact prose" output, which tripped the provider's output-extraction refusal.
+- `gstack-docs-candidate --help` prints its usage and exits 0; its usage error pointed at a `--help` that failed. The docs gate fixtures declare it, and an "atomic replacement unproven" line now names the check and the tool failure that denied it.
+- The prosons neutral-posture check no longer reads "a coverage call, not a taste call" as the neutral dodge.
+
+#### For contributors
+- Session ledger (`test/helpers/session-ledger.ts`), a pure failure-cause classifier beside `failureClassOf`, and one sanitizer for published text (`scripts/lib/published-text.ts`). Verdicts, `failure_class` and `error` are unchanged under EVAL_POLICY v1 (main and branch report code agree on all 15 stored census lanes).
+- Replay corpora for five repeat-offender detectors in the free suite, and an audit of phrase-only detectors (`docs/evals/detector-inventory.md`). The CEO auto-decide detector credits the mode handoff only when the chat shows its line.
+- Judge calibration controls (`docs/evals/judge-controls-2026-10.md`). The workflow judges passed bundles with a required step removed, so `test/workflow-required-steps.test.ts` now checks every step and phase heading of the judged workflow skills.
+- Paid shard durations refreshed from census 37198445662; a JUnit case's recorded wall is capped by its shard's wall.
+- The docsync fixture saves its observation interface to a file its children read instead of having the parent copy it into each child prompt.
+
## [1.91.29.0] - 2026-10-05
**Native runtime qualification can pass for Node, Bun, Python and Rails.**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8947eab8e..4cdcd74fa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -182,9 +182,9 @@ the new defaults.
| I want to… | Command | Cost / time | Needs |
|---|---|---|---|
-| Check an ordinary edit quickly | `bun run test:quick` | Free, about a minute | Bun 1.4.0 |
+| Check an ordinary edit quickly | `bun run test:quick` | Free, about a minute | Bun 1.4.2 |
| Run one free test file while repairing | `bun test ` | Free, seconds | Never bare `bun test` for the suite |
-| Run full free acceptance before publishing | `bun run test` | Free, a few minutes | Bun 1.4.0 |
+| Run full free acceptance before publishing | `bun run test` | Free, a few minutes | Bun 1.4.2 |
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY` |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs) | Free | Windows, Git Bash |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
@@ -207,7 +207,7 @@ Old command names are listed under [Retired commands](#retired-commands).
### Setup
-Development and tests require Bun 1.4.0 or newer; CI pins and tests 1.4.0.
+Development and tests require Bun 1.4.2 or newer; CI pins and tests 1.4.2.
Earlier Linux versions can close unrelated live file descriptors during
subprocess garbage collection, causing intermittent browser and HTTP fixture
failures ([upstream diagnosis](https://github.com/oven-sh/bun/issues/34785#issuecomment-5020318035)).
@@ -284,9 +284,10 @@ constants in `EVAL_POLICY` (`test/helpers/periodic-exclude-data.ts`):
PASS at 2 or more with no contract violation (`expectContract()`). Use it only
when a live model choice decides the verdict and an occasional deviation is
acceptable product behavior; the one-line reason goes in `BEHAVIOR_WHY`.
-- `judge`: an LLM judge scoring a fixed input; 3 samples of the same prompt,
- gated on the per-dimension mean (booleans on a majority) against the
- unchanged threshold. An erroring sample fails the panel and is never resampled.
+- `judge`: an LLM judge scoring a fixed input; exactly 3 samples of the same
+ prompt, each dimension gated on its median, at least 2 of 3 samples, against
+ the unchanged threshold (booleans on a majority; the mean is reported only).
+ An erroring sample fails the panel and is never resampled.
A timed-out, crashed or infrastructure-failed trial counts as a failed trial and
is reported with its class; a missing trial makes the case INCOMPLETE, which
@@ -296,6 +297,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
@@ -477,6 +485,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)
+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
@@ -534,8 +545,8 @@ Override the judge model per run with `GSTACK_EVAL_MODEL_JUDGE`:
- **Actionability** — Can the agent execute tasks using only the information in the doc?
Each dimension is scored 1-5 by a panel of 3 samples of the same prompt, drawn
-concurrently; each dimension's panel mean must meet that judge's threshold (≥ 4
-for most dimensions; see each case). An erroring sample fails the panel. There's also a regression test that compares generated docs against the hand-maintained baseline from `origin/main` — generated must score equal or higher.
+concurrently; each dimension's panel median (at least 2 of 3 samples) must meet
+that judge's threshold (≥ 4 for most dimensions; see each case). An erroring sample fails the panel. There's also a regression test that compares generated docs against the hand-maintained baseline from `origin/main` — generated must score equal or higher.
Needs `ANTHROPIC_API_KEY` in `.env`. The judge files run in every paid lane
(`bun run eval:bg:pr` selects the ones your diff touches).
diff --git a/README.md b/README.md
index 2a6d9b854..d35450885 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source
`./setup` took 31 seconds after the clone on a 4-vCPU Linux cloud machine (v1.91.13.0, clean HOME, including the binary build and the Chromium download). Slower networks and laptops take longer.
-**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only). **Recommended on macOS:** the [Aside](https://aside.com) browser (macOS 15+) — browser skills, `/make-pdf`, and `/diagram` drive it first, with your real logged-in sessions. Without it, `./setup` builds gstack's own bundled browser and the same skills use that. `/cso` additionally needs a Bun release with all four `--no-compile-autoload-*` build flags plus a native toolchain: a static-capable C compiler on Linux, Xcode command-line tools on macOS, or Visual Studio 2022 Build Tools with Desktop development with C++ on Windows. If those are absent, setup installs everything else, removes stale CSO helpers, and `/cso` reports `not assessed` with the prerequisite.
+**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.4.2+ (the tested version; setup refuses Bun older than 1.3.3, which would let compiled tools read a project's `.env`, and warns in between), [Node.js](https://nodejs.org/) (Windows only). **Recommended on macOS:** the [Aside](https://aside.com) browser (macOS 15+) — browser skills, `/make-pdf`, and `/diagram` drive it first, with your real logged-in sessions. Without it, `./setup` builds gstack's own bundled browser and the same skills use that. `/cso` additionally needs a Bun release with all four `--no-compile-autoload-*` build flags plus a native toolchain: a static-capable C compiler on Linux, Xcode command-line tools on macOS, or Visual Studio 2022 Build Tools with Desktop development with C++ on Windows. If those are absent, setup installs everything else, removes stale CSO helpers, and `/cso` reports `not assessed` with the prerequisite.
When qualified CSO runtime images are published, setup gives each automatic preload a 30-second window plus a bounded setup allowance for the declared catalog. For slower registries, set an integer such as `GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS=120` (accepted range: 5–300 seconds). One image timing out does not consume the remaining images' windows; setup reports partial progress and a later run resumes from exact digests already present in local Docker. The complete preload is capped at one hour.
@@ -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/TODOS.md b/TODOS.md
index 9e81b6dac..ca00db47d 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -2,6 +2,96 @@
## NEXT PRIORITY
+### P2/P3: reliability follow-ups deferrals (filed 2026-10-04, v1.91.30.0)
+
+Deferred from the approved reliability follow-ups plan (docs/designs/RELIABILITY_FOLLOWUPS_2026_10.md), each with its reason.
+
+- **Free-suite git hang behind shard neighbors (B5): Bun spawnSync loses the
+ child's exit (oven-sh/bun#34069, open upstream)** — CI shard 1 (PR runs
+ 37242691914 and earlier) timed out `execFileSync`/`spawnSync` git calls in
+ redact-prepush-rebase-force-push, render-sections-provenance and
+ shared-libs-stage-actor. A watcher on Ubicloud caught it: every "hung" git is a
+ zombie (exited, never reaped) while the `bun test` main thread runs, matching
+ the upstream report. CI's shard-1 file list reproduces it in about 3 of 60
+ runs (none of 20 without pty-workspace-trust, not significant). Next step:
+ move the git calls in shared fixture helpers to async spawn with a hard
+ timeout, or take a Bun release that fixes #34069. **Effort:** M. **Priority:** P2.
+- **Judge rubric anchors (C1, needs Garry: EVAL_POLICY)** — the pre-registered
+ control panels (`docs/evals/judge-controls-2026-10.md`, 36 calls) showed
+ every workflow judge separates gross breakage, but the qa workflow judge
+ passed a /qa bundle with Phase 10 Report removed (no sample noticed) and the
+ review judge passed /review with Step 3 removed (two of three samples noticed
+ it and rated it minor); no positive sample scored 5 on any dimension.
+ Missing-step coverage now lives in `test/workflow-required-steps.test.ts`.
+ Proposal: anchor completeness/actionability 4 and 5 to named deliverables
+ and inputs, as a pre-registered EVAL_POLICY change. **Effort:** M. **Priority:** P2.
+- **auto-decide-preserved: the CEO handoff line must reach the chat verbatim**
+ — the native detector now credits the helper's AUTO_DECIDE line only when a
+ chat message begins with it (Claude Code collapses the Bash result). One
+ diagnostic run on the pre-verbatim wording paraphrased it; one on the
+ verbatim wording passed. Watch the next censuses; a red here is a template
+ miss, not a detector false red. **Effort:** S. **Priority:** P2.
+- **Named red: ship-docsync-late-result on PR run 37237194905** — the model
+ wrote its report to a mistyped shard root (`…-3GanyY` for `…-3GanyU`) at
+ call 20, disclosed it, then wrote the real report at the right path. The
+ authority detector is correct (the file was created outside the fixture);
+ `test/docsync-authority.test.ts` replays the capture. If any docsync case
+ writes to a near-miss shard path again, give the model a short stable
+ fixture root (env var or fixed short symlink) instead of random tokens to
+ copy, without loosening the authority check. **Effort:** S. **Priority:** P2.
+- **PTY sessions are cost-unknown** — `test/helpers/pricing.ts` excludes
+ cache-write charges, so PTY trials stay `cost_known: false`. Price them from
+ native transcript usage once pricing covers cache writes. **Effort:** S. **Priority:** P3.
+- **Custom SDK query providers stream no partial messages** — overlay-case,
+ the ship hook/skip actors, workflow-boundaries and the setup-gbrain sandbox
+ record raw events, so their ledger rows carry no stall evidence (never
+ `provider_stall`). Let them drop `stream_event` rows and opt in to stream
+ liveness. **Effort:** S. **Priority:** P3.
+- **Name PTY sessions by case** at their call sites so multi-case shards
+ attribute ledger rows (single-case shards already do). **Effort:** S. **Priority:** P3.
+- **Red lines link the native-captures artifact** — they name the slice
+ artifact; `native-captures-` embeds the job id, which the
+ report job does not know. **Effort:** S. **Priority:** P3.
+- **EVAL_POLICY v2: provider_stall as INFRA** — decide with two censuses of
+ `eval:pass-rates --reds` cause data (labels only today). **Effort:** S. **Priority:** P3 (Garry).
+- **Detector convert candidates (B2)** — `docs/evals/detector-inventory.md`
+ lists 38 phrase-only checks with a structured source (top: S5 screen-first
+ auto-decision, PTY plan_ready/silent_write from journal tool calls, setup
+ selectors keyed on wording, the bws READY probe), and periodic "surface"
+ checks that match the injected SKILL.md body (context-restore-*,
+ scrape-prototype-path). **Effort:** M. **Priority:** P3.
+- **Retain the owned transcript tail in floor captures** — PR run 37176835584's
+ public transcript never showed the pending Edit, so the B3 parser fix may not
+ have been sufficient there. **Effort:** S. **Priority:** P3.
+- **CEO decision procedure latency** (~60 s per question: grid, save,
+ Read-back, ask) bounds plan-ceo-mode-routing's 240 s post-selection window.
+ Named, not cut. **Effort:** M. **Priority:** P3.
+- **Value-card JSON wording** — two of three qa-only judge samples still say
+ "written JSON keeps full values" names no file. Name the JSON per mode (ship
+ Step 7, /test-audit's sidecar) and drop the clause where no JSON carries
+ cards. **Effort:** S. **Priority:** P3.
+- **Windows check of E4's private object directory** —
+ `GIT_ALTERNATE_OBJECT_DIRECTORIES` uses `;` on Git for Windows (only matters
+ when alternates are already set) and the `cygpath -m` conversion is
+ unverified there. **Effort:** S. **Priority:** P3.
+- **Fresh /ship marathon case** — the marathon lane runs a fresh
+ /plan-eng-review; a fresh /ship journey case is new harness work.
+ **Effort:** M. **Priority:** P3.
+- **Refresh the E5/E6 duration seeds** — `codex-multiblock-live` (180 s) and
+ `design-model-smoke` (90 s) are estimates in `scripts/paid-test-durations.json`;
+ refresh them with `--write-durations` from their first census, and close
+ "Periodic paid smoke for the default design model pairing" once it has run
+ there. **Effort:** S. **Priority:** P3.
+- **Env-var hosts: optional runtime files outside the runtime root** — the
+ Chrome extension lookup in /open-gstack-browser and /connect-chrome, and the
+ prose pointers to `$GSTACK_ROOT/docs/...` and `$GSTACK_ROOT/scripts/...`
+ (question-split guide, jargon list, question registry) name files the
+ Codex/Factory runtime roots do not link. Executed fences are covered by
+ `test/env-host-runtime-assets.test.ts`; these reads are not. **Effort:** S. **Priority:** P3.
+- **Product A/B against the stock host agent** — compare gstack end to end
+ with the stock Claude Code/Codex agent on product outcomes (the overlay
+ harness covers model-level deltas only). Its own project. **Effort:** L. **Priority:** P3.
+
### P1–P3: test/eval/CI audit wave follow-ups (filed 2026-10-04)
From the approved 2026-10 test/eval/CI audit plan; the audit PR files these.
@@ -35,7 +125,7 @@ From the approved 2026-10 test/eval/CI audit plan; the audit PR files these.
Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10.md), each with its reason.
-- **Enforce the Bun floor at setup (#2815)** — `engines.bun` is now `>=1.4.0`
+- ~~**Enforce the Bun floor at setup (#2815)**~~ — done in v1.91.30.0: setup refuses Bun below 1.3.3 and warns below 1.4.2; auto-update and /gstack-upgrade check the incoming floor before moving the checkout. Original note: — `engines.bun` is now `>=1.4.0`
(the CI pin), but Bun does not enforce `engines` and setup never checks the
version. Bun older than 1.3.3 silently ignores `--no-compile-autoload-dotenv`
and `--no-env-file` (verified with 1.3.2), so D0's protection against a
@@ -49,12 +139,12 @@ Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10
- **C8 router wording behind a routing panel** — disabled skills now leave the
router, but the "When in doubt, invoke the skill" wording change (Tier 3) was
not run through a routing eval panel. **Effort:** S. **Priority:** P3.
-- **Cross-block variables on fresh-shell hosts: make-pdf `$P`** — make-pdf
+- ~~**Cross-block variables on fresh-shell hosts: make-pdf `$P`**~~ — done in v1.91.30.0: env-var hosts re-derive `$P` in every block (MAKE_PDF_BIN honored). Original note: — make-pdf
"exports" `$P` for later blocks, which Codex and the other env-var hosts lose
between blocks (same class as `$B`/`$D`, which the shared prelude now
re-derives). Add `P` to the runtime prelude or resolve it per block.
**Effort:** S. **Priority:** P2.
-- **#2709's recorded-Chromium reap never runs on Playwright 1.62** — Playwright
+- ~~**#2709's recorded-Chromium reap never runs on Playwright 1.62**~~ — done in v1.91.30.0: the owned headless launch records the browser PID over CDP `SystemInfo.getProcessInfo`. Original note: — Playwright
1.62's `Browser` has no `process()`, so `chromiumProcInfo` is always null and
the cleanup is a no-op (verified by probe). Record the PID another way (CDP
`SystemInfo.getProcessInfo` or the launch server). **Effort:** S. **Priority:** P2.
@@ -201,7 +291,7 @@ Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10
36898545245, 36787269090, 36641820398, 36633323521) across skills and checks
(a two-`(recommended)` option count; office-hours substance). It is kind
`rule` but gates on one judge sample. Decide under EVAL_POLICY whether it is
- a `judge` case (3 samples, mean) and supply pass-rate evidence before
+ a `judge` case (3 samples, median) and supply pass-rate evidence before
changing the kind. Effort S. **Priority:** P2.
- **qa/SKILL.md workflow judge dips below its actionability floor** — census
37186854666 (b24b6d8) scored actionability 3.67 < 4 (clarity 3, completeness
diff --git a/VERSION b/VERSION
index ad61f5dd7..4609f2bd2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.29.0
+1.91.30.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 09b43a5fd..aa4dfe875 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.29.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.30.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
diff --git a/benchmark-models/SKILL.md.tmpl b/benchmark-models/SKILL.md.tmpl
index 27a0413cd..2d804237f 100644
--- a/benchmark-models/SKILL.md.tmpl
+++ b/benchmark-models/SKILL.md.tmpl
@@ -37,7 +37,7 @@ Different from `/benchmark` — that skill measures web page performance (Core W
## Step 0: Locate the binary
```bash
-BIN="$HOME/.claude/skills/gstack/bin/gstack-model-benchmark"
+BIN="{{RUNTIME_ROOT}}/bin/gstack-model-benchmark"
[ -x "$BIN" ] || BIN=".claude/skills/gstack/bin/gstack-model-benchmark"
[ -x "$BIN" ] || { echo "ERROR: gstack-model-benchmark not found. Run ./setup in the gstack install dir." >&2; exit 1; }
echo "BIN: $BIN"
diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh
new file mode 100644
index 000000000..b7b8ccc5a
--- /dev/null
+++ b/bin/gstack-bun-version.sh
@@ -0,0 +1,75 @@
+# shellcheck shell=bash
+# gstack-bun-version.sh — the one source for the Bun versions gstack needs.
+# Sourced, never executed: ./setup refuses or warns from it before writing
+# anything, and bin/gstack-session-update and /gstack-upgrade read the
+# INCOMING release's floor (gstack_bun_incoming_hold) before advancing a
+# live checkout. test/bun-version-drift.test.ts
+# keeps it equal to package.json engines.bun, every CI pin, setup's install
+# hint and README. Bash 3.2 builtins only.
+#
+# GSTACK_BUN_FLOOR is the security floor. Older Bun accepts
+# --no-compile-autoload-dotenv/--no-compile-autoload-bunfig but ignores them,
+# so gstack's compiled tools would read a project's .env. Verified by a
+# compiled probe (a binary built with all four no-autoload flags, run beside a
+# .env): Bun 1.3.2 loads it, 1.3.3 and 1.4.0 do not.
+# GSTACK_BUN_TESTED is the supported minimum: the CI pin and engines.bun.
+GSTACK_BUN_FLOOR="1.3.3"
+GSTACK_BUN_TESTED="1.4.2"
+
+# _gstack_bun_parse — sets _gb_core ("MAJOR MINOR PATCH" as decimal
+# integers) and _gb_pre (prerelease, may be empty); returns 1 when malformed.
+# A leading "v" and "+build" metadata are ignored.
+_gstack_bun_parse() {
+ _gb_v="${1#v}"; _gb_v="${_gb_v%%+*}"; _gb_pre=""
+ case "$_gb_v" in
+ *-*) _gb_pre="${_gb_v#*-}"; _gb_v="${_gb_v%%-*}"; [ -n "$_gb_pre" ] || return 1 ;;
+ esac
+ case "$_gb_v" in *.*.*) ;; *) return 1 ;; esac
+ _gb_maj="${_gb_v%%.*}"; _gb_rest="${_gb_v#*.}"; _gb_min="${_gb_rest%%.*}"; _gb_pat="${_gb_rest#*.}"
+ case "$_gb_maj" in ''|*[!0-9]*) return 1 ;; esac
+ case "$_gb_min" in ''|*[!0-9]*) return 1 ;; esac
+ case "$_gb_pat" in ''|*[!0-9]*) return 1 ;; esac
+ case "$_gb_pre" in *[!0-9A-Za-z.-]*) return 1 ;; esac
+ _gb_core="$((10#$_gb_maj)) $((10#$_gb_min)) $((10#$_gb_pat))"
+}
+
+# _gstack_bun_below — true when version sorts below the
+# release. Prerelease rule (semver): 1.3.3-canary.1 is below 1.3.3 and above
+# 1.3.2, so a prerelease of the floor itself is refused.
+_gstack_bun_below() {
+ _gstack_bun_parse "$2" || return 1
+ set -- "$1" $_gb_core
+ _gstack_bun_parse "$1" || return 1
+ set -- "$@" $_gb_core
+ [ "$5" -ne "$2" ] && { [ "$5" -lt "$2" ]; return; }
+ [ "$6" -ne "$3" ] && { [ "$6" -lt "$3" ]; return; }
+ [ "$7" -ne "$4" ] && { [ "$7" -lt "$4" ]; return; }
+ [ -n "$_gb_pre" ]
+}
+
+# gstack_bun_status [floor] — prints too-old, untested, ok or
+# malformed. The optional floor lets the auto-updater apply the incoming
+# release's floor with this release's comparison rule.
+gstack_bun_status() {
+ if ! _gstack_bun_parse "$1"; then echo malformed
+ elif _gstack_bun_below "$1" "${2:-$GSTACK_BUN_FLOOR}"; then echo too-old
+ elif _gstack_bun_below "$1" "$GSTACK_BUN_TESTED"; then echo untested
+ else echo ok
+ fi
+}
+
+# gstack_bun_incoming_hold — the pre-advance check shared by
+# the auto-updater and /gstack-upgrade. Reads the floor from 's copy of
+# this file and checks the `bun` setup will run (first on PATH). Below that
+# floor it prints the held reason and returns 0; otherwise it prints nothing
+# and returns 1 (no bun, no floor in , or an unparseable version, which
+# setup only warns on). The comparison rule is this release's.
+gstack_bun_incoming_hold() {
+ command -v bun >/dev/null 2>&1 || return 1
+ _gb_floor=$(git -C "$1" show "$2:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_FLOOR="\([0-9A-Za-z.+-]*\)".*/\1/p' | head -1)
+ [ -n "$_gb_floor" ] || return 1
+ _gb_found=$(bun --version 2>/dev/null | head -1)
+ [ "$(gstack_bun_status "$_gb_found" "$_gb_floor")" = too-old ] || return 1
+ _gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1)
+ echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer"
+}
diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate
index 79dd2fdcb..880f48cdf 100755
--- a/bin/gstack-docs-candidate
+++ b/bin/gstack-docs-candidate
@@ -83,8 +83,15 @@ function one(args: string[], name: string): string {
return values[0]!;
}
+const USAGE = `usage: gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only --base [--select ]... [--docs ]...
+ gstack-docs-candidate compare `;
+
function main(argv: string[]): void {
const [command, ...args] = argv;
+ if ((command === '--help' || command === '-h') && !args.length) {
+ console.log(USAGE);
+ return;
+ }
if (command === 'snapshot') {
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs']);
for (let i = 0; i < args.length; i += 2) if (!known.has(args[i]!)) throw new Error(`unknown argument: ${args[i]}`);
@@ -131,7 +138,7 @@ function main(argv: string[]): void {
}));
return;
}
- throw new Error('usage: gstack-docs-candidate snapshot|compare (see --help in the file header)');
+ throw new Error(USAGE);
}
try { main(process.argv.slice(2)); }
diff --git a/bin/gstack-office-hours-review b/bin/gstack-office-hours-review
index 125c08d1b..4716e1d14 100755
--- a/bin/gstack-office-hours-review
+++ b/bin/gstack-office-hours-review
@@ -2,30 +2,54 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { atomicWriteSync } from '../lib/fs-atomic.ts';
-import { assessOfficeHoursReviews, loadOfficeHoursReviews, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock } from '../lib/office-hours-review.ts';
+import { assessOfficeHoursReviews, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts';
+
+const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`;
function main(args) {
const command = args.shift();
- if (!['check', 'prepare', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check | prepare --design --out-dir [round.json...] | finalize --design [--report ] [--unreviewed ] ');
+ if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check --receipt | prepare --design --out-dir [round.json...] | seal --design --verdict [preceding round.json...] | finalize --design [--report ] [--unreviewed ] (each also takes --max-rounds 1|2|3, default 3)');
const options = {};
const files = [];
while (args.length) {
const value = args.shift();
if (value.startsWith('--')) {
- const allowed = command === 'prepare' ? ['--design', '--out-dir'] : command === 'finalize' ? ['--design', '--report', '--unreviewed'] : [];
+ const allowed = [...{ check: ['--receipt'], prepare: ['--design', '--out-dir'], seal: ['--design', '--verdict'], finalize: ['--design', '--report', '--unreviewed'] }[command], '--max-rounds'];
if (!allowed.includes(value)
|| options[value] !== undefined || !args.length) throw new Error(`invalid option: ${value}`);
options[value] = args.shift();
} else files.push(value);
}
- const rounds = loadOfficeHoursReviews(files);
+ const maxRounds = officeHoursMaxRounds(options['--max-rounds']);
+ const rounds = loadOfficeHoursReviews(files, maxRounds);
if (command === 'check') {
- console.log(JSON.stringify(assessOfficeHoursReviews(rounds)));
+ // The receipt binds the reviewer's response to the last round's exact bytes.
+ if (!options['--receipt'] || !files.length) throw new Error('check requires --receipt with the reviewer response and every completed round');
+ const last = files.at(-1);
+ const named = parseOfficeHoursVerdictReceipt(options['--receipt']).path;
+ const same = fs.existsSync(named) && fs.realpathSync(named) === fs.realpathSync(last);
+ verifyOfficeHoursVerdictReceipt(options['--receipt'], { round: files.length, verdictPath: same ? named : last, bytes: fs.readFileSync(last) });
+ console.log(JSON.stringify(assessOfficeHoursReviews(rounds, maxRounds)));
return;
}
if (!options['--design']) throw new Error(`${command} requires --design`);
const designPath = path.resolve(options['--design']);
if (rounds.some(review => path.resolve(review.document) !== designPath)) throw new Error('artifact document does not match --design');
+ if (command === 'seal') {
+ if (!options['--verdict']) throw new Error('seal requires --verdict');
+ const verdictPath = path.resolve(options['--verdict']);
+ const round = rounds.length + 1;
+ if (path.basename(verdictPath) !== `round-${round}.json`) throw new Error(`verdict must be round-${round}.json after the supplied history`);
+ const bytes = fs.readFileSync(verdictPath);
+ let value;
+ try { value = JSON.parse(bytes.toString('utf8')); }
+ catch (error) { throw new Error(`saved verdict is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); }
+ const review = assessOfficeHoursReviews([...rounds, value], maxRounds).rounds.at(-1);
+ if (path.resolve(review.document) !== designPath) throw new Error('verdict document does not match --design');
+ if (round > 1) verifyOfficeHoursCitations(review, officeHoursSnapshotChanges(verdictPath, round).changed);
+ console.log(officeHoursVerdictReceipt(round, verdictPath, bytes));
+ return;
+ }
if (command === 'prepare') {
if (!options['--out-dir']) throw new Error('prepare requires --out-dir');
if (!fs.statSync(designPath).isFile()) throw new Error('design must be an existing file');
@@ -34,17 +58,30 @@ function main(args) {
const round = rounds.length + 1;
const promptPath = path.join(outDir, `round-${round}.prompt.md`);
const verdictPath = path.join(outDir, `round-${round}.json`);
- const prompt = renderOfficeHoursReviewerPrompt({ document: designPath, verdictPath, previous: rounds.at(-1) });
+ // Each round records the exact design it reviewed; later rounds see the diff since then.
+ const snapshotPath = path.join(outDir, `round-${round}.design.md`);
+ const design = fs.readFileSync(designPath, 'utf8');
+ let changes;
+ if (round > 1 && assessOfficeHoursReviews(rounds, maxRounds).stop !== 'CONTINUE') throw new Error('cannot prepare another round after a terminal review');
+ if (round > 1) {
+ const before = path.join(outDir, `round-${round - 1}.design.md`);
+ if (!fs.existsSync(before)) throw new Error(`out-dir lacks round-${round - 1}.design.md; prepare every round in the same review directory`);
+ changes = officeHoursDesignChanges(fs.readFileSync(before, 'utf8'), design).diff;
+ }
+ const prompt = renderOfficeHoursReviewerPrompt({ document: designPath, verdictPath, previous: rounds.at(-1), changes, maxRounds });
const inputs = new Set([fs.realpathSync(designPath), ...files.map(file => fs.realpathSync(file))]);
- for (const destination of [promptPath, verdictPath]) {
+ for (const destination of [promptPath, verdictPath, snapshotPath]) {
if (fs.existsSync(destination) && inputs.has(fs.realpathSync(destination))) throw new Error('output destination overlaps input evidence');
}
if (fs.lstatSync(verdictPath, { throwIfNoEntry: false })) throw new Error('next verdict already exists; check it before preparing another attempt');
+ const captured = fs.lstatSync(snapshotPath, { throwIfNoEntry: false });
+ if (captured && (!captured.isFile() || fs.readFileSync(snapshotPath, 'utf8') !== design)) throw new Error(`the design changed after round ${round} was prepared`);
const existing = fs.lstatSync(promptPath, { throwIfNoEntry: false });
- if (existing) {
- if (!existing.isFile() || fs.readFileSync(promptPath, 'utf8') !== prompt) throw new Error('existing review prompt differs from the complete saved evidence');
- } else atomicWriteSync(promptPath, prompt, { mode: 0o600 });
- const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}`;
+ if (existing && (!existing.isFile() || fs.readFileSync(promptPath, 'utf8') !== prompt)) throw new Error('existing review prompt differs from the complete saved evidence');
+ if (!captured) atomicWriteSync(snapshotPath, design, { mode: 0o600 });
+ if (!existing) atomicWriteSync(promptPath, prompt, { mode: 0o600 });
+ const seal = [shellQuote(fs.realpathSync(process.argv[1])), 'seal', ...(maxRounds === 3 ? [] : [`--max-rounds ${maxRounds}`]), '--design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' ');
+ const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nSeal: ${seal}`;
console.log(JSON.stringify({ round, promptPath, verdictPath, dispatch }));
return;
}
@@ -55,7 +92,7 @@ function main(args) {
if (inputs.has(fs.realpathSync(destination))) throw new Error('output destination overlaps a review artifact');
}
if (reportPath && fs.realpathSync(reportPath) === fs.realpathSync(designPath)) throw new Error('design and report must be different files');
- const rendered = renderOfficeHoursReview(rounds, options['--unreviewed']);
+ const rendered = renderOfficeHoursReview(rounds, options['--unreviewed'], maxRounds);
// Validate/read/render both destinations before publishing either. Each rename
// is atomic; any publication error fails the command and final verification.
const design = fs.readFileSync(designPath, 'utf8');
diff --git a/bin/gstack-review-log b/bin/gstack-review-log
index c1bdab264..b2b309523 100755
--- a/bin/gstack-review-log
+++ b/bin/gstack-review-log
@@ -40,7 +40,22 @@ WTREE=""
DIRTY=""
if [ -n "$COMMIT_FULL" ]; then
TREE=$(git rev-parse 'HEAD^{tree}' 2>/dev/null || true)
- WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || true)
+ # E4: the fingerprint's new objects go to a private 0700 directory this
+ # operation owns (a read-only .git still fingerprints), and stay readable
+ # until the consumers below (bindReview / checkSharedLibsReuse read the tree
+ # and blobs) finish; removed on EXIT/INT/TERM. gstack-wtree's one-line
+ # diagnostic reaches stderr; stdout stays the hash.
+ if WTREE_OBJECTS=$(mktemp -d "${TMPDIR:-/tmp}/gstack-wtree-objects-XXXXXX" 2>/dev/null); then
+ case "$(uname -s)" in
+ MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && WTREE_OBJECTS="$(cygpath -m "$WTREE_OBJECTS")" ;;
+ esac
+ trap 'rm -rf "$WTREE_OBJECTS"' EXIT
+ trap 'exit 130' INT TERM
+ WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" || true)
+ export GIT_ALTERNATE_OBJECT_DIRECTORIES="$WTREE_OBJECTS${GIT_ALTERNATE_OBJECT_DIRECTORIES:+:$GIT_ALTERNATE_OBJECT_DIRECTORIES}"
+ else
+ echo "gstack-review-log: cannot create a private object directory under ${TMPDIR:-/tmp}; no working-tree fingerprint. Set TMPDIR to a writable directory." >&2
+ fi
if [ -n "$(git status --porcelain -uno 2>/dev/null | head -1)" ]; then
DIRTY="true"
else
diff --git a/bin/gstack-session-update b/bin/gstack-session-update
index 0fa33bb35..259ec2aa9 100755
--- a/bin/gstack-session-update
+++ b/bin/gstack-session-update
@@ -5,6 +5,11 @@
# The entire update runs in background (forked). The hook itself exits
# immediately so session startup is never delayed.
#
+# Before the live checkout moves, the updater fetches, reads the incoming
+# release's Bun floor (bin/gstack-bun-version.sh) and checks the `bun` setup
+# will run; below it the update is held with reason bun-too-old and nothing
+# changes (E1). Updaters older than this check cannot protect their first pull.
+#
# Stages (B11): pull, setup and migrations are tracked separately. The
# "just upgraded" marker is written only when every stage succeeded. A failed
# stage is persisted in $STATE_DIR/session-update-pending and retried even
@@ -25,6 +30,9 @@ STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
# Egress receipt helpers (_receipted_git): fail-open — an update pull must
# never block a session over a receipt hiccup.
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
+# Bun floor comparison (E1): the incoming release's floor is applied before
+# the live checkout moves. Missing helper fails open, like every error here.
+. "$(cd "$(dirname "$0")" && pwd)/gstack-bun-version.sh" 2>/dev/null
THROTTLE_FILE="$STATE_DIR/.last-session-update"
LOCK_DIR="$STATE_DIR/.setup-lock"
LOG_FILE="$STATE_DIR/analytics/session-update.log"
@@ -63,7 +71,12 @@ if [ -f "$PENDING_FILE" ]; then
case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac
echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX"
elif [ -n "$_P_PULL" ]; then
- echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup"
+ case "$_P_PULL" in
+ bun-too-old*)
+ echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: bun upgrade && cd $GSTACK_DIR && git pull --ff-only && ./setup" ;;
+ *)
+ echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" ;;
+ esac
fi
fi
@@ -158,7 +171,7 @@ fi
# heartbeat.
trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
- # ── Pull latest ──
+ # ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ──
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}"
@@ -170,8 +183,22 @@ fi
# Capture stderr: the log must carry WHY a pull failed, never just the code.
PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "")
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
- bash -c 'git -C "$1" pull --ff-only --autostash -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE"
+ bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE"
PULL_EXIT=$?
+ # The fast-forward targets the exact revision whose requirement was read.
+ # Setup runs `bun` from this PATH, so that is the binary checked; a
+ # malformed version passes here because setup only warns on it.
+ INCOMING="" BUN_HELD=""
+ if [ "$PULL_EXIT" -eq 0 ]; then
+ INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1
+ fi
+ if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ]; then
+ BUN_HELD=$(gstack_bun_incoming_hold "$GSTACK_DIR" "$INCOMING" 2>/dev/null) || BUN_HELD=""
+ fi
+ if [ "$PULL_EXIT" -eq 0 ] && [ -z "$BUN_HELD" ]; then
+ git -C "$GSTACK_DIR" merge --ff-only --autostash -q "$INCOMING" >/dev/null 2>"${PULL_ERR_FILE:-/dev/null}"
+ PULL_EXIT=$?
+ fi
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
# Heartbeat: pull done — keep the TTL clock fresh for the setup step.
@@ -185,6 +212,13 @@ fi
case "$P_FAILURES" in ''|*[!0-9]*) P_FAILURES=0 ;; esac
case "$P_NEXT" in ''|*[!0-9]*) P_NEXT=0 ;; esac
+ if [ -n "$BUN_HELD" ]; then
+ log_entry "HELD $BUN_HELD incoming=$INCOMING"
+ P_PULL="$BUN_HELD"
+ pending_save
+ rm -f "$PULL_ERR_FILE" 2>/dev/null
+ exit 0
+ fi
if [ "$PULL_EXIT" -ne 0 ]; then
PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ')
log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}"
diff --git a/bin/gstack-wtree b/bin/gstack-wtree
index 94265fa7d..b2c1c258c 100755
--- a/bin/gstack-wtree
+++ b/bin/gstack-wtree
@@ -21,11 +21,17 @@
# produce the identical write-tree hash. Fallback: `read-tree HEAD` when the
# index copy is unavailable (fresh repo, exotic index).
#
-# The real repo index is never touched. Staged blobs land in the object store
-# as unreachable objects and get gc'd like stash churn (note: this means the
+# The real repo index is never touched. Run standalone, staged blobs land in
+# the object store as unreachable objects and get gc'd like stash churn (the
# CONTENT of untracked, non-ignored files enters .git/objects until gc — the
-# same property `git stash -u` has). Exit 1 outside a git repo or in a repo
-# with no commits — callers treat that as "no fingerprint".
+# same property `git stash -u` has), so `git diff ` between saved
+# snapshots works. With GSTACK_WTREE_OBJECT_DIR set (gstack-review-log, E4),
+# new objects go to that private directory instead, with the repository's
+# object store as an alternate: the fingerprint works under a read-only .git
+# (Codex's sandbox), and the caller owns the directory's lifetime because its
+# consumers read the tree afterwards. Exit 1 outside a git repo, in a repo with
+# no commits, or when a write fails (one stderr line names the cause) — callers
+# treat that as "no fingerprint"; a partial hash is never printed.
set -euo pipefail
TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
@@ -33,14 +39,34 @@ TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
# var set, `git rev-parse --git-path index` returns the temp index itself and
# the stat-cache seed silently self-copies into a dead fast path.
REAL_INDEX=$(git -C "$TOP" rev-parse --git-path index 2>/dev/null || true)
-TMPIDX=$(mktemp "${TMPDIR:-/tmp}/gstack-wtree-XXXXXX")
+# Same for the object store: the common dir's objects (linked worktrees share
+# it), resolved to an absolute path before GIT_OBJECT_DIRECTORY is exported.
+REAL_OBJECTS=$(git -C "$TOP" rev-parse --git-path objects 2>/dev/null || true)
+OBJ_DIR="${GSTACK_WTREE_OBJECT_DIR:-}"
+if [ -n "$OBJ_DIR" ] && [ ! -d "$OBJ_DIR" ]; then
+ echo "gstack-wtree: private object directory $OBJ_DIR does not exist" >&2
+ exit 1
+fi
+TMPIDX=$(mktemp "${TMPDIR:-/tmp}/gstack-wtree-XXXXXX" 2>/dev/null) || {
+ echo "gstack-wtree: cannot create a temp index under ${TMPDIR:-/tmp}; set TMPDIR to a writable directory" >&2
+ exit 1
+}
trap 'rm -f "$TMPIDX"' EXIT
+trap 'exit 130' INT TERM
export GIT_INDEX_FILE="$TMPIDX"
# Resolve relative --git-path output against the repo root.
case "$REAL_INDEX" in
""|/*) ;;
*) REAL_INDEX="$TOP/$REAL_INDEX" ;;
esac
+case "$REAL_OBJECTS" in
+ ""|/*) ;;
+ *) REAL_OBJECTS="$TOP/$REAL_OBJECTS" ;;
+esac
+if [ -n "$OBJ_DIR" ]; then
+ export GIT_OBJECT_DIRECTORY="$OBJ_DIR"
+ export GIT_ALTERNATE_OBJECT_DIRECTORIES="$REAL_OBJECTS${GIT_ALTERNATE_OBJECT_DIRECTORIES:+:$GIT_ALTERNATE_OBJECT_DIRECTORIES}"
+fi
if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2>/dev/null; then
# Carry the real index's mtime onto the copy. Git's racy-git protection
# re-hashes any entry whose cached mtime is not older than the index file
@@ -58,5 +84,12 @@ if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2>
else
git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1
fi
-git -C "$TOP" add -A 2>/dev/null || exit 1
-git -C "$TOP" write-tree 2>/dev/null
+if ! ERR=$(git -C "$TOP" add -A 2>&1 >/dev/null); then
+ echo "gstack-wtree: cannot stage the working tree: $(printf '%s' "$ERR" | head -1)" >&2
+ exit 1
+fi
+if ! TREE=$(git -C "$TOP" write-tree 2>&1) || ! printf '%s' "$TREE" | grep -Eqx '[0-9a-f]{40}|[0-9a-f]{64}'; then
+ echo "gstack-wtree: cannot write the tree: $(printf '%s' "$TREE" | head -1)" >&2
+ exit 1
+fi
+printf '%s\n' "$TREE"
diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts
index 2ae2221d8..c976511ed 100644
--- a/browse/src/browser-manager.ts
+++ b/browse/src/browser-manager.ts
@@ -182,6 +182,41 @@ function launchedProcess(browser: Browser | null | undefined): ChildProcess | nu
return typeof withProcess?.process === 'function' ? withProcess.process() : null;
}
+/**
+ * #2709: PID of the Chromium browser process gstack launched, read over CDP
+ * (`SystemInfo.getProcessInfo`, type "browser") because Playwright 1.62's
+ * Browser has no `process()`. Call it only for a launch gstack owns; a
+ * connected browser belongs to someone else and must never be reaped. Bounded:
+ * a hung or failed CDP call logs once and returns null, which means no reap.
+ */
+export async function launchedChromiumPid(browser: Browser, timeoutMs = 2_000): Promise {
+ let timer: ReturnType | undefined;
+ const query = async (): Promise => {
+ const session = await browser.newBrowserCDPSession();
+ try {
+ const { processInfo } = await session.send('SystemInfo.getProcessInfo');
+ const pid = processInfo.find((p) => p.type === 'browser')?.id;
+ return Number.isInteger(pid) && pid! > 0 ? pid! : null;
+ } finally {
+ await session.detach().catch(() => {});
+ }
+ };
+ try {
+ return await Promise.race([
+ query(),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
+ }),
+ ]);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.warn(`[browse] Could not record the Chromium PID (CDP SystemInfo.getProcessInfo: ${msg}); browse stop cannot reap a surviving Chromium.`);
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
/**
* Resolve why the underlying Chromium ChildProcess is going away.
*
@@ -669,12 +704,10 @@ export class BrowserManager {
});
// #2709: record the child's identity so the CLI can reap a survivor after
- // daemon shutdown. `.process()` exists here — we launched this browser.
+ // daemon shutdown. We launched this browser, so its PID is ours to record.
{
- const proc = launchedProcess(this.browser);
- this.chromiumProcInfo = proc?.pid
- ? { pid: proc.pid, startTime: readPidStartTime(proc.pid) }
- : null;
+ const pid = await launchedChromiumPid(this.browser);
+ this.chromiumProcInfo = pid ? { pid, startTime: readPidStartTime(pid) } : null;
}
this.context = await this.browser.newContext(contextOptions);
diff --git a/browse/src/cli.ts b/browse/src/cli.ts
index 0bc17876e..e7b5b1f91 100644
--- a/browse/src/cli.ts
+++ b/browse/src/cli.ts
@@ -2080,7 +2080,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// Playwright — but on macOS 26 the GPU process can survive that close and
// spin at ~800% CPU forever. The state snapshot read above still carries
// the launched child's identity; reap a verified survivor.
+ // Reap only after the daemon has finished its own shutdown: killing Chromium
+ // while the daemon is still closing reads as a crash, and the daemon exits(1)
+ // without removing its state file.
if (command === 'stop') {
+ const deadline = Date.now() + 5_000;
+ while (Date.now() < deadline && isProcessAlive(state.pid)) await Bun.sleep(100);
await reapRecordedChromium(state);
}
diff --git a/browse/test/cookie-import-native-job.test.ts b/browse/test/cookie-import-native-job.test.ts
index de5c726ca..6b0431008 100644
--- a/browse/test/cookie-import-native-job.test.ts
+++ b/browse/test/cookie-import-native-job.test.ts
@@ -343,7 +343,7 @@ const request: NativeCookieRequest = {
profile: 'Default',
domains: ['example.test'],
deadline: 25_000,
- qualifiedBunVersions: ['1.4.0'],
+ qualifiedBunVersions: ['1.4.2'],
};
function kernelContract(mode: string) {
diff --git a/browse/test/cookie-import-native-qualification.ts b/browse/test/cookie-import-native-qualification.ts
index 950fcc39b..8c703c9fe 100644
--- a/browse/test/cookie-import-native-qualification.ts
+++ b/browse/test/cookie-import-native-qualification.ts
@@ -24,7 +24,7 @@ function incomplete(reason: string, diagnostic?: NativeCookieDiagnostic): never
console.log(JSON.stringify({ ...receipt, artifactDirectory: output }));
process.exit(2);
}
-if (Bun.version !== '1.4.0') incomplete('bun_1_4_0_required');
+if (Bun.version !== '1.4.2') incomplete('bun_1_4_2_required');
let emptyJob: NativeCookieJob | undefined;
try {
emptyJob = await createNativeCookieJob();
diff --git a/browse/test/dia-launch-comparison.test.ts b/browse/test/dia-launch-comparison.test.ts
index 871473c2a..cab823ae6 100644
--- a/browse/test/dia-launch-comparison.test.ts
+++ b/browse/test/dia-launch-comparison.test.ts
@@ -98,7 +98,7 @@ describe('diagnostic-only Dia runtime comparison', () => {
const arm = (runtime: 'bun' | 'node', ready: boolean) => {
const sha = 'a'.repeat(64);
const config = { mode: 'launch-only', runtime, qualificationCredit: false, executableSha256: runtime === 'bun' ? sha : 'b'.repeat(64), driverSha256: sha, helpersSha256: sha };
- const driver = { runtime, version: runtime === 'bun' ? '1.4.0' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1',
+ const driver = { runtime, version: runtime === 'bun' ? '1.4.2' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1',
executableSha256: config.executableSha256, driverSha256: sha, helpersSha256: sha };
const result = (purpose: string, success: boolean) => ({ protocol: 1, purpose, ready: success, launchReturned: success, protocolResponded: success,
samplingEnabled: false, rootCount: 1, supervisor: { closed: true, exitCode: 0 }, cleanup: { confirmed: true, childClosed: true, groupAbsent: true, launchSettled: true,
@@ -112,7 +112,7 @@ describe('diagnostic-only Dia runtime comparison', () => {
qualification: { launchComparison: { qualificationCredit: false, source: result('source', ready) }, counts: { pass: 0, fail: 0, skip: 0 },
keychainStage: 'completed', isolation: { registeredIdentity: true, sharedRegisteredHome: true },
cleanup: { ownedBrowsersStopped: true, sourceProfileRemoved: true, keychainRestored: true, mountDetached: true, fixtureRemoved: true },
- platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.0', playwright: '1.62.1', release: '24.6.0' },
+ platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.2', playwright: '1.62.1', release: '24.6.0' },
artifact: { signatureVerified: true, gatekeeperNotarized: true, macosCompatibility: { compatible: true, hostVersion: '15.7.9' },
architectures: ['arm64'], sha256: sha, executableSha256: sha, version: '1.49.1', bundleId: 'company.thebrowser.dia', team: 'S6N382Y83G' } } };
};
diff --git a/browse/test/headless-gpu-and-reap.test.ts b/browse/test/headless-gpu-and-reap.test.ts
index cef5776e1..af12ada21 100644
--- a/browse/test/headless-gpu-and-reap.test.ts
+++ b/browse/test/headless-gpu-and-reap.test.ts
@@ -14,10 +14,11 @@
*/
import { describe, expect, test } from 'bun:test';
import { spawn } from 'node:child_process';
-import { headlessGpuArgs } from '../src/browser-manager';
+import { BrowserManager, headlessGpuArgs, launchedChromiumPid } from '../src/browser-manager';
import { reapRecordedChromium } from '../src/cli';
import { readPidStartTime } from '../src/xvfb';
import { isProcessAlive } from '../src/error-handling';
+import { chromium, type Browser } from 'playwright';
describe('headlessGpuArgs (#2709)', () => {
test('darwin gets the validated four-flag set', () => {
@@ -105,6 +106,83 @@ describe.skipIf(process.platform !== 'linux')('reapRecordedChromium identity gat
});
});
+// E3: Playwright 1.62's Browser has no process(), so the recorded identity was
+// always null and the reap above never ran. The PID now comes from CDP
+// SystemInfo.getProcessInfo, only for a launch gstack owns.
+describe.skipIf(process.platform !== 'linux')('Chromium PID via CDP on the pinned Playwright (#2709, E3)', () => {
+ const closeQuietly = (b: { close(): Promise } | null | undefined) =>
+ Promise.race([b?.close().catch(() => {}), new Promise((r) => setTimeout(r, 3_000))]);
+
+ test('an owned headless launch records the browser PID and start time, and the reap kills it', async () => {
+ const bm = new BrowserManager();
+ await bm.launch();
+ try {
+ const info = bm.getChromiumProcInfo();
+ expect(info, 'headless launch recorded no Chromium identity').not.toBeNull();
+ expect(isProcessAlive(info!.pid)).toBe(true);
+ expect(info!.startTime).toBe(readPidStartTime(info!.pid));
+ expect(info!.startTime).not.toBe('');
+ await reapRecordedChromium({ chromiumPid: info!.pid, chromiumStartTime: info!.startTime });
+ expect(isProcessAlive(info!.pid)).toBe(false);
+ } finally {
+ await closeQuietly(bm);
+ }
+ }, 60_000);
+
+ test('an externally owned Chromium is never reaped: its PID is not recorded and a stale identity never matches', async () => {
+ const external = await chromium.launch({ headless: true, chromiumSandbox: false });
+ const bm = new BrowserManager();
+ await bm.launch();
+ try {
+ const externalPid = await launchedChromiumPid(external);
+ expect(externalPid).not.toBeNull();
+ const ours = bm.getChromiumProcInfo();
+ expect(ours?.pid).not.toBe(externalPid);
+ await reapRecordedChromium({ chromiumPid: ours!.pid, chromiumStartTime: ours!.startTime });
+ expect(isProcessAlive(externalPid!)).toBe(true);
+ // A recycled PID: a live Chromium at the recorded PID with another start time.
+ await reapRecordedChromium({ chromiumPid: externalPid!, chromiumStartTime: 'Mon Jan 1 00:00:00 1990' });
+ expect(isProcessAlive(externalPid!)).toBe(true);
+ } finally {
+ await closeQuietly(bm);
+ await closeQuietly(external);
+ }
+ }, 60_000);
+});
+
+describe('launchedChromiumPid bounded CDP failure (E3)', () => {
+ const stub = (newBrowserCDPSession: () => Promise) => ({ newBrowserCDPSession }) as unknown as Browser;
+
+ test('a hung CDP session returns null within the bound and logs once', async () => {
+ const warn = console.warn;
+ const lines: string[] = [];
+ console.warn = (...args: unknown[]) => { lines.push(args.join(' ')); };
+ try {
+ const t0 = Date.now();
+ expect(await launchedChromiumPid(stub(() => new Promise(() => {})), 100)).toBeNull();
+ expect(Date.now() - t0).toBeLessThan(2_000);
+ } finally {
+ console.warn = warn;
+ }
+ expect(lines).toEqual([
+ '[browse] Could not record the Chromium PID (CDP SystemInfo.getProcessInfo: timed out after 100ms); browse stop cannot reap a surviving Chromium.',
+ ]);
+ });
+
+ test('a failing or browser-less answer returns null', async () => {
+ const warn = console.warn;
+ console.warn = () => {};
+ try {
+ expect(await launchedChromiumPid(stub(async () => { throw new Error('Target closed'); }))).toBeNull();
+ const session = (processInfo: unknown[]) => async () => ({ send: async () => ({ processInfo }), detach: async () => {} });
+ expect(await launchedChromiumPid(stub(session([{ type: 'GPU', id: 4242 }])))).toBeNull();
+ expect(await launchedChromiumPid(stub(session([{ type: 'browser', id: 4242 }])))).toBe(4242);
+ } finally {
+ console.warn = warn;
+ }
+ });
+});
+
// ─── Source pins: the #2709 pieces stay WIRED ────────────────────────────
// The unit tests above prove headlessGpuArgs and reapRecordedChromium behave;
// these pins prove the daemon persists the child's identity, the CLI reaps on
@@ -176,10 +254,12 @@ describe('stop-reap wiring pins (#2709)', () => {
);
// 5. Post-graceful-stop: the daemon closed Chromium via Playwright, but a
- // surviving GPU process must still be reaped after sendCommand('stop').
+ // surviving GPU process must still be reaped after sendCommand('stop') —
+ // only once the daemon has exited (an earlier kill reads as a crash and
+ // the daemon exits without removing its state file).
const postStop = between('await sendCommand(state, command, commandArgs);', "if (command === 'focus')");
expect(postStop).toMatch(
- /if \(command === 'stop'\) \{\s*\n\s*await reapRecordedChromium\(state\);/,
+ /if \(command === 'stop'\) \{[\s\S]*while \(.*isProcessAlive\(state\.pid\)\)[^\n]*\n\s*await reapRecordedChromium\(state\);/,
);
});
@@ -195,4 +275,12 @@ describe('stop-reap wiring pins (#2709)', () => {
const headless = src.slice(src.indexOf(' async launch() {'), src.indexOf(' async launchHeaded('));
expect(headless).toContain('launchArgs.push(...headlessGpuArgs(process.platform, process.env));');
});
+
+ test('launchedChromiumPid is called only by the owned headless launch()', () => {
+ const src = read('browser-manager.ts');
+ const calls = src.match(/await launchedChromiumPid\(/g) ?? [];
+ expect(calls.length).toBe(1);
+ const headless = src.slice(src.indexOf(' async launch() {'), src.indexOf(' async launchHeaded('));
+ expect(headless).toContain('const pid = await launchedChromiumPid(this.browser);');
+ });
});
diff --git a/browse/test/navigation-guard.test.ts b/browse/test/navigation-guard.test.ts
index 45da751ca..4a28cb918 100644
--- a/browse/test/navigation-guard.test.ts
+++ b/browse/test/navigation-guard.test.ts
@@ -4,6 +4,7 @@
* server.ts runs write commands: through BrowserManager.failIfNavigationBlocked.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
import { BrowserManager } from '../src/browser-manager';
import { handleWriteCommand } from '../src/write-commands';
@@ -48,19 +49,31 @@ function run(command: string, args: string[]): Promise {
return bm.failIfNavigationBlocked(session.getPage(), handleWriteCommand(command, args, session, bm));
}
+// While Bun's promise matchers (rejects/resolves) wait, each Playwright round-trip takes
+// about a second, which pushed the 5 s click timeout past the guard's block under load.
+// Settle the command first, then assert on the refusal.
+async function refusal(command: string, args: string[]): Promise {
+ try {
+ await run(command, args);
+ } catch (err) {
+ return err instanceof Error ? err.message : String(err);
+ }
+ throw new Error(`${command} ${args.join(' ')} was not refused`);
+}
+
describe('D2: navigation guard', () => {
test('a redirect to 169.254.0.0/16 fails the goto and leaves the tab blank', async () => {
- await expect(run('goto', [`${base}/to-link-local`])).rejects.toThrow(/169\.254\.170\.2 is a cloud metadata or link-local address/);
+ expect(await refusal('goto', [`${base}/to-link-local`])).toMatch(/169\.254\.170\.2 is a cloud metadata or link-local address/);
expect(bm.getActiveSession().getPage().url()).toBe('about:blank');
}, 30_000);
test('a redirect to an IPv4-mapped IPv6 link-local address is refused too', async () => {
- await expect(run('goto', [`${base}/to-mapped`])).rejects.toThrow(/cloud metadata or link-local/);
+ expect(await refusal('goto', [`${base}/to-mapped`])).toMatch(/cloud metadata or link-local/);
expect(bm.getActiveSession().getPage().url()).toBe('about:blank');
}, 30_000);
test('a blocked target that fails at once still leaves the tab blank for the next command', async () => {
- await expect(run('goto', [`${base}/to-fast-fail`])).rejects.toThrow(/cloud metadata or link-local/);
+ expect(await refusal('goto', [`${base}/to-fast-fail`])).toMatch(/cloud metadata or link-local/);
expect(bm.getActiveSession().getPage().url()).toBe('about:blank');
const result = await run('goto', [`${base}/safe`]);
expect(result).toContain('Navigated to');
@@ -69,13 +82,13 @@ describe('D2: navigation guard', () => {
test('clicking a link to a link-local address is refused', async () => {
await run('goto', [`${base}/link`]);
- await expect(run('click', ['#go'])).rejects.toThrow(/cloud metadata or link-local/);
+ expect(await refusal('click', ['#go'])).toMatch(/cloud metadata or link-local/);
expect(bm.getActiveSession().getPage().url()).toBe('about:blank');
}, 30_000);
test('a script-driven navigation to a link-local address is refused', async () => {
await run('goto', [`${base}/script`]);
- await expect(run('click', ['#go'])).rejects.toThrow(/cloud metadata or link-local/);
+ expect(await refusal('click', ['#go'])).toMatch(/cloud metadata or link-local/);
expect(bm.getActiveSession().getPage().url()).toBe('about:blank');
}, 30_000);
@@ -85,3 +98,9 @@ describe('D2: navigation guard', () => {
expect(bm.getActiveSession().getPage().url()).toBe(`${base}/safe`);
}, 30_000);
});
+
+test('live browser commands settle before the refusal is asserted', () => {
+ const source = readFileSync(import.meta.path, 'utf8');
+ expect(source).not.toMatch(new RegExp(['expect\\(', 'run\\('].join('')));
+ expect(source).not.toMatch(new RegExp(['\\.', 'rejects'].join('')));
+});
diff --git a/bun.lock b/bun.lock
index 42dd194ba..8cf207df2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,7 +17,7 @@
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "0.2.117",
"@anthropic-ai/sdk": "^0.78.0",
- "@types/bun": "1.4.0",
+ "@types/bun": "1.4.2",
"prettier": "3.9.9",
"typescript": "7.0.2",
"xterm": "^5.3.0",
@@ -176,7 +176,7 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
- "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
+ "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
@@ -234,7 +234,7 @@
"browser-split": ["browser-split@0.0.1", "", {}, "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow=="],
- "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
+ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
diff --git a/design/scripts/live-model-check.ts b/design/scripts/live-model-check.ts
index e887afc06..111a18587 100644
--- a/design/scripts/live-model-check.ts
+++ b/design/scripts/live-model-check.ts
@@ -1,31 +1,39 @@
/**
- * Opt-in live check of the design binary's OpenAI defaults (#2807). Not part
- * of any test lane: it spends real tokens (one low-quality 1024x1024 image,
- * one short vision answer).
+ * Live check of the design binary's OpenAI defaults (#2807, E5). It spends
+ * real tokens (one low-quality 1024x1024 image, one short vision answer).
*
* OPENAI_API_KEY=... bun run design/scripts/live-model-check.ts
*
* Sends exactly the request bodies the design commands send (built by
- * design/src/models.ts), honoring GSTACK_DESIGN_MODEL when set. Prints the
- * models requested and returned, HTTP status, token usage and PASS/FAIL per
- * call — never the key, and any key-shaped text in an error body is redacted.
- * Exit 0 both pass, 1 any fail, 2 OPENAI_API_KEY unset.
+ * design/src/models.ts) with the DEFAULT models: GSTACK_DESIGN_MODEL and
+ * GSTACK_DESIGN_IMAGE_MODEL are ignored (and named when set), so an override
+ * cannot mask a retirement of a default. Prints the models requested and
+ * returned, HTTP status, token usage and PASS/FAIL per call — never the key,
+ * and any key-shaped text in an error body is redacted.
+ * Exit 0 both pass, 1 any fail, 2 OPENAI_API_KEY unset: a skip that checked
+ * nothing, which a caller must record as skipped with no coverage credit.
*/
import {
+ DESIGN_IMAGE_MODEL_ENV,
DESIGN_MODEL_ENV,
- IMAGE_TOOL_MODEL,
designModel,
imageRequestBody,
+ imageToolModel,
visionRequestBody,
} from "../src/models";
import { receiptedFetch } from "../src/receipted-fetch";
const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
+const ignored = [DESIGN_MODEL_ENV, DESIGN_IMAGE_MODEL_ENV].filter((name) => process.env[name]?.trim());
+for (const name of ignored) delete process.env[name];
+console.log(`testing defaults: image=${designModel("image")} tool=${imageToolModel()} vision=${designModel("vision")}` +
+ (ignored.length ? ` (ignored: ${ignored.join(", ")})` : ""));
+
const key = process.env.OPENAI_API_KEY?.trim();
if (!key) {
- console.log("SKIP: OPENAI_API_KEY is not set");
+ console.log("SKIP: OPENAI_API_KEY is not set; the design model defaults were not checked (no coverage)");
process.exit(2);
}
@@ -56,7 +64,6 @@ async function post(payloadClass: string, url: string, body: string, timeoutMs:
const report = (fields: Record) =>
console.log(Object.entries(fields).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" "));
-console.log(`${DESIGN_MODEL_ENV}=${process.env[DESIGN_MODEL_ENV]?.trim() || "(unset, defaults)"}`);
let failed = false;
let image = TINY_PNG;
@@ -78,7 +85,7 @@ let image = TINY_PNG;
report({
call: "image", result: pass ? "PASS" : "FAIL", status: r.status, ms: r.ms,
requested_model: designModel("image"), returned_model: r.json?.model ?? null,
- tool_model: IMAGE_TOOL_MODEL, image_bytes: pass ? Buffer.from(item.result, "base64").length : 0,
+ tool_model: imageToolModel(), image_bytes: pass ? Buffer.from(item.result, "base64").length : 0,
...(pass ? {} : { error: redact(r.json?.error?.message ?? r.text) }),
});
failed ||= !pass;
diff --git a/design/src/models.ts b/design/src/models.ts
index 081709eb8..5fed94daf 100644
--- a/design/src/models.ts
+++ b/design/src/models.ts
@@ -8,6 +8,9 @@
* analysis — check, diff, memory, design-to-code, evolve's analysis step.
*
* `GSTACK_DESIGN_MODEL` (named like `GSTACK_CODEX_MODEL`) replaces both.
+ * `GSTACK_DESIGN_IMAGE_MODEL` replaces the image_generation tool's model; it
+ * must look like a gpt-image model name (IMAGE_TOOL_MODEL_PATTERN) and a bad
+ * value fails before any request, naming the variable.
*
* Defaults (checked against OpenAI's model and deprecation pages, 2026-10):
* gpt-5.5 lists Chat Completions, image input and the Responses
@@ -24,7 +27,8 @@
* The limit is a cap, not a charge — only generated tokens are billed.
*
* `bun run design/scripts/live-model-check.ts` probes these defaults with a
- * real key (opt-in, not in any test lane).
+ * real key. It always tests the defaults (overrides are ignored), so an
+ * override cannot mask a retirement.
*/
export const DESIGN_MODEL_ENV = "GSTACK_DESIGN_MODEL";
@@ -36,9 +40,29 @@ export const DEFAULT_DESIGN_MODELS: Readonly> = {
vision: "gpt-5.5",
};
-/** The image model the hosted image_generation tool runs; never omitted (the API default is gpt-image-1). */
+/** The image model the hosted image_generation tool runs by default; never omitted (the API default is gpt-image-1). */
export const IMAGE_TOOL_MODEL = "gpt-image-2";
+export const DESIGN_IMAGE_MODEL_ENV = "GSTACK_DESIGN_IMAGE_MODEL";
+
+/** Conservative shape for an image tool model override: gpt-image-[-variant]. */
+export const IMAGE_TOOL_MODEL_PATTERN = /^gpt-image-[0-9][a-z0-9.-]{0,31}$/;
+
+/**
+ * The image_generation tool's model: `GSTACK_DESIGN_IMAGE_MODEL` when set,
+ * else IMAGE_TOOL_MODEL. Throws, naming the variable, when the override does
+ * not match IMAGE_TOOL_MODEL_PATTERN.
+ */
+export function imageToolModel(env: Record = process.env): string {
+ const override = env[DESIGN_IMAGE_MODEL_ENV]?.trim();
+ if (!override) return IMAGE_TOOL_MODEL;
+ if (!IMAGE_TOOL_MODEL_PATTERN.test(override)) {
+ throw new Error(`${DESIGN_IMAGE_MODEL_ENV}=${JSON.stringify(override.slice(0, 80))} is not a gpt-image model name ` +
+ `(expected something like ${IMAGE_TOOL_MODEL}); fix it or unset it to use ${IMAGE_TOOL_MODEL}`);
+ }
+ return override;
+}
+
/** Reasoning allowance added to every vision call's visible-answer budget. */
export const REASONING_HEADROOM_TOKENS = 8192;
@@ -47,10 +71,10 @@ export function designModel(call: DesignCall, env: Record = {},
): string {
const model = designModel("image");
- const problem = imagePairingProblem(model);
+ const toolModel = imageToolModel();
+ const problem = imagePairingProblem(model, toolModel);
if (problem) throw new Error(problem);
return JSON.stringify({
model,
input,
...extra,
- tools: [{ type: "image_generation", model: IMAGE_TOOL_MODEL, size: tool.size, quality: tool.quality }],
+ tools: [{ type: "image_generation", model: toolModel, size: tool.size, quality: tool.quality }],
});
}
@@ -100,8 +125,11 @@ export function modelRejectionHint(status: number, body: string, call: DesignCal
if (!/\bmodel\b|unsupported (?:parameter|value)|not supported/i.test(body)) return "";
const model = designModel(call);
const source = process.env[DESIGN_MODEL_ENV]?.trim() ? `${DESIGN_MODEL_ENV}=${model}` : `the default model ${model}`;
- const what = call === "image"
- ? `image-generation (Responses API, tool model ${IMAGE_TOOL_MODEL})`
- : "vision (Chat Completions)";
- return ` — OpenAI rejected ${source} for this ${what} call; set ${DESIGN_MODEL_ENV} to a model your account can use for it`;
+ if (call !== "image") {
+ return ` — OpenAI rejected ${source} for this vision (Chat Completions) call; set ${DESIGN_MODEL_ENV} to a model your account can use for it`;
+ }
+ const toolOverride = process.env[DESIGN_IMAGE_MODEL_ENV]?.trim();
+ const tool = toolOverride ? `${DESIGN_IMAGE_MODEL_ENV}=${toolOverride}` : `the default tool model ${IMAGE_TOOL_MODEL}`;
+ return ` — OpenAI rejected ${source} or ${tool} for this image-generation (Responses API) call; ` +
+ `set ${DESIGN_MODEL_ENV} or ${DESIGN_IMAGE_MODEL_ENV} to models your account can use for it`;
}
diff --git a/design/test/image-gen-pairing.test.ts b/design/test/image-gen-pairing.test.ts
index 8e72ded15..dba74642a 100644
--- a/design/test/image-gen-pairing.test.ts
+++ b/design/test/image-gen-pairing.test.ts
@@ -1,13 +1,17 @@
import { afterEach, describe, expect, test } from "bun:test";
+import { spawnSync } from "child_process";
import fs from "fs";
+import os from "os";
import path from "path";
import {
DEFAULT_DESIGN_MODELS,
+ DESIGN_IMAGE_MODEL_ENV,
DESIGN_MODEL_ENV,
IMAGE_TOOL_MODEL,
imagePairingProblem,
imageRequestBody,
+ imageToolModel,
} from "../src/models";
// Tripwire for the image_generation orchestrator/tool pairing.
@@ -33,9 +37,12 @@ const sources = fs
.map((f) => ({ rel: `src/${f}`, body: fs.readFileSync(path.join(DESIGN_SRC, f), "utf-8") }));
const savedOverride = process.env[DESIGN_MODEL_ENV];
+const savedImageOverride = process.env[DESIGN_IMAGE_MODEL_ENV];
afterEach(() => {
if (savedOverride === undefined) delete process.env[DESIGN_MODEL_ENV];
else process.env[DESIGN_MODEL_ENV] = savedOverride;
+ if (savedImageOverride === undefined) delete process.env[DESIGN_IMAGE_MODEL_ENV];
+ else process.env[DESIGN_IMAGE_MODEL_ENV] = savedImageOverride;
});
describe("design image-generation tool/orchestrator pairing (#1771, #2807)", () => {
@@ -71,3 +78,72 @@ describe("design image-generation tool/orchestrator pairing (#1771, #2807)", ()
});
}
});
+
+// E5: users switch the tool model without editing code; a bad value fails
+// before any request, naming the variable; the live check always tests the
+// defaults, and a missing key is a skip, never a pass.
+describe("GSTACK_DESIGN_IMAGE_MODEL override (E5)", () => {
+ const body = () => JSON.parse(imageRequestBody("x", { size: "1024x1024", quality: "low" }));
+
+ test("a gpt-image model name replaces the tool model; blank means the default", () => {
+ delete process.env[DESIGN_MODEL_ENV];
+ for (const model of ["gpt-image-1.5", "gpt-image-2-mini", "gpt-image-3"]) {
+ process.env[DESIGN_IMAGE_MODEL_ENV] = ` ${model} `;
+ expect(body().tools[0].model).toBe(model);
+ }
+ process.env[DESIGN_IMAGE_MODEL_ENV] = " ";
+ expect(body().tools[0].model).toBe(IMAGE_TOOL_MODEL);
+ expect(imageToolModel({})).toBe(IMAGE_TOOL_MODEL);
+ });
+
+ test("anything else is refused before a request, naming the variable", () => {
+ for (const bad of ["dall-e-3", "gpt-5.5", "GPT-IMAGE-2", "gpt-image-2 --x", 'gpt-image-2"}', "gpt-image-", `gpt-image-2${"a".repeat(40)}`]) {
+ process.env[DESIGN_IMAGE_MODEL_ENV] = bad;
+ expect(() => body()).toThrow(`${DESIGN_IMAGE_MODEL_ENV}=`);
+ expect(() => imageToolModel()).toThrow(`unset it to use ${IMAGE_TOOL_MODEL}`);
+ }
+ });
+
+ test("the pairing refusal names the tool model actually requested", () => {
+ process.env[DESIGN_MODEL_ENV] = "gpt-4o";
+ process.env[DESIGN_IMAGE_MODEL_ENV] = "gpt-image-1.5";
+ expect(() => body()).toThrow("cannot drive the gpt-image-1.5 image_generation tool");
+ });
+});
+
+describe("live-model-check always tests the defaults (E5)", () => {
+ const script = path.join(import.meta.dir, "..", "scripts", "live-model-check.ts");
+ const run = (env: Record) => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "design-live-check-"));
+ try {
+ return spawnSync(process.execPath, ["run", script], {
+ encoding: "utf8", timeout: 60_000,
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GSTACK_HOME: home, ...env },
+ });
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ };
+ const overrides = { [DESIGN_MODEL_ENV]: "gpt-4.1", [DESIGN_IMAGE_MODEL_ENV]: "gpt-image-1" };
+
+ test("no key: exit 2 with a no-coverage SKIP line, after naming the ignored overrides", () => {
+ const r = run(overrides);
+ expect(r.status).toBe(2);
+ expect(r.stdout.trim().split("\n")).toEqual([
+ `testing defaults: image=${DEFAULT_DESIGN_MODELS.image} tool=${IMAGE_TOOL_MODEL} vision=${DEFAULT_DESIGN_MODELS.vision} (ignored: ${DESIGN_MODEL_ENV}, ${DESIGN_IMAGE_MODEL_ENV})`,
+ "SKIP: OPENAI_API_KEY is not set; the design model defaults were not checked (no coverage)",
+ ]);
+ });
+
+ test("with a key, the requests carry the defaults even when overrides are set", () => {
+ // An unreachable proxy fails both calls locally: no request leaves the machine.
+ const r = run({ ...overrides, OPENAI_API_KEY: "sk-test-not-a-real-key", HTTPS_PROXY: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9" });
+ expect(r.status).toBe(1);
+ const image = r.stdout.split("\n").find((l) => l.startsWith("call=image")) ?? "";
+ const vision = r.stdout.split("\n").find((l) => l.startsWith("call=vision")) ?? "";
+ expect(image).toContain(`requested_model=${DEFAULT_DESIGN_MODELS.image}`);
+ expect(image).toContain(`tool_model=${IMAGE_TOOL_MODEL}`);
+ expect(vision).toContain(`requested_model=${DEFAULT_DESIGN_MODELS.vision}`);
+ expect(r.stdout).not.toContain("sk-test-not-a-real-key");
+ });
+});
diff --git a/deslop-shared-libs/SKILL.md b/deslop-shared-libs/SKILL.md
index 9a5fbead3..64a7b68e2 100644
--- a/deslop-shared-libs/SKILL.md
+++ b/deslop-shared-libs/SKILL.md
@@ -204,7 +204,9 @@ Keep explanations short and plain spoken. Report:
do not hide test or integration costs in a single optimistic number.
3. Up to three ranked recommendations. Link both verified callers, describe the
smallest helper and destination, sketch migration and compatibility tests,
- explain why it is useful now, and name the main risk or uncertainty. Briefly
+ explain why it is useful now, and name the main risk or uncertainty: a concrete
+ behavior that could differ after migration, or the shared-failure blast radius,
+ never "none". Briefly
explain why the other candidates rank lower and how recency affected the choice.
Separately identify work covered by existing PRs. If no worthwhile opportunity
survives validation, say so and state the evidence limits.
diff --git a/deslop-shared-libs/SKILL.md.tmpl b/deslop-shared-libs/SKILL.md.tmpl
index 29cf64c5d..b87f791da 100644
--- a/deslop-shared-libs/SKILL.md.tmpl
+++ b/deslop-shared-libs/SKILL.md.tmpl
@@ -171,7 +171,9 @@ Keep explanations short and plain spoken. Report:
do not hide test or integration costs in a single optimistic number.
3. Up to three ranked recommendations. Link both verified callers, describe the
smallest helper and destination, sketch migration and compatibility tests,
- explain why it is useful now, and name the main risk or uncertainty. Briefly
+ explain why it is useful now, and name the main risk or uncertainty: a concrete
+ behavior that could differ after migration, or the shared-failure blast radius,
+ never "none". Briefly
explain why the other candidates rank lower and how recency affected the choice.
Separately identify work covered by existing PRs. If no worthwhile opportunity
survives validation, say so and state the evidence limits.
diff --git a/diagram/SKILL.md.tmpl b/diagram/SKILL.md.tmpl
index d36f93856..e6d3dc795 100644
--- a/diagram/SKILL.md.tmpl
+++ b/diagram/SKILL.md.tmpl
@@ -73,7 +73,7 @@ gstack versions never clobber each other.
```bash
BUNDLE=""
-for c in "$HOME/.claude/skills/gstack/lib/diagram-render/dist/diagram-render.html" \
+for c in "{{RUNTIME_ROOT}}/lib/diagram-render/dist/diagram-render.html" \
"$(git rev-parse --show-toplevel 2>/dev/null)/lib/diagram-render/dist/diagram-render.html"; do
[ -f "$c" ] && BUNDLE="$c" && break
done
diff --git a/docs/OVERLAY_BENCHMARK_CONTRACT.md b/docs/OVERLAY_BENCHMARK_CONTRACT.md
index 1c15e1fe5..d6f3cc38e 100644
--- a/docs/OVERLAY_BENCHMARK_CONTRACT.md
+++ b/docs/OVERLAY_BENCHMARK_CONTRACT.md
@@ -1,4 +1,4 @@
-# Overlay benchmark behavior contract v2
+# Overlay benchmark behavior contract v4
The paid overlay fixtures verify supported behavior, with comparative efficacy
reported separately as research evidence. A passing case does **not** establish
@@ -7,7 +7,7 @@ blocking efficacy contract; historical failures retain their original verdicts.
The contract is defined by `OVERLAY_CONTRACT` in
`test/helpers/overlay-case-policy.ts`. Every new measurement and aggregate record
-identifies version 3. Eval entry names include `contract-v3` so automated result
+identifies version 4. Eval entry names include `contract-v4` so automated result
matching cannot silently compare the old and new contracts by the same name.
Selection IDs for the six retained paid cases stay unchanged.
@@ -20,9 +20,17 @@ OFF does not. Each retained fixture runs ten samples per arm, separately for
Every planned sample in both arms must execute successfully, produce exactly
one successful native terminal result without an error flag, preserve workspace
scope, and yield a finite metric within the fixture's bounds. Missing reasoning
-metadata, failed output validation, oracle process errors, partial sampling,
-deadlines, cleanup failures, or recording failures keep the case failed. Missing
-observations are never replaced with zero or discarded to obtain a passing arm.
+metadata, oracle process errors, partial sampling, deadlines, cleanup failures,
+or recording failures keep the case failed. Missing observations are never
+replaced with zero or discarded to obtain a passing arm.
+
+A completed answer that fails the fixture's output check is a measured
+correctness result, not an invalid measurement, in either arm: its record has
+`measurementPassed: true`, `taskCorrect: false` and the check's message in
+`answerError`. Only ON correctness gates the case, so an OFF wrong answer stays
+in the comparison and an ON wrong answer fails the case. Version 3 counted an
+OFF wrong answer as an invalid measurement (census 37198445662); v3 verdicts
+are not reinterpreted.
| Fixture family | Exact task and scope contract | Additional ON requirement | Measurement |
| --- | --- | --- | --- |
diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md
index bf72850a1..3cd498af8 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
@@ -379,7 +383,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
@@ -387,6 +406,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 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
@@ -407,12 +427,20 @@ and the kind fixes its trials before the run:
- `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
- mean against the unchanged threshold (no dimension compensates for another),
- booleans on a strict majority. A sample that errors (refusal, truncation,
+ 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
@@ -430,6 +458,59 @@ 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 (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 `:#n`, the exact `budget_ms` it armed, monotonic
+`elapsed_ms`, the `end` reason, sanitized evidence, `billed`, and a compact
+liveness summary (longest in-request silence, the last event kind, any tool open
+at the end). `claude -p` sessions stream partial messages for this summary;
+transcripts keep their shape and size (full stream diagnostics only with
+`publicStreamDiagnostics`). A failed ledger write never fails a trial; a trial
+without ledger rows has no `sessions` and its headroom is `unknown`, never
+scored. The ledger rides in each slice artifact beside the shard results.
+
**Quarantine** (`CASE_QUARANTINE`, same file). An entry needs: a per-trial rate
below 95% over at least 10 post-policy trials of the case's current input
identity (pre-policy backfill may justify only an initial entry, labeled as
@@ -449,7 +530,9 @@ It reads the `trial-outcomes` artifact of the last N completed weekly
`evals-periodic.yml` runs, meaning scheduled runs on `main` plus `main`
dispatches, plus completed branch census runs in the same window (flags:
`--case`, `--runs N`, `--branch` to inspect one branch, `--dir`, `--backfill`,
-`--json`, `--gate`). A branch trial counts only toward a series `main` has also
+`--json`, `--gate`, and the views `--headroom`, `--reds`,
+`--run `; `--help` works offline, and an unknown flag or case id exits 2
+before anything is fetched; every view prints its `scope:` line). A branch trial counts only toward a series `main` has also
run (same case-owned bytes, `HARNESS_VERSION`, model and CLI); a branch never
starts or becomes a case's current series, and weeks for quarantine expiry
count `main` runs only. It prints
@@ -468,8 +551,26 @@ only, when a non-quarantined blocking case meets the entry rule (proposing an
entry), when a `rule` case does (rule case behaving like behavior: fix or
reclassify), when a blocking case's current identity is significantly below its
previous one (one-sided Fisher exact, α = 0.05, at least 6 trials each side,
-Holm-controlled across the cases tested), and on the quarantine rules above.
-History that cannot be fetched fails the gate closed.
+Holm-controlled across the cases tested), on the quarantine rules above, and
+with a `[headroom]` alarm when a case's slowest session ran above
+`HEADROOM_FAIL` (85%) of the budget it armed. History that cannot be fetched
+fails the gate closed.
+
+The views (`scripts/lib/eval-history.ts`): `--headroom` lists each case's
+slowest armed session against its budget (warn above `HEADROOM_WARN`, 75%;
+`insufficient` below 3 samples; timed-out samples marked censored; `unknown`
+without a session ledger, with the case wall beside it as a labelled upper
+bound) and the census critical path from the slice job timings. `--reds` lists
+verdict reds per census lane by failure class and cause, pooled and shrunk
+per-case red rates, and the all-green probability Π(1 − p_i) over the latest
+census's cases with an interval, labelled an approximation because verdicts are
+not proven independent. `--run ` is one census's triage: each red with its
+values and history, and the transcript evidence it downloads from only the
+slices its reds name (a slice artifact over 64 MB is reported, not fetched).
+The three thresholds `HEADROOM_WARN`, `HEADROOM_FAIL` and `STALL_WINDOW_MS`
+live in `test/helpers/eval-budgets.ts`, outside `EVAL_POLICY`; a slow case gets
+less work, never a larger budget. The red-census walkthrough is
+[docs/evals/census-red.md](evals/census-red.md).
**The arithmetic.** With per-trial pass rate p, the chance a single case goes
red (a false red while the product works, the catch rate once it has
@@ -504,7 +605,8 @@ and judge verdicts at p_rule:
The rule term dominates: a green lane on a working product needs rule cases to
be near-deterministic (0.999), which is why failing detectors are converted to
outcome checks and product defects are fixed or named, and why each census
-reports its expected lane false-red from the measured rates.
+reports its all-green probability from the measured rates (`eval:pass-rates
+--reds`, carried into the census report).
**Timeout policy.** Paid tests use the tiers in
`test/helpers/eval-budgets.ts` (JUDGE/CAPTURE/CAPTURE_LONG/PTY/PTY_LONG);
diff --git a/docs/designs/RELIABILITY_FOLLOWUPS_2026_10.md b/docs/designs/RELIABILITY_FOLLOWUPS_2026_10.md
new file mode 100644
index 000000000..c34dadbd8
--- /dev/null
+++ b/docs/designs/RELIABILITY_FOLLOWUPS_2026_10.md
@@ -0,0 +1,112 @@
+
+# gstack reliability follow-ups (October 4, 2026): headroom, honest eval verdicts, last host gaps
+
+## Implementation plan
+
+### Context
+
+Base: origin/main `2db0b3adc` (v1.91.19.0, #3025). The severe fix wave left follow-ups in TODOS.md. Its eight periodic censuses on the wave branch (37174266054 … 37198445662) are the evidence base for this plan:
+
+- 0 of 8 censuses were green; 21 verdict reds in total (2-3 per census).
+- By cause: 10 detectors, scripted actors or the CEO mode handoff (each repaired one at a time); 5 judge low samples; 3 provider-shaped events (one stream stall, two refusals); 2 fixture/product; 1 headroom.
+- 27 of 28 failed trials are labelled `assertion`, including observer timeouts, so the reports themselves misdescribe what happened.
+- At the measured rate (about 1.2% red per verdict across ~193 verdicts) an all-green census is about 7% likely. The lever is the per-verdict red rate, and the biggest single source is brittle detectors and scripted actors, not provider events.
+- Slowest cases: plan-ceo-mode-routing up to 687 s and plan-mode-no-op 613 s; ship-docsync-completion at 88% and store at 79% of budget; census wall time 14-15 minutes against the ~10-minute target. review-army-perf (102-140 s of 300 s) and plan-design-review-plan-mode (142-240 s, no timeouts) are not headroom problems.
+- In census 37198445662, 20 of the 21 workflow judges that score actionability sat at exactly 4.00 and none scored 5: text edits can remove low samples but cannot lift a mean that the rubric caps at 4.
+
+### Release promises
+
+- **P0. Census reds become rarer and honest.** The PR reports, per census, verdict reds by machine class and the expected all-green probability, for the 8 pre-PR censuses and every census on this PR. Every red class that recurred has a structural fix (replay corpus, structured signal or speedup), not a one-off spelling patch.
+- **P1. The slowest cases get headroom.** Cases the headroom report flags above 75% of their armed budget are brought under it by doing less work; no budget is raised. The weekly `eval:pass-rates --gate` alarm (not a free check against a committed file, which could never fail on a code change) flags any case above 85%. The census wall target is 10 minutes, so the slowest gate slice must fall to about 450 s (today 711 s).
+- **P2. Labels say what happened.** Every trial record keeps `failure_class` (`assertion | contract | timeout | infra`, which verdicts read) and its 300-character `error` field exactly as today, and gains optional, bounded fields: `failure_cause` (`contract`, `pre_turn_infra`, `api_error`, `refusal`, `provider_stall`, `session_timeout`, `observer_timeout`, `assertion`, `unknown`), `failure_cause_evidence`, and `failure_detail` (assertion `{expected, received}` or judge `{dimension, mean, samples, rationale}`, each part capped). A machine cannot tell a detector miss from a product miss, so there is no `detector` cause; that is a diagnosis written by a person. Verdict outputs (`panelVerdict`, `collector-outcomes.json`, lane exit codes, INFRA re-dispatch eligibility) are identical under EVAL_POLICY v1, proven by running main's and the branch's verdict code over the 8 baseline censuses' artifacts and by running a frozen copy of the v1 reader over new records. This PR is judged under v1.
+- **P2b. A red points to its evidence and the next step.** Each red line in the census report and tracking issue shows the case and trial, the observed failure with its Expected/Received values (or, for a judge, the dimension, mean, samples and lowest-scoring rationale), the artifact that holds the transcript, and the next action: inspect and replay free first; a paid run only "after a repair", selecting just that case. Target: diagnosis from the report line alone in 5 minutes per red (today 15-20). Every model- or fixture-written string that reaches a public issue, PR comment or committed file passes one sanitizer: control characters stripped, mentions neutralized, a code fence longer than any backtick run, HTML escaped, length capped, and the repo's credential-scan patterns redacted (adversarial test: @mention, triple backtick, ``, image link, `ghp_`/`sk-` token, 10 KB string).
+- **P3. Every low judge sample has a cause.** Each low workflow-judge sample in recent censuses is traced to a skill-text fix or recorded as a density-only complaint; judges are first checked on deliberately bad control bundles to learn whether they can discriminate at all.
+- **P4. The wave's small product gaps close** (Bun floor, make-pdf `$P`, the #2709 reap, /review's fingerprint under Codex's read-only `.git`, design model smoke, live Codex periodic case).
+
+### Workstream 0 (first, free): evidence
+
+- **0.1 Session ledger, headroom report and red ledger.** Runners (SDK, PTY, Codex) append one line per armed session to a small session ledger file in the trial's artifact dir (same pattern as `contract-violations.jsonl`): stable session key, the exact timeout value it armed, monotonic elapsed, end reason, and a compact liveness summary (longest in-request silence, last event kind, tool open at end). The record builders (including `junitCensus` for PTY and JUnit-only cases) attach it to trial records. A new module (not `eval-flake-rank.ts`) reports per case: elapsed vs armed budget per session (a case's headroom is its slowest session; timed-out samples are marked censored), the lane critical path from runner slice timings, and verdict reds by machine cause with per-case rates. Past censuses carry no session clocks, so history shows the case wall as a labelled upper bound and session headroom starts with the first census after this lands; below a minimum sample count a case is `insufficient`, never a pass. Reached as `bun run eval:pass-rates --headroom | --reds | --run ` with offline `--help`, unknown-flag and unknown-case rejection, `--case` applied to JSON and alarms, and the printed run/branch scope; `--run` downloads only the slices its red lines name. The all-green probability is Π(1-p_i) over per-case shrunk rates with an interval, labelled an approximation (independence is not proven).
+- **0.2 Failure cause classifier (policy-neutral).** One pure, dependency-free `failureCauseOf()` beside `failureClassOf()` (not in `e2e-helpers.ts`, which imports `bun:test` and selection machinery), called by both record builders from runner observations and the session ledger; precedence contract > pre_turn_infra > api_error > refusal > provider_stall > session_timeout > observer_timeout > assertion > unknown; it never changes `failure_class`, and conflicting evidence yields the higher-precedence cause with both recorded: a PTY idle turn showing an `API Error:` panel records `api_error` with the panel line (the PTY runner's `observe()` already stops on it; other runners match); refusals are detected from the structured stop reason first, phrases last; a stream stall is recorded only when the session streams partial messages and no event of any kind, thinking deltas included, arrives for 120 s (pre-registered; the longest in-message silence across 50 captured messages on Claude Code 2.1.284 was 9.6 s) while a model request is in flight and no tool call, permission prompt, hook or subagent is outstanding. Sessions without partial messages are never classified as stalls. Partial messages are enabled for this, but only the compact liveness summary is kept (full diagnostics would roughly quadruple transcript size). `panelVerdict` and lane verdicts are unchanged. Replays: 37195203538 (refusals), 37174266054 and 37176837432 (timeouts), and 37186854666 (it ran without partial messages, so it must come out `session_timeout`); the `provider_stall` positive is a synthetic fixture with an injected silence; controls: long-thinking, hung-tool, permission-pending, hook-running and subagent-running transcripts are not `provider_stall`; a tool result quoting refusal text is not `refusal`; one fixture per runner (SDK, PTY, Codex, JUnit).
+
+- **0.3 Report and tracking issue.** Expected/Received values (today `sanitizeTrialError` keeps only the first line, so 16 of 28 wave failures show nothing useful) and judge scores and rationale go into `failure_detail`, not `error`; every red names its artifact; `rerun:` becomes "after a repair:" and selects only that case; the weekly issue includes the full pass-rate alarm lines (today its grep keeps only the header), the red ledger and the all-green probability (a documented formula with a command anyone can rerun); branch-dispatched censuses no longer post to or close main's tracking issue (both steps gated on `refs/heads/main`, same in evals-marathon.yml), and the periodic workflow's concurrency group is scoped by branch so a branch census no longer cancels main's; branch and run identity are passed explicitly instead of `git rev-parse --abbrev-ref HEAD`.
+- **0.4 "A census went red" guide.** A short page linked from the report, the issue and CONTRIBUTING: prerequisites, the exact inspect and replay commands, expected output, what unknown data means, and when a paid run is allowed (only after a repair, per TESTING_INTERNALS). Durable evidence (the baseline red ledger, judge diagnoses) is committed under `docs/evals/`; scratch stays in `.context/`.
+
+### Workstream A: headroom on the cases the report flags (P1)
+
+- **A1. ship-docsync completion (88%) and store (79%).** Diagnose from the stored transcripts first. If the parent's reading of release-file contents dominates, the parent stops reading them and recomputes hashes with `bin/gstack-docs-candidate` (cheap, no model reading), comparing them with the child's claimed hashes; a mismatch stays a hard failure. Any change to the child's result contract updates the parser, child, actor and parent together, defines the hash algorithm and file set, and keeps negatives for stale results, missing files and unreported changes.
+- **A2. plan-mode-no-op (432-613 s; likely the 711 s critical-path gate slice) and plan-ceo-mode-routing (687 s)**: time-boxed to the stored transcripts, report a per-phase breakdown (preamble, Step 0, model latency, tool time) first, then cut work in the skill or fixture; if model latency dominates, it becomes a named item rather than a cut that weakens the workflow.
+- **A3.** Any other case the 0.1 report puts above 75%. review-army-perf and plan-design-review-plan-mode get no work unless the report flags them.
+- **A4. Fresh critical journey.** The marathon lane runs the existing fresh /plan-eng-review case (plan-eng-review-artifact-full) on the PR head; time to the first question and to the written test plan are reported in the PR (non-blocking lane). A fresh /ship marathon case would be new harness work and goes to TODOS.
+- Before/after numbers come from named CI censuses, never local runs.
+
+### Workstream B: robust detectors and actors (the largest red class)
+
+- **B1. Replay corpora for repeat offenders.** shared-libs-plan-callers, plan-ceo-section-loading, auto-decide-preserved, plan-design-review-plan-mode and ship-docsync-late-result each get the stored passing and failing captures still downloadable (inventory first; artifacts expire after 30-90 days, and expired ones are listed as unavailable) replayed through their detector or actor in the free suite (≥1 pass and ≥1 fail each). Entries are minimal, redacted excerpts (the detector's input slice, size-capped, credential-scanned in CI), pinned to the series identity they came from, with stale entries reported for refresh. Each corpus entry records its original census outcome, the expected detector/actor result and a one-line justification, so a fixed false red is an expected pass with its history kept, and genuine negatives stay refused.
+- **B2. Structured signals before phrases.** Where a skill already writes a structured record (decision ledgers, review logs, handoff helper output, `VERDICT:` lines), valid current structured evidence (right session, after the action, not copied fixture input) is authoritative; phrase detectors run only when no structured evidence exists. Where the contract is user-visible output (a question shown, text rendered), the behavioral witness stays required and the record only locates it. Audit every periodic and gate detector for phrase-only checks and convert the ones with a structured source.
+- **B3. Floor runner TTY preview** (TODOS P2): accept collapsed context rows and gutterless wraps in `currentEditPreview`, still compared against exact source lines; replay the screen from PR run 37176835584.
+- **B4. `--case` selects loop-registered cases** (coverage audits, TPA, plan-mode-no-op) and an unknown id exits non-zero before any process starts.
+- **B5. Pre-push hook hang behind shard neighbors**: reproduce with the failing plan under xvfb and diagnose first; fix if the cause is in scope, otherwise a named open item.
+
+### Workstream C: judges (P3)
+
+- **C1. Calibration controls first, only for judges that produced low samples** (qa, qa-only, review, plan-ceo modes, plan-eng sections, setup-deploy as applicable). Score compression at 4 is a hypothesis, not a fact (the schema allows 5). Pre-register in `docs/evals/` the positive, negative and targeted-defect control bundles, the expected outcome for each, and one 3-sample panel per judge, reusing `scripts/judge-calibration-configs.ts`. If a judge cannot separate the negative controls, list its cases in the PR as giving no coverage and bring a rubric proposal to Garry as a pre-registered EVAL_POLICY change.
+- **C2. Trace low samples.** Collect every low workflow-judge sample's rationale in the last N censuses into `.context/`; fix skill text only where a rationale names a concrete, execution-relevant gap (and tie it to an observable failure where one exists); density-only complaints are recorded, not chased. Regenerate, run parity, prompt-size and removed-clause checks, then one 3-sample panel per changed judge.
+- **C3. Behavior cases in drift.** plan-review-prosons-neutral-neg (flagged ACTION REQUIRED by `eval:pass-rates`) and shared-libs-opportunity-judgment: diagnose from stored transcripts and fix the cause, or propose CASE_QUARANTINE entries only if the diagnosis is detector/harness/model-latency and the entry rule is met.
+- **C4. auq-matrix.** Its recommendation-substance check gates a `rule` case on one judge sample and was red in 6 of the last 24 censuses. Gate question below: keep capture and judging in one execution and score that one capture with a 3-sample panel (mean, unchanged threshold), recording the evidence in `E2E_KINDS`.
+
+### Workstream D: provider events and the overlay control arm
+
+- **D1. Overlay comparison control arm (gate question below).** Today `fixture.verify()`/`measurementsValid` turns a control-arm wrong answer into an invalid measurement that fails the comparison. Proposed: a completed wrong answer in either arm stays a measured correctness result in the comparison report, and only predefined execution or measurement failures invalidate a trial, applied symmetrically; the overlay contract moves to v4. Because this changes what a gate scores after the 37198445662 red was seen, it needs Garry's approval and applies only to censuses after it.
+- **D2. plan-ceo-review-format-approach refusals.** Check whether its capture prompt ("verbatim … exact prose you would have shown") triggers the refusal classifier; reword the fixture if so.
+
+### Workstream E: small product gaps (P4)
+
+- **E1. Bun versions from one source.** Supported minimum stays 1.4.0 (`engines.bun`, the tested CI version); 1.3.3 is the security floor (below it D0's no-autoload flags are silently ignored, verified by a compiled probe, not a version comparison). Extend `test/bun-version-drift.test.ts` so setup, `engines`, the install hint (now 1.4.0 instead of 1.3.10), README and CI agree. Setup refuses below 1.3.3 and warns between 1.3.3 and 1.4.0 ("untested, upgrade with `bun upgrade`"), with a troubleshooting anchor; prerelease strings are compared by a defined rule; `--help` and `--status` keep working without Bun. The auto-updater fetches, reads the incoming release's requirement and checks the `bun` that setup will use before advancing the live checkout, leaving the live revision and installs untouched on rejection with a stable `bun-too-old` reason and resume command. The first upgrade run by an older updater cannot be protected; the CHANGELOG says so.
+- **E2. make-pdf `$P`** joins `$B`/`$D` in the shared runtime prelude for env-var hosts through one make-pdf resolver used for both readiness and every block, preserving the `MAKE_PDF_BIN` override and the real `make-pdf/dist/pdf` name (paths with spaces tested).
+- **E3. #2709 reap on Playwright 1.62:** record the Chromium PID via CDP `SystemInfo.getProcessInfo` (or the launch server) only for launches gstack owns; the reaper keeps checking recorded start time and command identity before signaling; tests cover the pinned launcher, bounded CDP failure, a recycled PID and an externally owned browser that is never reaped.
+- **E4. /review fingerprint under Codex's sandbox:** `bin/gstack-wtree` writes new objects into a private object directory (`mktemp -d`, 0700, under `$TMPDIR`, absolute common-dir objects as alternates resolved before exporting, linked worktrees supported) whose lifetime is owned by the review-log operation, because `sharedLibsSnapshotCoverage()` later reads the fingerprint's tree and blobs; it is removed (EXIT/INT/TERM) only after that consumer finishes, and a failed write exits 1 instead of printing a partial hash; a failed `git fetch` reports stale-base coverage naming the base revision used; `gstack-review-log` keeps the fingerprint helper's one-line stderr diagnostic (stdout stays hash-only), and /review Step 1.3's `git fetch &&` chain no longer stops at "Nothing to review" under a read-only `.git`; tested through `--start` → `--finish` → shared-libs reuse under a read-only `.git`, with dirty tracked files, untracked files and a linked worktree.
+- **E5. Design model smoke (periodic).** The defaults already pin gpt-image-2 (`design/src/models.ts:40`); register `design/scripts/live-model-check.ts` as a periodic case so a retirement or entitlement change shows up before users hit it. A missing key (exit 2) is a visible skip with no coverage credit. The image tool model gains an env override (`GSTACK_DESIGN_IMAGE_MODEL`, validated against a conservative pattern, named in errors) so users can switch without editing code; the periodic check always tests the default, so an override cannot mask a retirement.
+- **E6. Live Codex multi-block check as a periodic case** in the host-run Codex job, reusing the job's checkout and running setup once so it does not become the new critical path.
+
+### Gate questions for Garry (decided before Workstream 0 lands)
+
+EVAL_POLICY v1 lets an all-INFRA census be re-dispatched once, and counts only errors before the first model turn as INFRA. Should `provider_stall` (with the streaming evidence in 0.2) join INFRA under a v2 for future censuses? This PR is judged under v1 either way. Refusals stay a failure class of their own in both options (a refusal is model output users also see). Recommendation from the CEO review: labels only now (v1 unchanged); revisit v2 with the pooled provider-event base rate from 0.1 attached.
+
+Also for approval: D1 (overlay control-arm scoring, applies to later censuses only) and C4 (auq-matrix scores its single capture with a 3-sample judge panel).
+
+### Delivery
+
+One PR, a commit per item. Lanes (each commits sources only; one integrator owns `touchfiles-data.ts`, the ratchet file, generated SKILL.md, VERSION, CHANGELOG): F (session ledger, classifier, report, issue/workflow), P (A1, A2 headroom fixes), S (B1-B5 detectors and harness), D (C1-C4 judges, D1-D2), W (E1-E6 product); F, P, S and D start together, W's E1 waits for F's workflow edits. Freeze harness edits before the first paid PR-lane push, because every push re-runs the full gate. Patch version from the queue.
+
+### Validation
+
+- Each fix has a free regression test that fails on main; detector and classifier changes replay captured transcripts with negative controls.
+- Paid runs are pre-registered at the gate as an inventory: one diagnostic run per concrete repair of a `rule` case (never on unchanged inputs), a 3-trial panel for a changed `behavior` case, one 3-sample panel per changed judge and per C1 calibration set, the new E5/E6 cases once each to prove wiring, then the PR lane once on the frozen head (full gate, ~106 cases and 24 judges, because `engines.bun` and new files force full fallback), one periodic census on the frozen head, and the marathon run. Diagnostic runs never form a verdict.
+- Free suite once on Ubicloud at the end; PR gate lane; one periodic census on the frozen head.
+- Each workstream names the docs it updates (TESTING_INTERNALS, troubleshooting, the new red-census guide, CONTRIBUTING, AGENTS.md, README, TODOS.md). The 75%/85% thresholds and the stall window are named constants in one module; per-case exemptions go through a list with reason and expiry, never a raised budget.
+- **Done when** PR CI is green and the census on the frozen head has every red either machine-classified INFRA under the approved policy or listed as a named red with diagnosis in the PR and TODOS.md. A repair made after that census needs a fresh census on the new head (TESTING_INTERNALS:407-411). "Diagnosis complete" is not "release accepted": under v1, an INFRA-only census may be re-dispatched once; any other red left unresolved needs Garry's explicit exception before merge. The PR body carries the red-class table, the all-green probability before/after, and a "diagnosable from the report line alone" column.
+
+### Not in scope
+
+Comparing gstack end to end against the stock host agent on product outcomes (the overlay harness covers model-level deltas; a full product A/B is its own project, filed to TODOS). Severe-wave items not taken here: C8 router panel, #2670 bash half, G2 TS twin, Codex telemetry/notice follow-ups, gate-outcome telemetry, design health subcommand, cmd.exe autoplan hook, /gstack-upgrade redesign, marketplace, placeholder convention, #2750, ios-qa tokens, #931, #1713, Windows SAC Node lane, navigation pre-send blocking, gbrain batch lookup, memory follow-ups, #2971 non-privacy pieces, redaction allowlist #2598.
+
+## Review record
+
+Autoplan run started 2026-10-04 (GSTA-21). Source: TODOS.md on main 2db0b3adc.
+
+### CEO phase, 2026-10-04
+
+Voices: Codex gpt-6-astra (completed, 8 findings, recommends staged releases), Claude native blind (hash 3163a6b1 matched, 2 critical: stall rule ambiguity, PR judged under its own new policy), Claude primary (8-census data, 15 amendments). Consensus: confirmed on all six dimensions that the draft optimized stale premises (A2/A3 not slow; real slow cases missing), that B as drafted would mislabel thinking or tool hangs and let the PR judge itself under v2, that A5's data source cannot support per-case headroom, and that judge margin cannot come from text alone. Disagreement (taste): Codex wanted separate releases; Claude voices kept one PR (owner rule). User challenge candidates were reframed as the gate's policy question (labels-only vs stall-as-INFRA) because all voices agree the change needs Garry's approval regardless. Rewritten plan above adopts AM-1..AM-15 plus native F1/F3/F4/F7/F10/F12/F13 and Codex 2/3/4/5/6/8.
+
+### DX phase, 2026-10-04
+
+Voices: Codex gpt-6-astra (completed, 10 findings), Claude native blind (hash a661b072 matched; 2 critical: failure-class schema clash, non-deterministic free 85% check), Claude primary (DX 4.3 → 7.1; time from red census to diagnosis 15-20 min per red → target 5). Consensus confirmed on all six dimensions: the report line must carry evidence and the next action (not `rerun:`), the new classes must not touch the policy field or verdicts, the headroom check needs a defined clock, statistic and committed snapshot, the CLI needs help/validation, a two-minute triage guide, and E1 needs an upgrade recovery path. Taste: T-DX-1 Bun floor 1.3.3 (security) with 1.4.0 as tested version, chosen over a hard 1.4.0 refuse (Claude primary; avoids stranding git installs at SETUP_FAILED; Codex wanted one minimum, which 1.3.3 from one source satisfies); T-DX-2 separate `failure_cause` field (all three voices); T-DX-3 85% alarm also in `eval:pass-rates --gate` (accepted).
+
+### Eng phase (last), 2026-10-04
+
+Voices: Codex gpt-6-astra (completed, 16 findings, recommends splitting), Claude native blind (hash 43124ba1 matched; high: error-field compat, unsanitized public text, headroom data gap, D1 is a post-hoc gate change), Claude primary (feasibility verified: thinking deltas present on Claude Code 2.1.284 with 9.6 s max in-message silence; past censuses carry no session clocks; full-fallback paid selection; census critical path 711 s; 16 amendments). Consensus confirmed on all six dimensions. Accepted: session ledger + pure classifier beside failureClassOf; bounded new fields with `error` untouched and a frozen-v1-reader test; one sanitizer for published text; no `detector` cause; synthetic stall positive; E4 object lifetime owned by review-log; E1 checks before advancing the checkout with 1.4.0 kept as supported minimum; D1 and C4 to the gate; main-only issue steps and branch-scoped concurrency; structured-first detectors keep behavioral witnesses for user-visible contracts; pre-registered paid inventory. Trimmed for proportion: the committed-snapshot 85% free check (could never fail on code), the fresh /ship marathon case, and the separate auq-matrix judge case; C1 runs only for judges with low samples. Disagreement (taste): Codex recommended splitting into several PRs; kept one PR per Garry's standing rule, with dependency-ordered lanes. Test plan: ~/.gstack/projects/garrytan-gstack/garrytan-reliability-followups-oct4-test-plan-20261004.md.
+
+### Final approval gate, 2026-10-04
+
+Garry approved the plan as one PR. Decisions: (1) provider stalls are labels only (`provider_stall` cause, still a failure; EVAL_POLICY v1 unchanged); (2) D1 approved: a completed wrong answer in the overlay-OFF arm counts as a comparison result, not a failure, for censuses after this change (overlay contract v4); (3) C4 approved: auq-matrix scores its one capture with a 3-sample judge panel, mean against the unchanged threshold, for censuses after this change. Status: APPROVED.
+
diff --git a/docs/evals/census-red.md b/docs/evals/census-red.md
new file mode 100644
index 000000000..f6950ff3a
--- /dev/null
+++ b/docs/evals/census-red.md
@@ -0,0 +1,134 @@
+# A census went red
+
+A periodic census (`evals-periodic.yml`: the weekly periodic lane plus the
+gate census) or a marathon run reports each red verdict as one line. This page
+takes one red line to a diagnosis, using free commands first. A paid run is
+allowed only after a repair.
+
+Where the report is:
+
+- **Main** (the scheduled weekly run, or a dispatch on `main`): a comment on the
+ open "Weekly periodic evals: red lane needs triage" issue (marathon: "Weekly
+ marathon evals: red lane needs triage").
+- **Any branch**: the run's step summary and its `census-report-a`
+ artifact (marathon: `marathon-report-a`). A branch census never
+ comments on or closes main's issue.
+
+## Prerequisites
+
+- A checkout of the census branch with `bun install` done. Bun 1.4.2 is the
+ tested version.
+- `gh` authenticated with read access to the repository's Actions (or
+ `GH_TOKEN` set). `eval:pass-rates` downloads artifacts through the REST zip
+ route, which also works where `gh run download` is blocked.
+- The run id: the number at the end of the run URL in the report's first line.
+
+## 1. Read the red line
+
+A red line in the census report looks like this (one line; a behavior panel
+with one failed trial shown):
+
+```
+✗ plan-review-prosons-neutral-neg behavior FAIL 1/3 (✗✗✓) t1: assertion (session completed; check failed) — expect(received).not.toMatch(expected) · cause assertion: session completed; check failed Expected: not /taste call/i · Received: "a taste call"; t2: assertion [t1@slice 7, t2@slice 9, t3@slice 9, attempt 1] evidence: paid-slice-7-a1/shards/skill-e2e-plan-prosons--plan-review-prosons-neutral-neg.t1 (fetch: bun run eval:pass-rates --run 37193478719 --case plan-review-prosons-neutral-neg) after a repair: bun run scripts/test-paid-shards.ts --tier periodic --case plan-review-prosons-neutral-neg --trials 3
+```
+
+| Field | What it says |
+|---|---|
+| case, kind, verdict | the registry id, its kind (`rule`, `behavior`, `judge`) and the verdict with each trial's outcome; the brackets name the slice and attempt of each trial |
+| `failure_class` | the class verdicts read: `assertion`, `contract`, `timeout` or `infra` |
+| `cause` | what the machine observed (`failure_cause`, glossary in [TESTING_INTERNALS](../TESTING_INTERNALS.md#failure-causes)), with its evidence line |
+| Expected / Received | the failed matcher's values; for a judge, each failing dimension's gating median (the mean before EVAL_POLICY v3), threshold, sample count and lowest rationale |
+| `evidence:` | the slice artifact and shard directory holding the transcript |
+| `after a repair:` | the command that runs only this case, once a repair exists |
+
+The cause is a label. Under EVAL_POLICY v1 it never changes a verdict: a
+`provider_stall` or `refusal` is still a failed trial. Whether an assertion
+failure was a detector, harness or product fault is your diagnosis, not a
+machine cause.
+
+## 2. Inspect the evidence (free)
+
+```bash
+bun run eval:pass-rates --run # every red of that census
+bun run eval:pass-rates --run --case # one case
+```
+
+Expected output: one block per red, with its history and a local evidence path.
+
+```
+run 37198445662 (main @ 1a2b3c4d5, 2026-10-04T11:29:00Z): 2 verdict red(s)
+✗ qa-only/SKILL.md workflow periodic/full FAIL (history: 7/8 verdicts green)
+ t1: assertion / cause assertion: session completed; check failed clarity 3.67 < 4 (3 samples) "…"
+ evidence: ~/.gstack/eval-pass-rates-cache/garrytan-gstack/37198445662/paid-slice-5-a1/shards/skill-llm-eval
+```
+
+Only the slices the reds name are downloaded, into
+`/eval-pass-rates-cache/-//`. The shard
+directory holds the eval record JSON (transcript, judge scores), `junit.xml`
+and, for runs after the session ledger landed, `session-ledger.jsonl` (each
+session's armed budget, elapsed time, end reason and liveness summary). A slice
+artifact over 64 MB is reported as `too large to fetch`; download it from the
+run page or with `gh api repos///actions/artifacts//zip > slice.zip`.
+
+## 3. Put it in context (free)
+
+```bash
+bun run eval:pass-rates --reds --runs 10 --branch # reds per census by class and cause, all-green probability
+bun run eval:pass-rates --headroom --runs 10 --branch # slowest session per case vs its armed budget
+bun run eval:pass-rates --case --runs 10 # the case's pass rate per input series
+```
+
+`--reds` ends with the all-green probability: Π(1 − p_i) over the latest
+census's cases, with per-case red rates shrunk toward the pooled rate. It is an
+approximation (verdicts are not proven independent). A case that is red in
+several censuses is a repeat offender; its fix is structural (a replay corpus,
+a structured signal or a speedup), not a one-off spelling patch.
+
+What incomplete data means:
+
+| Shown | Meaning |
+|---|---|
+| headroom `unknown (no session ledger)` | the trials predate the session ledger, or the runner wrote none; the case wall beside it is an upper bound, not a session clock |
+| headroom `insufficient` | fewer than 3 session samples; never read as a pass |
+| headroom `censored` | a sample timed out, so its real duration is at least the budget |
+| cause `unrecorded` | the trial was written before `failure_cause` existed |
+| cause `unknown` | the trial failed with no evidence for any other cause |
+| `cost unknown` | the harness (PTY, Codex) records no billing; only the known sum is shown |
+| `history unavailable (...)` | the GitHub history could not be read; `--gate` fails closed |
+
+## 4. Replay and repair (free)
+
+Diagnose before changing code: product defect, test or detector defect, harness
+defect, or infrastructure. Then reproduce with the smallest free check:
+
+```bash
+bun test test/detector-corpus-.test.ts # cases with a replay corpus
+bun run eval:pass-rates --reds --dir # re-read downloaded artifacts offline
+```
+
+The repair lands with a free regression test that fails before it and passes
+after it (`AGENTS.md`, "Validation discipline").
+
+## 5. After a repair: one paid run
+
+Run the line's `after a repair:` command, which selects only that case:
+
+```bash
+bun run scripts/test-paid-shards.ts --tier periodic --case # a rule case: one diagnostic trial
+bun run scripts/test-paid-shards.ts --tier periodic --case --trials 3 # a behavior case: its 3-trial panel
+```
+
+A judge prints an `EVALS_JUDGE_SELECTION_JSON=... bun test -t ''`
+command instead; a changed judge gets one 3-sample panel.
+
+Rules (see [TESTING_INTERNALS](../TESTING_INTERNALS.md#eval-verdict-policy)):
+
+- A paid run follows a concrete repair. A red census is never rerun on
+ unchanged inputs.
+- A diagnostic run never forms a verdict. The next census on the repaired head
+ decides.
+- A census whose every red is machine-classified INFRA or INCOMPLETE is
+ re-dispatched once automatically; both runs stay reported.
+- An unresolved red is listed as a named red with its diagnosis. A case enters
+ `CASE_QUARANTINE` only under its entry rule, and a product defect is never
+ quarantined.
diff --git a/docs/evals/detector-inventory.md b/docs/evals/detector-inventory.md
new file mode 100644
index 000000000..4b962f410
--- /dev/null
+++ b/docs/evals/detector-inventory.md
@@ -0,0 +1,320 @@
+# E2E detector inventory: phrase checks and structured sources
+
+This inventory lists every pass/fail decision in the gate and periodic E2E cases (`E2E_TIERS` in test/helpers/touchfiles-data.ts, plus the three standalone periodic AUQ files) that rests only on phrases or regexes in model free text, at HEAD `cbcccf9`. It has 153 phrase-check rows: 13 are already structured-first (a), 38 are convert candidates (b), 79 need a phrase witness because the contract is user-visible (c), and 23 have no structured source (d); 13 more rows are LLM judges. Shared detectors (S1-S10, F1-F3, A1) are counted once, even though up to nine cases use each. Of the five B1 cases, only auto-decide-preserved has a structured record that can decide its outcome; the other four already read their records first or test text the user sees, so B1 replay corpora protect their phrase checks.
+
+Dispositions:
+
+- **(a) already structured-first.** The check reads a structured record first and falls back to phrases only when the record is absent.
+- **(b) convert candidate.** A structured record of the same fact exists in this run, and the contract is not user-visible text.
+- **(c) phrase witness required.** The contract is what the user sees (a question shown, a report or message rendered). A record may locate the text but cannot replace it. Numeric format regexes on a shown question, such as `Completeness: N/10`, are (c).
+- **(d) no structured source.** An internal fact that nothing in the run records.
+- **judge.** An LLM judge. It is listed but is not a phrase check.
+
+"Journal" means the hermetic Claude session JSONL that test/helpers/plan-count-transcript.ts reads. "Surface" means `fullSurface`/`fullOutputSurface`, which includes Skill-injected SKILL.md bodies.
+
+## Shared detectors
+
+| ID | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| S1 | test/helpers/pty/screen.ts:399 via test/helpers/pty/classify.ts:490 | screen `❯ 1.` option list → `asked` | AskUserQuestion tool_use in the journal locates it | yes (a question shown) | (c) |
+| S2 | test/helpers/pty/classify.ts:45 via :505 | lettered/numbered prose options, `Recommendation:`, `(recommended)` → `asked` | none when AUQ is disallowed | yes | (c) |
+| S3 | test/helpers/pty/screen.ts:186 via test/helpers/pty/classify.ts:467 | "ready to execute" / exit-plan panel → `plan_ready` | ExitPlanMode tool_use in the journal | no | (b) |
+| S4 | test/helpers/pty/classify.ts:425 | screen `⏺ Write(path)` outside sanctioned dirs → `silent_write`; plan write before first AUQ → `wrote_findings_before_asking` | Write/Edit tool_use (file_path, order) in the journal | no | (b) |
+| S5 | test/helpers/pty/screen.ts:205 via test/helpers/pty/classify.ts:460 | `(auto-decided from plan-tune preference)` → `auto_decided`; runs before the structured path at test/helpers/pty/runners/observation.ts:389 | `gstack-ceo-mode-handoff --auto` (plan-ceo-review/SKILL.md.tmpl:410), question-log `auto_decided:true` (:416; scripts/resolvers/question-tuning.ts:38), read by test/helpers/native-auto-decide.ts:412 | yes (the chat must begin with the handoff line, plan-ceo-review/SKILL.md.tmpl:410); the record can locate it, the transcript line stays the witness | (b) |
+| S6 | test/helpers/pty/runners/observation.ts:410-416 | Haiku `waiting` verdict → `asked` | — | — | judge |
+| S7 | test/helpers/pty/plan-native.ts:36 via :605 | plan file `^## GSTACK REVIEW REPORT$` with no later `## ` heading | none (rendered plan file) | yes | (c) |
+| S8 | test/helpers/plan-mode-evidence.ts:72 → test/helpers/pty/screen.ts:279 | plan file has `## Decisions` on `plan_ready` → fail | none | yes | (c) |
+| S9 | test/helpers/pty/classify.ts:148 | squished screen "what should I (design-)review" + "current branch diff" | AUQ tool_use locates it when native AUQ is allowed | yes | (c) |
+| S10 | test/helpers/pty/classify.ts:166, test/helpers/plan-scope-selection.ts:54-110, test/helpers/pty/runners/observation.ts:483 | scope-gate auto-select announcement, first-line target wording, seed token on screen | none (no scope-selection record; the seed token is in the pasted seed) | no | (d) |
+| F1 | test/helpers/pty/runners/floor.ts:474 → test/helpers/plan-floor-review.ts:217 | staged question judged a `finding` (only pass) | — | — | judge |
+| F2 | test/helpers/pty/runners/floor.ts:451 | prose candidate only if viewport and last assistant message both show prose options; native call read first | native AUQ call (structured first) | yes | (a) |
+| F3 | test/helpers/pty/runners/floor.ts:497 | screen Write render / plan-ready text → fail | Write/ExitPlanMode tool_use in run.transcript | no | (b) |
+| A1 | test/helpers/auq-sdk-capture.ts:30-43 `scoreAuqFormat` | question has ELI10:, Recommendation:, Pros / cons, ✅, ❌, Net:, (recommended) | AUQ tool input (its prose is the contract) | yes | (c) |
+
+Cases that use S1-S8: plan-ceo-review-plan-mode, plan-devex-review-plan-mode, plan-mode-no-op, office-hours-auto-mode (gate); auto-decide-preserved, plan-eng-review-plan-mode, plan-design-review-plan-mode-smoke (periodic). S9-S10: plan-mode-no-op, plan-eng-review-plan-mode, plan-design-review-plan-mode-smoke. F1-F3: plan-ceo-finding-floor, plan-devex-finding-floor (gate); plan-design-finding-floor, plan-eng-finding-floor (periodic). A1: auq-format-gate (gate); the three standalone AUQ files (periodic).
+
+## Gate tier
+
+### test/skill-e2e-plan-mode-no-op.test.ts, test/skill-e2e-plan-ceo-plan-mode.test.ts, test/skill-e2e-plan-devex-plan-mode.test.ts, test/skill-e2e-office-hours-auto-mode.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| plan-mode-no-op | test/skill-e2e-plan-mode-no-op.test.ts:114, :175 | screen lacks the plan-mode reminder sentence | none | yes (leaked text) | (c) |
+| plan-mode-no-op | test/skill-e2e-plan-mode-no-op.test.ts:186 | screen contains seed token `ZephyrLedgerWidget` | none; the token is in the pasted seed | no | (d) |
+| office-hours-auto-mode | test/skill-e2e-office-hours-auto-mode.test.ts:41 | S5 `auto_decided` fails the case | question-log row | no | (b) |
+
+The plan-ceo-review-plan-mode (:89, :108), plan-devex-review-plan-mode (:181-222) and office-hours-auto-mode (:54) verdicts are otherwise S1-S8.
+
+### test/skill-e2e-plan-devex-finding-floor.test.ts, test/skill-e2e-plan-design-with-ui.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| plan-devex-finding-floor | test/helpers/plan-floor-review.ts:278-280 `pickPlanFloorProductType` | product-type brief `^Is this…?$` and one exact "Project/branch/task" context; no match leaves the menu unanswered (stall) | AUQ header `Product type` + option labels (already parsed :268-281) | no (selector) | (b) |
+| plan-design-with-ui-scope | test/skill-e2e-plan-design-with-ui.test.ts:34-36 | design-focus question prose "I've rated this plan N/10…" or "all 7 … focus … design?" | AUQ header/options of the answered native call | no (boundary id) | (b) |
+| plan-design-with-ui-scope | test/skill-e2e-plan-design-with-ui.test.ts:44-57 | review-finding identity from option labels or UI vocabulary | option labels (structured), no design-finding record | no | (b) |
+| plan-design-with-ui-scope | test/helpers/plan-review-board-feedback.ts:111-120, :127-138 | outside-voices question lead regex; one `/boards/` URL in prose; mismatch throws | header `Voices` + exact option labels (compared :115-116) | no (actor) | (b) |
+| plan-design-with-ui-scope | test/skill-e2e-plan-design-with-ui.test.ts:80-81 | screen prompt `/boards/` without a native call throws | native-call absence decides | no | (a) |
+| plan-design-with-ui-scope | test/helpers/pty/classify.ts:555 `COMPLETION_SUMMARY_RE` via test/helpers/pty/runners/counting.ts:554 | screen report/summary/`VERDICT:` text ends collection before the ceiling | review-log row / ExitPlanMode tool_use | no | (b) |
+
+### test/skill-e2e-ask-user-question-format-compliance.test.ts, test/skill-e2e-plan-tune.test.ts, test/skill-e2e-plan.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| auq-format-gate | test/skill-e2e-ask-user-question-format-compliance.test.ts:58 | question ~ `options differ in kind` | AUQ tool input | yes | (c) |
+| auq-format-gate | test/skill-e2e-ask-user-question-format-compliance.test.ts:61, :68 | recommendation substance ≥ 4 | — | — | judge |
+| plan-tune-inspect | test/skill-e2e-plan-tune.test.ts:167-169, :185 | output names ≥2 of 3 seeded question IDs or aliases | question-log is seeded fixture input | yes (inspection report) | (c) |
+| plan-review-report | test/skill-e2e-plan.test.ts:463-491 | plan.md `## GSTACK REVIEW REPORT`, CEO/Eng/Design rows, ends with `NO UNRESOLVED DECISIONS` or a bullet | rows come from the seeded review log; the table is rendered | yes | (c) |
+
+### test/skill-e2e-design.test.ts (gate cases)
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| plan-design-review-no-ui-scope | test/skill-e2e-design.test.ts:561-565, :572 | output contains no ui/no frontend/no design/not applicable/backend | none (early exit writes no record) | no | (d) |
+| design-review-plugin-handoff | test/skill-e2e-design.test.ts:1012, :1020-1023 | detector-output.md has `IMPECCABLE_READY`, FINDING-001, rule ids, `deferred`, per-handoff `/impeccable x` | probe/scan tool output (checked :994, :1010-1011) | yes (report) | (c) |
+| design-review-detector-shim | test/skill-e2e-design.test.ts:1158 | tool outputs or report include `IMPECCABLE_READY` | probe tool output read first | yes | (a) |
+| design-review-detector-shim | test/skill-e2e-design.test.ts:1159-1161 | report contains FINDING-001, [ai-color-palette], [low-contrast] | calls.jsonl receipts + engine log (:1132-1154) | yes | (c) |
+| design-review-detector-shim-dom | test/skill-e2e-design.test.ts:1254-1255 | report contains [ai-color-palette], "static scan of the rendered DOM" | DOM dump + Bash trace (:1247-1252) | yes | (c) |
+
+### test/skill-e2e-coverage-audit.test.ts, test/skill-e2e-workflow.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| review-coverage-audit, plan-eng-coverage-audit | test/helpers/coverage-audit-evidence.ts:546-547 | last JSON `{"tested","untested"}` line first; ASCII-diagram grammar fallback (:459-510) | prompt-requested JSON summary | no | (a) |
+| ship-coverage-audit | test/helpers/coverage-audit.ts:34-38 | diagram has gap/no test, tested/✓/★, coverage, both function names | none (the diagram is the requested output) | yes | (c) |
+| document-release | test/skill-e2e-workflow.test.ts:101-102, :109 | CHANGELOG keeps old entries; current entry and README mention "feature c" | none (docs are the product) | yes | (c) |
+
+### test/skill-e2e-shared-libs.test.ts, test/skill-e2e-shared-libs-paths.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| shared-libs-read-only | test/skill-e2e-shared-libs.test.ts:113-120 | report ~ uncommitted/overlay/raw, tip and branch SHAs, retry-worker/route, no blob link for branch-only file | none (read-only by contract) | yes | (c) |
+| shared-libs-unsupported-git | test/skill-e2e-shared-libs.test.ts:136, :144 | report ~ unavailable/unsupported/limited; not "no commits/PRs found" | none | yes | (c) |
+| shared-libs-review-path-eligibility | test/skill-e2e-shared-libs-paths.test.ts:133-134 | `trace + output` ~ submodule/160000, check-ignore/ignored (output alone satisfies) | Bash command trace (first operand) | no | (b) |
+
+### test/skill-e2e-ship-docsync.test.ts, test/skill-e2e-docsync-spawned.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| ship-docsync-store | test/skill-e2e-ship-docsync.test.ts:74, :78 | ship report ~ `Documentation…blocked`; not `Documentation: current` | no receipt, no publish call (:75-77) | yes (report status) | (c) |
+| ship-docsync-completion, ship-docsync-current | test/skill-e2e-ship-docsync.test.ts:81, :97 | parsed docs contract, then report contains `contract.documentation_section` | docs dispatch JSON (ship/sections/documentation.md.tmpl:52-56) | yes | (a) |
+| ship-docsync-late-result, -missing-marker, -missing-asset, -launch-failure, -timeout-unsettled, -failure | test/helpers/docsync-fault-eval.ts:28, :30 | non-success report ~ `Documentation…blocked`, no `Documentation: current` outside inline code | actor event log decides dispatch count and publication first (:22-27) | yes | (c) |
+| ship-docsync-recovery, -stale-before, -stale-after | test/helpers/docsync-fault-eval.ts:31 | report contains the accepted audit id | actor state `acceptedId` | yes | (a) |
+| docsync-spawned | test/skill-e2e-docsync-spawned.test.ts:60 | `contract.blockers` ~ security/sensitive | parsed docs contract | no | (a) |
+
+### test/helpers/ship-hook-actor.ts (ship-hook-consent, ship-hook-refresh files)
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| ship-managed-hook-refresh | test/helpers/ship-hook-actor.ts:122 | final ~ refresh/updat/install/current | installer receipt (checked :113-121) | yes (outcome told to user) | (c) |
+| ship-unmanaged-hook-consent | test/helpers/ship-hook-actor.ts:129 | final ~ declin/unchanged/not install/preserv/leave | declined interaction record (:127) | yes | (c) |
+| ship-local-hook-preservation | test/helpers/ship-hook-actor.ts:133 | final ~ /manual/ | none | yes | (c) |
+
+### test/skill-e2e-review.test.ts, test/skill-e2e-review-attribution.test.ts, test/skill-e2e-review-army.test.ts (gate cases)
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| review-sql-injection | test/skill-e2e-review.test.ts:87-95 | review-output.md includes sql/injection/sanitiz/parameteriz/… | none: the fixture copies only the checklist, so no review-log row (review/SKILL.md.tmpl:402) | partly | (d) |
+| review-enum-completeness | test/skill-e2e-review.test.ts:173-177 | review mentions "returned" and enum/status/critical | none (focused checklist run) | partly | (d) |
+| ship-base-branch | test/skill-e2e-review-attribution.test.ts:144 | ship-preflight.md ~ /main\|base/ | git commands in the Bash trace (review-base-branch uses this, :87) | no | (b) |
+| review-dashboard-via | test/skill-e2e-review-attribution.test.ts:273, :275 | output + dashboard ~ autoplan, clear | review log is seeded input; dashboard is rendered | yes | (c) |
+| review-army-migration-safety | test/skill-e2e-review-army.test.ts:136-142 | output includes drop/data loss/reversib/migration/column | none (single-shot) | partly | (d) |
+| review-army-perf-n-plus-one | test/skill-e2e-review-army.test.ts:272-280 | output includes n+1/eager/includes/preload/query/loop | specialist JSON findings in Agent results (review/sections/review-army.md); not verified per finding | no | (b) |
+| review-army-delivery-audit | test/skill-e2e-review-army.test.ts:395-404 | output includes not done/missing and email/notification | none verified | partly | (d) |
+| review-army-quality-score | test/skill-e2e-review-army.test.ts:494-496 | `SPECIALIST REVIEW: 1 findings (1 critical, 0 informational)`, `PR Quality Score: 8/10`, `[ADVISORY]` | merged-review.json (checked :485-493) | yes (rendered format) | (c) |
+
+### test/skill-e2e-bws.test.ts, test/skill-e2e-session-intelligence.test.ts, test/skill-e2e-learnings.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| skillmd-no-local-binary | test/skill-e2e-bws.test.ts:156 | result.output ~ READY/NEEDS_SETUP | setup-block Bash tool_result prints `READY: $B`/`NEEDS_SETUP` (browse/SKILL.md:214) | no | (b) |
+| skillmd-outside-git | test/skill-e2e-bws.test.ts:194 | same | same | no | (b) |
+| context-recovery-artifacts | test/skill-e2e-session-intelligence.test.ts:181-183, :195 | output contains any of recent artifacts/ceo-plans/last_session/ship/timeline/completed (near-vacuous) | bin/gstack-context-recovery:31, :37 prints the record, but the fixture does not copy it (:46-48) | no | (d) (b once the bin is copied) |
+| context-save-writes-file | test/skill-e2e-session-intelligence.test.ts:257, :266 | checkpoint includes `---` and `status:` | the file's YAML frontmatter (parseable) | no | (b) |
+| context-restore-loads-latest | test/skill-e2e-session-intelligence.test.ts:379-393, :401-402 | `RESTORED: ` or newer-body phrase; Read of the newer file as fallback | Read tool_use | partly | (c) |
+| learnings-show | test/skill-e2e-learnings.test.ts:117-119, :131 | output mentions ≥2 of n-plus-one, stale/cache, rubocop | `gstack-learnings-search` Bash result (fixture copies it, test/skill-e2e-learnings.test.ts:43) | yes (/learn shows them) | (c) |
+
+### test/skill-e2e-qa-functional*.test.ts, test/skill-e2e-skillify.test.ts, test/skill-e2e-deploy.test.ts, test/skill-e2e-cso.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| qa-functional-cli-report, -webhook-report, -cli-fix, -webhook-fix | test/helpers/qa-checkpoint-evidence.ts:207-209 | report.md links each `exploration-NNN.json` checkpoint | checkpoint files + gstack-qa-evidence receipts; the link is rendered | yes | (c) |
+| same 4 | test/helpers/qa-functional-evidence.ts:179-180 | private sentinel absent from output, report and evidence | n/a (leak canary) | yes | (c) |
+| skillify-happy-path | test/skill-e2e-skillify.test.ts:379 → test/helpers/skill-body-narration.ts:7 | committed SKILL.md has no `I `/`I'll`/`Let me` narration | none (generated skill text is the artifact) | yes | (c) |
+| skillify-provenance-refusal | test/skill-e2e-skillify.test.ts:429, :467 | surface ~ unknown skill/not registered (load tripwire) | Skill tool_use + its error tool_result | no | (b) |
+| skillify-provenance-refusal | test/skill-e2e-skillify.test.ts:444, :468 | assistant ~ "no recent scrape result" / "run /scrape first" | none | yes (refusal, skillify/SKILL.md.tmpl:67) | (c) |
+| land-and-deploy-workflow | test/skill-e2e-deploy.test.ts:81 | deploy reports + CLAUDE.md ~ /fly/ | Bash result `PLATFORM:fly` (land-and-deploy/sections/first-run-validation.md:35); not verified this flow runs it | no | (b) |
+| land-and-deploy-workflow | test/skill-e2e-deploy.test.ts:82 | artifacts contain `test-app` | none | no | (d) |
+| land-and-deploy-first-run | test/skill-e2e-deploy.test.ts:150-151 | report contains fly or first-run-app | Bash result `PLATFORM:fly` (this case runs Step 1.5) | no | (b) |
+| land-and-deploy-review-gate | test/skill-e2e-deploy.test.ts:221-222 | readiness report ~ review and not run/no reviews/missing | none (gstack-review-read not installed) | yes | (c) |
+| setup-deploy-workflow | test/skill-e2e-deploy.test.ts:408-410 | CLAUDE.md contains fly, my-cool-app, Deploy Configuration | the `## Deploy Configuration` key/value block (setup-deploy/SKILL.md.tmpl:183-190) | no (machine-read) | (b) |
+| cso-diff-mode | test/skill-e2e-cso.test.ts:181 | webhook.js finding prose ~ signature/authenticat/forg | report.json has no class/CWE field (lib/cso/contracts.ts:65-80) | no | (d) |
+| cso-diff-mode (and the periodic CSO cases) | test/skill-e2e-cso.test.ts:61, :86, :133-134 | CANARY token absent from output, transcript and reports | n/a (leak canary) | yes | (c) |
+
+### test/skill-e2e-third-party-actions.test.ts
+
+The consent question is plain text by design (Read and Bash only). The probe's Bash result (`READY:`/`NEEDS_ASIDE: `/`ASIDE_NOT_RUNNING`, ship/SKILL.md:395-409) records detection, not what was offered.
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| tpa-present | test/skill-e2e-third-party-actions.test.ts:197-203 | Aside, `A)`, defer, dashboard host; no download pitch (regex :41) | probe `READY:` | yes | (c) |
+| tpa-absent-linux | test/skill-e2e-third-party-actions.test.ts:218-226; test/helpers/third-party-actions.ts:6, :23 | no pitch, no Aside-drive option, manual + headed-drive options | probe `NEEDS_ASIDE: Linux` | yes | (c) |
+| tpa-broken | test/skill-e2e-third-party-actions.test.ts:244, :249-252 | no drive option; "open the Aside app" or a lettered question | probe `ASIDE_NOT_RUNNING` | yes | (c) |
+| tpa-absent-darwin | test/skill-e2e-third-party-actions.test.ts:272-274 | pitch sentence, `macOS 15`, no drive options | probe `NEEDS_ASIDE: Darwin` | yes | (c) |
+| tpa-apple-ban | test/skill-e2e-third-party-actions.test.ts:307-311 | no drive option for account.apple.com; app-specific password guidance | none | yes | (c) |
+
+### test/helpers/workflow-boundaries-fixture.ts (investigate-owned files), test/skill-e2e-safety-pair-agent.test.ts, test/skill-e2e-safety-ship-evidence.test.ts
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| investigate-owned-completion, -abort, -ending-error | test/helpers/workflow-boundaries-fixture.ts:156-160 | scripted actor: option label ~ abort/stop/cancel or continue/proceed, else unsupported | AUQ input (labels are prose) | yes | (c) |
+| investigate-owned-abort | test/helpers/workflow-boundaries-fixture.ts:194 | final ~ abort/stop/cancel | receipts (:186-193) | yes | (c) |
+| investigate-owned-ending-error | test/helpers/workflow-boundaries-fixture.ts:209 | final ~ error/unavailable/cannot/unable | receipt `VERIFY_STATUS:69` (:208) | yes | (c) |
+| safety-pair-agent-block | test/skill-e2e-safety-pair-agent.test.ts:86, :91 | final contains the instruction block byte-for-byte | block is in the fake tool result; relaying it is the contract | yes | (c) |
+| safety-ship-stale-evidence | test/skill-e2e-safety-ship-evidence.test.ts:133 `callJudge` | — | — | — | judge |
+
+Gate cases with no phrase-only checks: the shared-libs review-lifecycle, revalidation, index-flags and prior-coverage cases (review-log rows and traces); ship-skipped-queued-finding; the five qa-callers cases; ship-section-loading; ship-triage (test/helpers/ship-triage-labels.ts:12 parses JSON first, so (a)); ship-coverage-value, review-test-value, test-audit-report-only (JSON records; regexes only select items, so (a)); review-army-json-findings; ship-local-workflow; gstack-upgrade-happy-path; qa-quick; qa-only-no-fix; qa-bootstrap; browse-basic; browse-snapshot; skillmd-setup-discovery; operational-learning; skillify-approval-reject; canary-workflow; benchmark-workflow; retro-base-branch; timeline-event-flow; hermetic-canary; hermetic-sentinel; diagram-triplet; safety-codex-consult-embed.
+
+## Periodic tier
+
+### B1 cases
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| auto-decide-preserved | test/skill-e2e-auto-decide-preserved.test.ts:173 (S5) | screen auto-decided annotation passes | handoff `--auto` + question-log, read only when the screen is unclassified | yes (handoff line) | (b) |
+| auto-decide-preserved | test/skill-e2e-auto-decide-preserved.test.ts:159 (S1/S2) | a shown option list fails | prose question only (AUQ disallowed) | yes | (c) |
+| plan-ceo-section-loading | test/helpers/ceo-stale-fill-decision.ts:54 ‖ test/helpers/ceo-section-loading-fixture.ts:688 (called test/skill-e2e-plan-ceo-review-section-loading.test.ts:118) | approved ledger row + `currentDecision` naming fill/write/race; else structured-then-prose finding | decision ledger and `currentDecision` blocks in PLAN.md | no | (a) |
+| plan-ceo-section-loading | test/helpers/auq-sdk-capture.ts:399-420 (called :101) | `COMPLETION SUMMARY` with Section 1-11 outcome rows | none per section | yes | (c) |
+| plan-ceo-section-loading | test/skill-e2e-plan-ceo-review-section-loading.test.ts:74, :110-111 | `## GSTACK REVIEW REPORT`, review table header, `\| CEO Review \|` row | rendered dashboard | yes | (c) |
+| plan-ceo-section-loading | test/helpers/auq-sdk-capture.ts:213 (called :113) | report row `Outside Review` status starts `disabled` | none in this native-only run | yes | (c) |
+| shared-libs-plan-callers | test/helpers/shared-libs-plan-actor.ts:28-112 (phrase refusals :63, :67, :72, :75, :85, :109) | scripted actor parses question prose: reuse verbs, `lib/retry-after.ts`, contract wording, 9 scope-expansion regexes; a match fails the case | AUQ input; option text is the only carrier of what is offered | yes (question content) | (c) |
+| shared-libs-plan-callers | test/skill-e2e-shared-libs-periodic.test.ts:198 | — | — | — | judge |
+| plan-design-review-plan-mode | test/skill-e2e-design.test.ts:489, :497, :508 | output ~ `\d+/10` | review-log `initial_score`/`overall_score` (plan-design-review/sections/review-sections.md.tmpl:229); not verified in fixture | yes | (c) |
+| plan-design-review-plan-mode | test/skill-e2e-design.test.ts:496-497, :508 | output or rated plan growth has information architecture/interaction state/ai slop/hierarchy | none (review-log has scores and counts only) | no | (d) |
+| plan-design-review-plan-mode | test/skill-e2e-design.test.ts:499-504, :511 | plan.md contains empty/loading/error/state/responsive/accessibility | none | no | (d) |
+| ship-docsync-late-result | gate tier; listed in the ship-docsync table | — | — | — | see gate |
+
+### Plan reviews: test/skill-e2e-plan-ceo-mode-routing.test.ts, test/skill-e2e-plan-eng-*.test.ts, test/skill-e2e-plan-design-*.test.ts, carve-section-loading
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| plan-ceo-mode-routing | test/helpers/ceo-mode-option.ts:661-670 (called test/skill-e2e-plan-ceo-mode-routing.test.ts:248) | posture regex on the handoff tool-result line first, then post-answer prose | `gstack-ceo-mode-handoff` tool result (plan-ceo-review/SKILL.md.tmpl:410) | no | (a) |
+| plan-ceo-mode-routing | test/skill-e2e-plan-ceo-mode-routing.test.ts:257 | HOLD posture review | — | — | judge |
+| plan-ceo-mode-routing | test/helpers/pty/classify.ts:341 `MODE_RE` | option label starts with a mode name → mode question found | native AUQ options | yes | (c) |
+| plan-design-finding-floor | test/helpers/pty/boundaries.ts:653-658 `pickDesignFocusAll` | Step-0D title, "Project/branch/task" and ELI10 rating prose; no match stalls | AUQ header + option labels (parsed :659-661) | no (selector) | (b) |
+| plan-eng-multi-finding-batching | test/helpers/pty/boundaries.ts:62-116, :587-600, :626-640 | setup vs review question decided by prose ("This plan introduces N new classes", "Issue", scope-reduction) | decision ledger R records (plan-eng-review/sections/review-sections.md.tmpl:213-235); qid branches inert under `QUESTION_TUNING=false` | no | (b) |
+| plan-eng-multi-finding-batching | test/helpers/eng-seeded-coverage.ts:33-104 | distinct-issue identity per answered AUQ ("Issue N:", D/R-ID, ELI10 gates) | ledger R records, used only as fallback behind the prose gates | no | (b) |
+| plan-eng-multi-finding-batching | test/helpers/pty/classify.ts:555 via counting.ts:554 | screen completion text ends counting | review-log row / ExitPlanMode tool_use | no | (b) |
+| plan-eng-review-artifact | test/skill-e2e-plan-eng-artifact.test.ts:78 | test-plan artifact ~ dashboard/fetchStats/`/api/stats` | artifact schema has no field naming the change (scripts/resolvers/testing.ts:501-528) | no | (d) |
+| carve-section-loading [plan-eng-review] | test/helpers/carve-section-case.ts:120 → test/helpers/auq-sdk-capture.ts:381 | report or output ~ report/review/summary/design doc/handoff | review-log row in the capture state root, deleted before the verdict (auq-sdk-capture.ts:368-370) | no | (b) |
+| carve-section-loading [12 other skills] | same | same loose regex | none (Bash not allowed, auq-sdk-capture.ts:358-359) | no | (d) |
+
+The plan-eng-review-plan-mode and plan-design-review-plan-mode-smoke verdicts are S1-S10. plan-eng-finding-floor and plan-design-finding-floor verdicts are F1-F3.
+
+### Question format: test/skill-e2e-plan-format.test.ts, test/skill-e2e-plan-prosons.test.ts, standalone AUQ files
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| plan-ceo-review-format-mode, plan-eng-review-format-kind | test/skill-e2e-plan-format.test.ts:174-175, :377-378 | no `Completeness: N/10`; kind note present | captured question | yes | (c) |
+| plan-ceo-review-format-approach, plan-eng-review-format-coverage | test/skill-e2e-plan-format.test.ts:241, :310 | `Completeness: N/10` present | captured question | yes | (c) |
+| all four format cases | test/skill-e2e-plan-format.test.ts:177, :243, :312, :380 | recommendation quality | — | — | judge |
+| plan-review-prosons-format | test/skill-e2e-plan-prosons.test.ts:184-196 | `D\d+ —`, ELI10:, Stakes…:, Recommendation:, Pros / cons:, ^Net:, ✅≥4, ❌≥2, (recommended) | captured question | yes | (c) |
+| plan-review-prosons-hardstop-neg | test/skill-e2e-plan-prosons.test.ts:246-249 | no hard-stop escape line; ✅/❌ counts | captured question | yes | (c) |
+| plan-review-prosons-neutral-neg | test/skill-e2e-plan-prosons.test.ts:299-303 | no `taste call`; (recommended); `Recommendation:.*because` | captured question | yes | (c) |
+| plan-ceo-review-prosons-cadence | test/skill-e2e-plan-prosons.test.ts:353-358 | Pros / cons header; hard-stop escape or pros+cons | captured question | yes | (c) |
+| test/skill-e2e-auq-matrix.test.ts | test/helpers/auq-sdk-capture.ts:54-59 (called :166) | `Recommendation:` line; exactly one `(recommended)` label | AUQ options (the suffix the AUTO_DECIDE hook parses) | yes | (c) |
+| test/skill-e2e-auq-consistency.test.ts | test/skill-e2e-auq-consistency.test.ts:65, :87 | each A1 element present in every run | AUQ text | yes | (c) |
+| test/skill-e2e-auq-verbose-vs-carved-ab.test.ts | test/skill-e2e-auq-verbose-vs-carved-ab.test.ts:43, :108 | carved format count ≥ verbose | AUQ text | yes | (c) |
+| AUQ matrix, consistency, A/B | auq-matrix :148; consistency :69, :95-96; A/B :48, :109 | substance panels and tolerances | — | — | judge |
+| plan-ceo-review-expansion-energy | test/skill-e2e-plan.test.ts:271-275 | posture | — | — | judge |
+
+### Shared-libs, review army, codex review, design
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| shared-libs-opportunity-judgment | test/skill-e2e-shared-libs-periodic.test.ts:121-122 | report contains tip SHA and `lib/retry-after.ts` | none | yes | (c) |
+| shared-libs-opportunity-judgment | test/skill-e2e-shared-libs-periodic.test.ts:123 | `JSON.stringify(transcript)` ~ inventory.py/search.py/negative inventory (includes assistant prose) | Read/Bash tool-call inputs | no | (b) |
+| shared-libs-opportunity-judgment, shared-libs-pr-coverage | test/skill-e2e-shared-libs-periodic.test.ts:88, :125, :158 | — | — | — | judge |
+| shared-libs-codex-read-only | test/codex-e2e-shared-libs.test.ts:123-129 | Codex report contains SHA, overlay wording, file names, savings, tests | none | yes | (c) |
+| review-army-red-team | test/skill-e2e-review-army.test.ts:651 | output ~ red team/adversarial | Agent/Task dispatch description (perf case reads it, :256-258) | no | (b) |
+| review-army-consensus | test/skill-e2e-review-army.test.ts:737-741 | output includes sql/injection/interpolat | specialist JSON findings in Agent results; not verified | no | (b) |
+| review-army-simplification | test/skill-e2e-review-army.test.ts:806-815 | output includes native/stdlib/speculative/…; not "lean already. ship." | none | partly | (d) |
+| review-army-simplification-precision | test/skill-e2e-review-army.test.ts:868-872 | no delete/shrink JSON finding on the self-check; `NO FINDINGS` sentinel | specialist JSON lines / sentinel | no | (a) |
+| review-design-lite | test/skill-e2e-review.test.ts:285-306 | ≥4 of 7 keyword groups | none | partly | (d) |
+| codex-review | test/skill-e2e-workflow.test.ts:478, :481 | executed verdict from Codex command output | lib/outside-review-result.ts | no | (a) |
+| codex-review | test/skill-e2e-workflow.test.ts:482 | codex-output.md ~ `GATE: PASS\|FAIL (N critical\|UNVERIFIED`, not fail-closed | same verdict | yes (gate line) | (c) |
+| design-consultation-core | test/skill-e2e-design.test.ts:160-161, :186, :190 | DESIGN.md has one synonym per 7 section sets; CLAUDE.md mentions design.md | none for content | yes | (c) |
+| design-consultation-research | test/skill-e2e-design.test.ts:253, :263 | "Search unavailable" only when no WebSearch/aside call | WebSearch / aside tool calls read first (:243-245) | yes | (a) |
+| design-consultation-existing | test/skill-e2e-design.test.ts:314-315, :334-335 | DESIGN.md includes color and spacing | format marker only | yes | (c) |
+| design-review-fix | test/skill-e2e-design.test.ts:714, :742 | design-audit.md ~ 47/48px, line-height, radius, padding, spacing, heading | none | yes | (c) |
+| design-html-slop-gate | test/skill-e2e-design.test.ts:1318-1319 | gate-output.md has ai-color-palette and accepted | scan call count only | yes | (c) |
+| design-consultation-core, plan-decision-classification, plan-devex-peer-comparison-classification, diagram-authoring-quality | design :168; classification :150-153, :40-43; diagram :159-183 | — | — | — | judge |
+
+### Context, scrape, CSO, health, Aside
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| context-save-then-restore-roundtrip | test/skill-e2e-context-skills.test.ts:221, :231 | surface has the magic title (vacuous: it is in the Skill input) | restore Read/Bash tool_result of the saved file | no | (b) |
+| context-restore-fragment-match | test/skill-e2e-context-skills.test.ts:266-267, :277-278 | surface has the PAYMENTS marker, lacks ALPHA/OMEGA | Read/Bash tool_result content | no | (b) |
+| context-restore-provenance-order | test/skill-e2e-context-skills.test.ts:321-327, :344 | "Next steps" before "Verify first" with tokens placed | none | yes (context-restore/SKILL.md.tmpl:217-221) | (c) |
+| context-restore-provenance-order | test/skill-e2e-context-skills.test.ts:328, :335, :338, :345 | `FIRST_ACTION: verify PROV_ASSUMED_7Q` | none (AUQ disallowed) | no | (d) |
+| context-restore-empty-state | test/skill-e2e-context-skills.test.ts:374, :385 | surface ~ no saved context/NO_CHECKPOINTS (vacuous: in the injected body) | Bash result `NO_CHECKPOINTS` (context-restore/SKILL.md.tmpl:84, :95) | partly | (b) |
+| context-restore-list-delegates | test/skill-e2e-context-skills.test.ts:413, :423 | surface ~ `context-save list` (vacuous) | none | yes | (c) |
+| context-restore-legacy-compat | test/skill-e2e-context-skills.test.ts:465-470, :480 | surface has marker/title/filename/branch or pre-rename | Read of the legacy file | no | (b) |
+| context-save-list-current-branch | test/skill-e2e-context-skills.test.ts:517, :536 | surface ~ timestamp/main-work | Bash listing result | partly | (b) |
+| context-save-list-current-branch | test/skill-e2e-context-skills.test.ts:525-526, :537-538 | final lacks other-branch entries | none (model-rendered list) | yes | (c) |
+| context-save-list-all-branches | test/skill-e2e-context-skills.test.ts:572-576, :586 | surface has all 3 timestamps | Bash listing result | partly | (b) |
+| scrape-prototype-path | test/skill-e2e-skillify.test.ts:283, :290-294, :304-305 | surface ~ /skillify/ and `"items": [` (both vacuous: in scrape/SKILL.md.tmpl:32, :173) | none | yes | (c) |
+| cso-full-audit | test/skill-e2e-cso.test.ts:139-140 | server.js finding prose ~ tenant/owner/authoriz | no class field in report.json | no | (d) |
+| cso-infra-scope | test/skill-e2e-cso.test.ts:227-229 | finding prose ~ comment body, run/shell, GITHUB_TOKEN | same | no | (d) |
+| health-reporting | test/helpers/health-eval-fixture.ts:132-156 | rendered dashboard: composite 5.6, partial coverage, 0/10 typecheck, "60 errors", unavailable categories, no trend verdict, N/A composite | health-history.jsonl rows (already checked :158-176) | yes | (c) |
+| aside-browse-basic, aside-qa-quick, aside-scrape-json | test/skill-e2e-aside.test.ts:179, :219, :234-239 | title in final message; `health score` in report; final JSON contains page1 | none verified | yes | (c) |
+| aside-canary-quick | test/skill-e2e-aside.test.ts:256 | canary report ~ CANARY REPORT/Status: | `{date}-canary.json` (canary/SKILL.md.tmpl:221); not verified | no | (b) |
+| qa-b6-static, qa-b7-spa, qa-b8-checkout | test/skill-e2e-qa-bugs.test.ts:158, :177-179 | — | — | — | judge |
+
+### Office hours, gbrain, codex, outside voice, safety, benchmarks
+
+| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
+|---|---|---|---|---|---|
+| office-hours-phase4-fork | test/skill-e2e-office-hours-phase4.test.ts:190-191 | written question has `because` and ≥2 option lines | none (AUQ suppressed by the prompt, :128) | yes | (c) |
+| office-hours-phase4-fork | test/skill-e2e-office-hours-phase4.test.ts:193 | approach/architecture vocabulary or Server/Client/Hybrid options | none; a native capture would make it structural | no | (d) |
+| office-hours-phase4-fork | test/helpers/llm-judge.ts:496-516 (via test/helpers/e2e-helpers.ts:320-322) | deterministic `Recommendation:` + `because` part of judgeRecommendation | none | yes | (c) |
+| office-hours-design-draft | test/helpers/office-hours-completion.ts:107-115 | design doc has substantive Problem/Approach/Success/"What I noticed" and Assignment sections | none | yes | (c) |
+| setup-gbrain-bad-token | test/skill-e2e-setup-gbrain-bad-token.test.ts:60-61 | `JSON.stringify(result)` ~ error_class AUTH/rotate token (satisfied by the helper's own stdout) | verify JSON `error_class` (bin/gstack-gbrain-mcp-verify:64), already checked :65-67 | partly | (b) |
+| setup-gbrain-remote | test/skill-e2e-setup-gbrain-remote.test.ts:326-328 | serialized transcript must not contain `"error_class": NETWORK/AUTH/MALFORMED` | verify JSON in the Bash result | no | (b) |
+| setup-gbrain-remote | test/skill-e2e-setup-gbrain-remote.test.ts:352 | CLAUDE.md ~ `Mode: remote-http` | the persisted config block | yes | (c) |
+| setup-gbrain-path4-local-pglite | test/skill-e2e-setup-gbrain-path4-local-pglite.test.ts:72-73; test/helpers/setup-gbrain-fixture.ts:122-169 | question ~ code search/pglite/symbol; scripted actor classifies option labels and throws on unknown sets | AUQ labels only, no option ids | yes | (c) |
+| sync-gbrain-read-ready, sync-gbrain-read-unknown | test/helpers/sync-gbrain-readiness-verdict.ts:4-25 | Capability row OK/WARN, YELLOW verdict, no unnegated ready claim | bin/gstack-gbrain-read-capability.ts:87 status JSON (status only; row and verdict are rendered) | yes | (c) |
+| codex-discover-skill | test/helpers/codex-eval.ts:43 | output ~ review/gstack/skill | none | partly | (d) |
+| codex-review-findings | test/helpers/codex-eval.ts:66-67; lib/outside-review-result.ts:88 | output ~ finding/issue/…/no issues; refusal phrases → unavailable | executed commands read first; no review-log step in the fixture | partly | (d) |
+| outside-voice-codex-to-claude-code, outside-voice-claude-code-to-codex | test/helpers/outside-voice-evidence.ts:214-216 | outside stdout ~ invoice and authorization defect; no refusal | dispatch and receipts are structural; the review-log row holds status only | no | (d) |
+| outside-plan-disabled-no-fallback | test/helpers/disabled-plan-review-fixture.ts:322-325 | JSON `outside_review_this_run: disabled` first, then prose | review-log `outside_status:"disabled"` (scripts/resolvers/outside-voice-steps.ts:331) | yes | (a) |
+| outside-plan-disabled-no-fallback | test/helpers/disabled-plan-review-fixture.ts:326-328 | no unattributed "both reviewers agree" claim | none | yes | (c) |
+| safety-design-risk-stop | test/skill-e2e-safety-design-risk.test.ts:165 | final names each unfixed FINDING-00N | git log decides which remain; the listing has no record | yes | (c) |
+| benchmark-providers-live | test/skill-e2e-benchmark-providers.test.ts:116, :136, :168 | provider output contains "ok" | none | yes | (c) |
+| office-hours-forcing-energy, office-hours-builder-wildness, office-hours-phase4-fork, safety-design-risk-stop, llm-judge-recommendation, arm-benchmark-* | office-hours :102-107, :183-188; e2e-helpers.ts:323-326; design-risk :166-174; llm-judge-recommendation :42-163; arm-benchmark :121 (non-gating) | — | — | — | judge |
+
+Periodic cases with no phrase-only checks: plan-ceo-review, plan-ceo-review-selective, plan-eng-review (exit reason and optional file length only); autoplan-dual-voice and autoplan-journal-drift (transcript evidence); the eleven journey-* routing cases (Skill tool input); context-save-routing; scrape-match-path; qa-fix-loop; retro; office-hours-brain-writeback; gbrain-roundtrip-local; safety-codex-boundary; safety-ios-demo; codex-sol-scope-termination; ios-qa-device; design-consultation-preview; the five overlay-harness cases (JSON parse plus deep-equal).
+
+## Convert candidates
+
+These are ordered by red history across the 8 wave censuses (periodic censuses 37174266054 through 37198445662), then by tier and how many cases one change covers.
+
+1. **S5 screen-first auto-decision** (test/helpers/pty/classify.ts:460). auto-decide-preserved went red in 3 of 8 censuses, all `outcome=timeout`. Its replay corpus (test/fixtures/detector-corpora/auto-decide-preserved/) shows two were product failures (the model never sent the handoff line) and one an order false red that is already fixed, so no red came from the screen phrase itself. Letting the bound question-log record hand the decision to the native transcript check also covers office-hours-auto-mode and the plan-mode smokes, but two of the five recent passing captures cannot prove that path from their archived polls (the poll predates the handoff message), so the conversion needs a paid diagnostic run before it lands.
+2. **shared-libs-opportunity-judgment transcript regex** (test/skill-e2e-shared-libs-periodic.test.ts:123). The case went red in 2 of 8, but both reds were judge failures, so this conversion removes a fragile check rather than a past red.
+3. **S3/S4/F3 PTY terminals** (test/helpers/pty/classify.ts:425, :467; test/helpers/pty/runners/floor.ts:497). 0 reds. Derive `plan_ready` and `silent_write` from ExitPlanMode and Write tool_use in the journal the runner already reads. One change covers 4 gate and 5 periodic cases.
+4. **Setup selectors that stall on wording** (test/helpers/plan-floor-review.ts:278-280, test/helpers/pty/boundaries.ts:653-658, test/helpers/plan-review-board-feedback.ts:111-120, test/skill-e2e-plan-design-with-ui.test.ts:34-57). 0 census reds, but PR-lane reds for plan-design-with-ui-scope (runs 37166586458, 37172907071). Identify the question by AUQ header and option labels.
+5. **Gate READY probe** in skillmd-no-local-binary and skillmd-outside-git (test/skill-e2e-bws.test.ts:156, :194). Read the setup-block Bash result instead of the final message.
+6. **Gate artifact and trace swaps**: context-save-writes-file (parse frontmatter), ship-base-branch (git trace), land-and-deploy-workflow and -first-run (`PLATFORM:` result), setup-deploy-workflow (config block), skillify-provenance-refusal load tripwire (Skill tool_result), shared-libs-review-path-eligibility (drop the `output` operand). Separately, context-recovery-artifacts needs `bin/gstack-context-recovery` copied into its fixture before it can convert.
+7. **plan-eng-multi-finding-batching** (test/helpers/pty/boundaries.ts:62-116, test/helpers/eng-seeded-coverage.ts:33-104, test/helpers/pty/classify.ts:555). Count decision-ledger R records bound to answered AUQ ids instead of question prose.
+8. **Periodic surface narrowing**: the context-skills roundtrip, fragment, empty-state, legacy and list cases; carve-section-loading [plan-eng-review]; review-army red-team and consensus; setup-gbrain-bad-token and -remote; aside-canary-quick. Several of these phrase checks are vacuous today, because the surface includes the injected SKILL.md body, so narrowing them to tool results will surface new reds rather than remove old ones.
+
+Red cases that are not convert candidates: shared-libs-plan-callers (3 reds, actor phrase refusal on "hardening", (c)); plan-ceo-section-loading (2 reds, report checks (c) and a ledger check already (a)); plan-review-prosons-neutral-neg (2, question format (c)); plan-ceo-review-format-mode and -format-approach, tpa-absent-darwin, and auq-matrix (1 each, (c)); plan-design-review-plan-mode (1, (d)); scrape-prototype-path (1, vacuous (c); the failing assertion is unknown); plan-ceo-mode-routing (1, already (a)); plan-eng-review-artifact (timeout), shared-libs-read-only (filesystem snapshot), shared-libs-review-revalidation (process abort) and the overlay sonnet case (measurement validity) did not fail on a phrase check. The four `SKILL.md workflow`/`platform setup` reds are skill-llm-eval judges outside `E2E_TIERS`.
+
+## Not audited
+
+- Census commits `a8ace39` through `fd6854b` are not in this checkout, so the wave-reds line numbers could not be mapped to assertions; reds are attributed from the trial error text.
+- The skill-llm-eval.test.ts judge cases are outside `E2E_TIERS` and are not covered.
+- codex-ci-access.test.ts, codex-hardening.test.ts, codex-under-codex-detection.test.ts, e2e-shard-reuse.test.ts and skill-routing-e2e.test.ts name the umbrella keys but are free tests or route to the files audited above.
+- plan-eng-review-artifact-full is `marathon` and out of scope.
+- The aside-* cases need a live Aside on macOS and never run in CI.
+- "Not verified" in the structured-source column means the template writes the record, but the audit did not confirm that the fixture installs the helper.
diff --git a/docs/evals/headroom-diagnosis-2026-10.md b/docs/evals/headroom-diagnosis-2026-10.md
new file mode 100644
index 000000000..64977e41c
--- /dev/null
+++ b/docs/evals/headroom-diagnosis-2026-10.md
@@ -0,0 +1,98 @@
+# Headroom diagnosis, October 2026 (A1-A3)
+
+Per-phase timings for the cases the reliability plan named as slow, measured from
+stored CI artifacts of census 37198445662 (head `fd6854b`) and, for the one red,
+37176837432. Native captures carry message timestamps; PTY cases carry the
+assistant-message timestamps their observation files keep. Durations in the
+trial history (`wave-trials`) for multi-test files are the JUnit **sum** of tests
+that run concurrently, not wall time or session time, so they overstate headroom
+for `plan-mode-no-op` and `plan-ceo-mode-routing`.
+
+## A1. ship-docsync-completion and -store (session budget 585 s)
+
+History: completion 349-517 s, store 377-466 s across the eight wave censuses.
+
+| Phase (37198445662) | completion | store |
+|---|---|---|
+| Prepare: read phase, skills, Git state, fixture files; snapshot | 85 s (two thinking gaps of 9 s and 26 s) | 99 s (33 s thinking) |
+| Compose child prompt (9.5 KB / 7.1 KB `Agent` input) | 43 s | 32 s |
+| Child (`document-release`, foreground) | 122 s | 118 s |
+| Validate (compare, Git reads, small file reads) | 3 s tool time, 38 s thinking | 3 s tool time, 13 s thinking |
+| Write records after the child | 45 s (section copy, report, result JSON with typed hashes) | 78 s (child output copy, section, record, report) |
+| Publish stand-in, verify, final message | 32 s | 25 s |
+
+Tool time in the parent is about 1 s per session. Reading release-file contents
+does not dominate (10 small files, ~1 s), so the parent keeps its reads and the
+child result contract stays unchanged. The parent's own model time dominates
+(~270 s of ~390 s). Two parts are cuttable without weakening the gate:
+
+- **Records after the child** (product): the parent retyped hash maps and child
+ output into several files. The gate now records post-child hashes by rerunning
+ the `gstack-docs-candidate snapshot` command (`--out -post.json`),
+ Step 16 runs `compare` on it, and each record is saved once and cited by path.
+- **Child prompt copy** (fixture only): ~70% of the child prompt is the fixture's
+ observation interface, which the fixture tells the parent to copy into child
+ prompts (`docsNativeInterface`, "include this interface in child prompts").
+ Writing that interface to a fixture file the child reads would save ~30-40 s per
+ session; it touches the shared detector/actor module, so it is a lane D request.
+
+## A2. plan-mode-no-op and plan-ceo-mode-routing
+
+**plan-mode-no-op is not a headroom problem.** Its six PTY sessions start
+concurrently; 37198445662's file wall is 263 s (slice entry 271.6 s) while the
+JUnit sum is 518 s. The slowest session is 159 s of its 300 s observation budget
+(53%). It runs in gate slice 4, not the 711 s critical-path slice 11.
+
+**plan-ceo-mode-routing** runs two concurrent sessions (wall 378 s, sum 687 s).
+The slowest session (HOLD SCOPE) is 378 s of 600 s (63%). The tight budget is the
+test's 240 s post-selection window:
+
+| HOLD SCOPE session (37198445662) | elapsed |
+|---|---|
+| Preamble | 17 s |
+| Pre-review audit batch | 46 s |
+| Setup questions and answers | 45 s |
+| Learnings search, Step 0 analysis written to the plan | 50 s |
+| Mode question rendered and answered | 20 s |
+| Handoff helper, plan write, two section reads, Codex probe, then posture chat | 114 s |
+| HOLD continuation question and posture assessment | 76 s |
+
+In the red (37176837432) the session skipped the handoff helper and paraphrased
+the mode ("holding scope"), then spent ~60 s per decision on the ledger
+save/read-back/ask cycle until the 240 s window closed. The skill now sends the
+handoff line before any other tool call, plan write or section load, and never
+skips the helper. The remaining time is model latency of the decision procedure
+(about 60 s per question: comparison grid, save, Read-back, ask); cutting that
+would weaken the question-persistence gate, so it stays a named item.
+
+## A3. Census critical path
+
+Gate slice 11 (711 s, the census critical path) holds 17 short files. Their
+recorded estimates (`scripts/paid-test-durations.json`, recorded 2026-09-29)
+total 1078 s; they ran 1353 s. Four files ran about twice their estimate in
+every wave census:
+
+| File | estimate | wave census range |
+|---|---|---|
+| `skill-e2e-plan.test.ts#plan-review-report` | 76 s | 168-273 s |
+| `skill-e2e-plan-ceo-plan-mode.test.ts` | 37 s | 85-120 s |
+| `skill-e2e-deploy.test.ts#benchmark-workflow` | 62 s | 59-121 s |
+| `skill-e2e-safety-codex-consult.test.ts` | 83 s | 111-212 s |
+
+None is near its own budget (plan-review-report peaks at 46% of 600 s). The fix
+is a refreshed duration seed so the planner packs slice 11 within the 540 s slice
+budget (lane F / integrator). review-army-perf and plan-design-review-plan-mode
+need no work.
+
+## plan-eng-multi-finding-batching: 975 s in census 37228573062 (GSTA-23 branch)
+
+The red trial ran 975 s of its 1,500 s armed session (65%), against 313-409 s
+in the nine earlier censuses (37162480720 … 37198445662). The wall is a
+consequence of the detector miss, not slower work: the runner stops
+collecting once the counter credits FLOOR = 3 review questions (normally
+by D3, about 5-7 minutes). The counter credited none, because the report
+declared its target mid-line ("… by the user. Review target (fixed):
+`PLAN.md` …"), so collection ran on through D10 and the completed report.
+With the target-field fix the same capture is credited at D5 (521 s).
+Replay: `bun test test/detector-corpus-plan-eng-multi-finding-batching.test.ts`.
+No budget or kind change.
diff --git a/docs/evals/judge-controls-2026-10.md b/docs/evals/judge-controls-2026-10.md
new file mode 100644
index 000000000..584eb2d73
--- /dev/null
+++ b/docs/evals/judge-controls-2026-10.md
@@ -0,0 +1,83 @@
+# Workflow-judge control bundles (C1), pre-registered 2026-10-04
+
+Purpose: learn whether the workflow judges that produced low samples in the eight
+wave censuses (37174266054 … 37198445662) can separate a sound workflow from a
+broken one. Score compression at 4 is a hypothesis; these controls test it.
+
+Judges in scope (low samples that failed a panel): `qa/SKILL.md workflow`,
+`qa-only/SKILL.md workflow`, `review/SKILL.md workflow`,
+`setup-deploy/SKILL.md platform setup`. Each control uses the production prompt
+builder, model, options and pass floors of its eval (clarity ≥ 3, completeness ≥ 3,
+actionability ≥ 4 on the 3-sample mean), built by `scripts/judge-controls.ts`,
+which reuses `readWorkflowJudgeInput`, `buildWorkflowJudgePrompt` /
+`buildQaWorkflowJudgePrompt` and `callJudge` exactly as
+`test/skill-llm-eval.test.ts` and `scripts/judge-calibration-configs.ts` do.
+
+## Bundles and expected outcomes
+
+Every bundle is derived from the generated files at the tested revision. The
+mutation is applied to the judge's input text after the production reader builds it.
+
+| Bundle | Construction | Expected panel verdict |
+|---|---|---|
+| positive | the production bundle, unchanged | pass |
+| negative | file boundary lines and Markdown headings only; every instruction, command and table removed | fail |
+| targeted defect | the production bundle with one execution-blocking defect (below) | fail |
+
+Targeted defects, one per judge:
+
+| Judge | Defect |
+|---|---|
+| qa workflow | `## Phase 10: Report` removed up to `## Phase 11:`: the deliverable and its path are undefined |
+| qa-only workflow | after `# /qa-only: Report-Only QA Testing`, insert "After reporting each bug, fix it in the source and commit the fix." (contradicts report-only) |
+| review workflow | `## Step 3: Get the diff` removed up to `## Step 3.4`: no instruction says how to obtain the reviewed candidate |
+| setup-deploy platform setup | the Step 2 detection `bash` block removed: platform detection has no command |
+
+## Panels and reading the result
+
+One 3-sample panel per bundle (12 panels, 36 calls). No reruns, no extra samples.
+A judge **discriminates** when the positive passes and both the negative and the
+targeted defect fail. A judge whose negative passes gives no coverage; its cases are
+listed in the PR as such, and a rubric proposal goes to Garry as a pre-registered
+EVAL_POLICY change. A targeted defect that passes is recorded as a sensitivity gap
+for that judge, with the sample rationales.
+
+Results are written by the runner to `docs/evals/judge-controls/results.json` and
+summarised below after the run.
+
+## Results
+
+Run 2026-10-04 on branch `wave/s-followups` (36 calls, `results.json` holds every
+sample and rationale). Means are clarity / completeness / actionability.
+
+| Judge | Positive | Negative | Targeted defect |
+|---|---|---|---|
+| qa workflow | pass 4.00 / 4.33 / 4.00 | fail 3.00 / 3.33 / 2.33 | **pass** 3.33 / 4.00 / 4.00 |
+| qa-only workflow | pass 3.67 / 4.33 / 4.00 | fail 2.00 / 1.33 / 1.00 | fail 2.00 / 4.00 / 2.67 |
+| review workflow | pass 4.00 / 4.00 / 4.00 | fail 2.00 / 1.00 / 1.00 | **pass** 4.00 / 4.00 / 4.00 |
+| setup-deploy platform setup | pass 4.00 / 4.00 / 4.00 | fail 1.33 / 1.00 / 1.00 | fail 4.00 / 3.00 / 3.33 |
+
+- Every judge separates the negative control, so each gives coverage for gross
+ breakage; no case is listed as giving no coverage.
+- No positive sample scored 5 on any dimension, consistent with the 4.00
+ compression seen in census 37198445662.
+- Sensitivity gaps: the qa workflow judge passed a bundle with no Phase 10 report
+ step (no sample mentioned it), and the review workflow judge passed a bundle with
+ no Step 3 (two of three samples noticed "no Step 3 exists" but rated it minor). In
+ bundles of 70-160 KB these judges do not detect a missing deliverable or input
+ step. A rubric proposal (anchor 4 and 5 to named deliverables and inputs) is a
+ pre-registered EVAL_POLICY change for Garry, not part of this change.
+
+## Missing-step coverage moved to a free test
+
+Because the qa and review workflow judges passed the targeted defects above,
+missing-step coverage no longer rests on the judges.
+`test/workflow-required-steps.test.ts` checks that every step and phase heading
+of the judged workflow skills (qa, qa-only, review, ship, plan-ceo-review,
+plan-eng-review, setup-deploy) is still in the generated SKILL.md or section
+file, in order, and names the missing step when one is removed (the two control
+defects above are its negative cases). The pinned list,
+`test/fixtures/workflow-required-steps.json`, was derived from the templates'
+step headings; a new step heading must be added to it. The judges keep scoring
+clarity, completeness and actionability; the rubric revision stays a proposal
+for Garry (TODOS.md).
diff --git a/docs/evals/judge-controls/results.json b/docs/evals/judge-controls/results.json
new file mode 100644
index 000000000..fed07ea01
--- /dev/null
+++ b/docs/evals/judge-controls/results.json
@@ -0,0 +1,365 @@
+{
+ "recorded_at": "2026-10-04T17:58:09.689Z",
+ "results": [
+ {
+ "judge": "qa/SKILL.md workflow",
+ "bundle": "positive",
+ "verdict": "pass",
+ "mean": {
+ "clarity": 4,
+ "completeness": 4.333333333333333,
+ "actionability": 4
+ },
+ "samples": [
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The bundle has a strong skeleton: a section index with explicit load conditions, repeated STOP/Read directives with a uniform missing-asset blocked path, a well-defined browser-vs-functional split (scope.md surface selection, isolation rules, LOCAL host definition), an explicit caller-authority matrix for browser setup (report-only vs standalone, unknown → report-only), a concrete functional contract map with enumerated outcomes, and a fully specified fix loop (8a→8f) with regression-before-repair, value card, commit/classify/revert rules, self-regulation triggers and a hard cap. Tooling is executable (bash scripts, DEADLINE_TOOL/EVIDENCE_TOOL invocations, health-score weights and rounding, both report templates, screenshot naming). Weaknesses that keep it from 5s: prose is extremely compressed and sometimes terse to the point of ambiguity (e.g., checkpoint 'exploration-NNN.json' rules, 'Guard if bounded; await completion', 'preserve every safe program-JSON key/value and identity hash'); the interplay between exploratory.md's loop and qa-patterns' Phases 1-6 'as techniques' requires careful reading; there is mild tension between 'extend an existing table-driven test' and 'use new native test files / {name}.regression-N.test.{ext}'; functional report naming ('safe command/service label') is vaguer than the browser convention; and some execution depends on assets not in the bundle (browse/SKILL.md Cookbook, /devex-review, gstack tool semantics). An agent can execute and produce the deliverables, but must work hard to parse the dense rules."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The bundle is well-structured: SKILL.md gives an ordered phase sequence (Setup → learnings/test-plan context → baseline probes → triage → fix loop with regression-before-repair → final QA → report → TODOS → learnings) with explicit, conditional Read directives and defined behavior for missing/unreadable sections (block affected probes, continue safe checks). Authority (report-only vs standalone, caller-owned decisions), isolation (LOCAL vs non-local, owned fixtures, one AskUserQuestion for mutations), native contract map, deadline/evidence tooling, checkpoint publication, severity rubric, output naming, templates and taxonomy are all specified with concrete bash/aside/$B commands, making it largely executable. Weaknesses: exploratory.md is extremely dense and terse (e.g., 'Preserve every safe program-JSON key/value and identity hash unchanged', 'QA_DEADLINE receipts are not observations', PROBE_DIR vs REPORT_DIR selection) and would require careful parsing; the DX path is a single sentence in scope.md with no location for /devex-review; the 8a.5 value-card 'JSON keeps full values' references an undefined JSON artifact; and some cross-skill dependencies (browse cookbook, deadline/evidence tool flags beyond those listed) are external to the bundle. These create minor ambiguities and small gaps rather than blocking defects."
+ },
+ {
+ "clarity": 4,
+ "completeness": 5,
+ "actionability": 4,
+ "reasoning": "The entrypoint lays out an unambiguous phase sequence (Setup → Learnings → Test-plan context → baseline Phases 1-6 → Triage → Fix loop 8a-8f → Final QA → Report → TODOS → Learnings) with explicit Read directives, stated load conditions, and a uniform blocked-path rule (missing/unreadable section → report setup blocker, block affected probes, continue safe independent checks). Authority is clearly partitioned by caller (standalone /qa vs report-only), isolation and ownership rules are concrete (LOCAL host definition, mutating-action consent, owned fixtures, unknown ownership blocks), and native functional contracts are enumerated in a contract map with defined outcome states. Browser-only sections are consistently fenced off from functional runs, and templates, naming conventions (regression-N.test.ext, run-YYYYMMDDTHHMMSSZ, screenshot names), tool invocations (deadline/evidence tools, learnings search, report copy), commit formats, value card, classification states, stop rules and hard cap are all specified. Weaknesses that keep it from perfect scores: the prose is extremely dense and relies heavily on cross-file references ('shared loop's gates', 'Phase 5 is the before', 'surface report template'), the checkpoint/exploration-NNN.json publication protocol is strict and easy to get wrong, a few outputs are only implied (exact functional report filename, mixed-run report layout, 'safe hostname' transformation), and there is mild tension between 'Diff-aware is automatic with no URL' in qa-patterns and 'absence of a URL never forces a browser' in SKILL.md (reconciled only by the surface-selection order). An agent can execute and produce the deliverables, but with notable cognitive load."
+ }
+ ]
+ },
+ {
+ "judge": "qa/SKILL.md workflow",
+ "bundle": "negative",
+ "verdict": "fail",
+ "mean": {
+ "clarity": 3,
+ "completeness": 3.3333333333333335,
+ "actionability": 2.3333333333333335
+ },
+ "samples": [
+ {
+ "clarity": 3,
+ "completeness": 3,
+ "actionability": 2,
+ "reasoning": "Only section headings were supplied, not the instruction bodies, so the evaluation is limited to structure. The outline is well-organized: an entrypoint with a section index, numbered phases 1-11 covering baseline, triage, fix loop (diagnose/reproduce, regression-before-repair, fix, re-test, commit, classify, self-regulation), final QA, reporting, TODOS update and learnings capture; conditional sections for browser setup/fallback, exploratory probing, native functional contracts, test bootstrap; plus report templates and an issue taxonomy. However, several things the rubric asks about are not visible at all in the provided material: authority rules, isolation/safety boundaries, blocked paths, how 'Phases 1-6: QA Baseline' maps onto native (non-browser) surfaces when qa-patterns.md is explicitly 'Browser QA methodology', how scope.md (a lone ### fragment, 23 lines) drives the browser/native/mixed decision, and what triggers DX/test-bootstrap loading. SKILL.md starts at line 427, implying missing preceding content. Inserted steps like 8a.5 and 8e.5 hint at patching that may create ordering ambiguity. Without the actual step text, bounded-probe limits, decision criteria, and deliverable formats cannot be confirmed as executable, so actionability is rated conservatively."
+ },
+ {
+ "clarity": 3,
+ "completeness": 4,
+ "actionability": 3,
+ "reasoning": "What is visible is only a heading-level outline of each file, not the step text, so executable decisions cannot be confirmed. Structurally the bundle is strong: a scope/surface selector loaded before setup, a gated browser-setup aside with an explicit headless fallback, a native-tool functional section with a contract map and evidence retention, a shared exploratory probe loop with parent handoff, a full Test→Triage→Fix Loop (diagnose/reproduce, regression-before-repair, re-test, commit, classify, self-regulation STOP) →Final QA→Report→TODOS update→Capture Learnings chain, two report templates, a severity/category taxonomy, and a health-score rubric. Gaps at the outline level: no visible section naming authority/isolation rules or an explicit blocked-path protocol (presumably buried in Setup/Additional Rules/Browser access decision), the section-index load conditions and mode-selection criteria are not exposed, and Prior Learnings/Capture Learnings have no dedicated file. Clarity and actionability are therefore rated as plausible-but-unverified: the phase ordering is unambiguous, but whether each phase gives concrete commands, exit criteria, and decision tests cannot be judged from headings alone."
+ },
+ {
+ "clarity": 3,
+ "completeness": 3,
+ "actionability": 2,
+ "reasoning": "The bundle exposes a coherent skeleton: an entrypoint with Setup, Phases 1-11, a detailed Fix Loop (diagnose/reproduce → regression test before repair → fix → re-test → commit → classify → self-regulation STOP gate), conditional sections for browser setup/fallback, native functional contracts, exploratory probing, and report templates plus a taxonomy. Phase ordering and the test-before-fix discipline are clear at the heading level. However, only headings are provided, so the actual load conditions for each section, authority/isolation rules, the contract map contents, blocked-path handling, and the browser-access decision criteria cannot be verified. Several ambiguities surface even from the outline: SKILL.md 'Phases 1-6' overlaps with qa-patterns' own 'Phase 1-6' and exploratory's '1-4' numbering; scope.md is a lone H3 fragment with no parent context; decimal sub-steps (8a.5, 8e.5) hint at bolted-on logic; and no explicit section names the stated conditions for loading browser vs functional vs mixed surfaces. Without the body text, an agent could follow the phase order but could not reliably make the surface-selection, fallback, or blocked-path decisions or produce evidence-backed deliverables, so actionability is limited."
+ }
+ ]
+ },
+ {
+ "judge": "qa/SKILL.md workflow",
+ "bundle": "defect",
+ "verdict": "pass",
+ "mean": {
+ "clarity": 3.3333333333333335,
+ "completeness": 4,
+ "actionability": 4
+ },
+ "samples": [
+ {
+ "clarity": 3,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The entry point is well-structured: a conditional section index, explicit STOP/Read gates with blocked-path fallbacks, parameter table, tier/mode rules, clean-tree gate, report-dir ownership rules, and a detailed fix loop (8a–8f) with a regression-before-repair value card, classification, self-regulation caps and commit discipline. Authority (report-only vs standalone /qa), isolation (scope.md ownership/production-forwarding rules), native contract map, browser/functional separation, health rubric and both report templates are all present, so an agent can produce the stated deliverables. Clarity suffers from the extremely dense, jargon-heavy exploratory.md (SECONDS/DEADLINE_FILE/PROBE_DIR/checkpoint publication rules compressed into nested caller-specific conditionals), the 'Phases 1-6' label that only maps to browser techniques while functional runs use a contract map, a missing Phase 10 (9 jumps to 11), a `{name}.regression-N.test.{ext}` naming rule that can conflict with native conventions (e.g., pytest), and several references to external skills/cookbooks (browse/SKILL.md, /devex-review, /setup-browser-cookies) not supplied. Decisions are executable but require careful parsing; some edge cases (mixed-run reporting, bounded browser wrapping of aside repl) are only lightly specified."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The workflow has a well-ordered spine (Setup → Learnings → Phases 1-6 baseline → Triage → Fix Loop 8a-8f → Final QA → TODOS → Learnings) with explicit STOP/Read gates, surface selection before setup, strict browser-vs-functional conditional loading, and clear authority (report-only vs standalone, caller-owned fixes) and isolation rules. Blocked-path handling is consistent (missing assets/tools block affected probes, continue safe checks, never substitute curl/tests). Native functional contracts are concrete via the contract map, EVIDENCE_TOOL/DEADLINE_TOOL commands, and the functional report template; browser work has scripts, $B fallback mapping, a scoring rubric, and templates. Deliverables (REPORT_DIR layout, report filenames, baseline.json, screenshots, exploration-NNN.json checkpoints, annotations.json, regression test naming/attribution, commit formats) are specified. Weaknesses: the prose is extremely dense and telegraphic (e.g., checkpoint field rules, SECONDS/mode resolution, 'QA_DEADLINE receipts are not observations'), raising misparse risk; Phase numbering skips Phase 10; some referenced assets (browse Cookbook, command-list, devex-review) are external; and tension between 'extend existing table' and the mandatory new regression-N file naming is only partly resolved. Overall executable but demands careful reading."
+ },
+ {
+ "clarity": 3,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The bundle is thorough: conditional Read directives state precise load conditions ('never for functional-only'), blocked-path handling is consistent (missing assets block affected probes, continue safe checks), scope.md defines surface selection and isolation crisply, system-functional.md gives a concrete contract map and outcome vocabulary, the Health Score Rubric and both report templates are fully specified, and Phase 8 (regression-before-repair, value card, commit, classify, self-regulation caps) is executable. Authority tiers (report-only vs standalone /qa, LOCAL vs non-local mutation consent) are explicit. Weaknesses hurt clarity more than completeness: the exploratory probe loop is dense and partially self-overlapping (a '(1)(2)(3)' summary followed by numbered steps 1-5 with browser/functional branches for checkpoint publication), heavy jargon (DEADLINE_TOOL/EVIDENCE_TOOL/checkpoints/superseded) requires careful parsing, qa-patterns.md and system-functional.md are loaded only indirectly via exploratory.md rather than a SKILL.md STOP, phase numbering jumps from 9 to 11 (no Phase 10), and 'Phases 1-6' is a browser-technique numbering reused as the baseline heading for functional runs. Minor gaps: mixed-run final-report merging and tool fallback behavior if gstack binaries are absent are thinly specified. Overall an agent can execute and produce the expected deliverables, but would need to read slowly to resolve the cross-references."
+ }
+ ]
+ },
+ {
+ "judge": "qa-only/SKILL.md workflow",
+ "bundle": "positive",
+ "verdict": "pass",
+ "mean": {
+ "clarity": 3.6666666666666665,
+ "completeness": 4.333333333333333,
+ "actionability": 4
+ },
+ "samples": [
+ {
+ "clarity": 4,
+ "completeness": 5,
+ "actionability": 4,
+ "reasoning": "The bundle is internally consistent across entrypoint and sections: timers (Quick 30s/Full 900s) match qa-patterns' 5-15 minute Full, REPORT_DIR/PROBE_DIR layouts agree between SKILL.md and exploratory.md, and the deferred-charter ordering is explicit. Decision points are thorough—conflicting --quick/--regression, non-empty output dirs, missing assets/baselines, unauthorized learning stores, and report-only authority are all resolved without gaps. Clarity suffers from extreme density and layered cross-skill path references (e.g., 'relative to the installed qa/gstack-qa SKILL.md directory... beside this host's installed caller skill'), and the 'accidental early Read' recovery rule is convoluted. Actionability is strong: exact DEADLINE_TOOL/EVIDENCE_TOOL commands, four-field checkpoint schema, and templates are specified, though the cognitive load of reconciling several sections mid-run is high."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The workflow is internally consistent despite dense cross-referencing: SKILL.md explicitly defers charters/clocks to Run the Selected Checks and tells exploratory §1 to reuse the REPORT_DIR resolved in Prepare Report Artifacts, so the apparent ordering tension resolves cleanly. Decision points are defined (conflicting --quick/--regression → ask; non-empty output dir → fresh run-UTC subdirectory; missing baseline blocks only that surface's regression). Tooling is concrete—exact DEADLINE_TOOL/EVIDENCE_TOOL syntax, four-field exploration-NNN.json schema, Aside/$B scripts, scoring rubric and templates. Weaknesses: heavy jargon and layered 'caller/owned/PROBE_DIR' definitions slow comprehension, and 'one probe plus checks' vs multi-capture scripts leaves slight room for batching ambiguity. Report-only boundaries are stated repeatedly and unambiguously."
+ },
+ {
+ "clarity": 3,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The workflow is internally consistent and decisions are mostly explicit (conflicting --quick/--regression → ask; missing baseline blocks only that surface; mixed order defaults functional→browser; charters Written before any clock). Deliverables are concrete: REPORT_FILE naming, run-dir collision rules, exploration-NNN.json's four fields, annotations/materialize before reporting.md, value cards, project-copy path. Clarity suffers from dense forward cross-references, e.g. 'Select Surfaces' tells the agent to defer charters to a later section whose §1 reuses REPORT_DIR resolved in a section after it, forcing reconstruction of execution order. Exploratory §4's materialize step is functional-centric ('browser-only: evidence []'), leaving browser-run handling implicit until SKILL.md's Output Structure. Browser Quick (30s) with mandatory per-probe checkpoint Writes is defined but practically tight."
+ }
+ ]
+ },
+ {
+ "judge": "qa-only/SKILL.md workflow",
+ "bundle": "negative",
+ "verdict": "fail",
+ "mean": {
+ "clarity": 2,
+ "completeness": 1.3333333333333333,
+ "actionability": 1
+ },
+ "samples": [
+ {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The bundle supplies only headings; every file's body (SKILL.md lines 382-596, exploratory.md, reporting.md, scope.md, browser-setup.md, qa-patterns.md, template) is absent, so no actual instructions, decision criteria, or outputs can be followed. Example: 'Select Surfaces and Isolation' and scope.md's 'Select the surface before setup' give no rule for choosing browser/native/mixed targets or isolation. Example: 'Additional Rules (qa-only specific)' and reporting.md name no concrete prohibitions on mutating product/tests/Git nor evidence or timing field definitions, and the report template lists section titles with no field contents. The outline structure is coherent, which earns minimal clarity credit, but an agent cannot execute the workflow or produce a checked report from this content."
+ },
+ {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The supplied bundle contains only headings; no instruction bodies, decision rules, or output contracts are present. For example, \"Select Surfaces and Isolation\" and scope.md's \"Select the surface before setup\" give no criteria for choosing browser vs native functional vs mixed targets, and the Health Score Rubric lists weights (Console 15%, Links 10%) without counting or scoring rules. The outline structure hints at a sensible flow (preparation gate, probe loop, evidence-based reporting, no-mutation rules), but an agent cannot determine what to run, what evidence to retain, or how to populate the report template. Nothing executable can be derived from headings alone."
+ },
+ {
+ "clarity": 2,
+ "completeness": 2,
+ "actionability": 1,
+ "reasoning": "The bundle supplies only headings; no instruction bodies survive, so every step, decision rule, and output contract is undefined. The outline is coherent (surface selection, isolation, conditional browser setup, evidence-based reporting, no-mutation rules) and the section index maps to real files, which earns minimal structural credit. But decisive gaps remain: 'Select Surfaces and Isolation' and scope.md give no criteria for choosing browser vs. native functional vs. mixed targets, and the report-only mutation prohibition exists only as a heading ('Additional Rules') with no enumerated forbidden actions or enforcement check. An agent cannot execute the probe loop, fill the functional report template fields, or verify coverage limits from this material; it would have to invent the workflow."
+ }
+ ]
+ },
+ {
+ "judge": "qa-only/SKILL.md workflow",
+ "bundle": "defect",
+ "verdict": "fail",
+ "mean": {
+ "clarity": 2,
+ "completeness": 4,
+ "actionability": 2.6666666666666665
+ },
+ "samples": [
+ {
+ "clarity": 2,
+ "completeness": 4,
+ "actionability": 3,
+ "reasoning": "The bundle is detailed and otherwise coherent, but the entrypoint opens with a decisive contradiction: SKILL.md's first instruction says 'After reporting each bug, fix it in the source and commit the fix,' immediately followed by 'NEVER fix anything or change product tests,' Additional Rule 1 and exploratory §3 forbidding any product/test/Git mutation. An agent cannot resolve this without guessing, and the wrong guess violates the workflow's core report-only guarantee. Elsewhere decisions are well-specified: mode conflicts trigger a user question, REPORT_DIR/REPORT_FILE resolution, mixed-run clock directories, checkpoint field contents, timing fields and the value-card bar are all defined, and the preparation gate, probe loop, finalization order and templates give a complete deliverable path. Minor friction: scope/method Reads live in the qa directory while the skill is qa-only, requiring path resolution."
+ },
+ {
+ "clarity": 2,
+ "completeness": 4,
+ "actionability": 2,
+ "reasoning": "The entrypoint's second line orders \"After reporting each bug, fix it in the source and commit the fix,\" directly contradicting the next bolded sentence (\"NEVER fix anything\"), Additional Rule 1, exploratory §3 and reporting's report-only repair fields. An agent cannot resolve which authority governs, and the wrong resolution violates the workflow's defining constraint. Otherwise the bundle is thorough: surface selection, mode handling, REPORT_DIR/REPORT_FILE derivation, charter-before-probe ordering, deadline/evidence tools, checkpoint schema, templates, value cards and finalization checks are well specified, with explicit blocker handling. Minor friction remains (the Browser Setup Read points at the qa directory while sibling Reads use qa-only), but the fix-and-commit contradiction is the decisive defect for clarity and actionability."
+ },
+ {
+ "clarity": 2,
+ "completeness": 4,
+ "actionability": 3,
+ "reasoning": "The entrypoint's first instruction (\"After reporting each bug, fix it in the source and commit the fix\") directly contradicts the bolded report-only mandate, exploratory §3 and Additional Rule 1, leaving an agent to guess which governs the core safety boundary. Otherwise the bundle is thorough: surface selection, mode/baseline resolution, REPORT_DIR/REPORT_FILE rules, deadline/evidence tooling, checkpoint schema, health rubric and finalization checks are all specified. A second ambiguity: SKILL.md tells functional-only runs to skip Browser Setup, yet 'Run the Selected Checks' says to read TODOS.md 'before browser probes' without clarifying functional preflight ordering. Deliverables are achievable, but the opening contradiction risks a prohibited commit."
+ }
+ ]
+ },
+ {
+ "judge": "review/SKILL.md workflow",
+ "bundle": "positive",
+ "verdict": "pass",
+ "mean": {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4
+ },
+ "samples": [
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The skeleton is dense but each path resolves: Step 4 reads QA scope/methods read-only, Step 4.7 runs bounded smoke even for small diffs without plan/server, test_stub forces ASK (Step 5a), incomplete/blocked probes keep COMPLETED=false, and Step 5.8 reruns affected probes with a new REVIEW_START. Two rough edges keep it from 5: exploratory.md says `materialize` runs once per PROBE_DIR while Step 4.7/5.8 reuse REPORT_DIR and require revalidation captures after repairs, leaving where rerun evidence is materialized undefined; Step 3.4 gives no handling when BRANCH_VERSION or NEXT_SLOT is empty before the version comparison. Outputs (report, log record, QA section) are otherwise well specified."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The skeleton plus on-demand sections give a determinable next action on nearly every path: QA Reads resolved from the installed QA directory, smoke bounded via DEADLINE_FILE while plan checks/revalidation use --timeout-ms, test_stub findings forced to ASK, incomplete/blocked probes mapped to COMPLETED=false, and repeat passes rerunning only affected probes. Minor tensions remain: SKILL.md Step 4.7 step 3 says 'post-expiry smoke rechecks are not-run' while step 4c and Step 5.8 require rerunning affected probes after fixes, leaving which rule governs an expired-smoke revalidation to inference; Step 3 defines a pass as 'before any fixes' while Step 5.8 says a pass covers Steps 3–5. Outputs, records and gates are otherwise well specified and executable."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The decision tree is dense but each path resolves: Step 4 fixes the QA Read order and installation fallback, Step 4.7 mandates smoke probes even for small diffs without plan/server, test_stub findings are forced to ASK with regression-before-repair in 5d, incomplete/blocked/not-run probes propagate to COMPLETED=false, and 5.8 defines reruns of affected probes with new REVIEW_START tokens. Residual ambiguity: Step 4.7.3 says 'post-expiry smoke rechecks are not-run' while also requiring post-expiry 'revalidation' and 5.8 requires rerunning affected smoke probes after fixes—the agent must infer that fix-triggered reruns count as revalidation, not rechecks. Step 4's 'research runs alongside specialist dispatch' also slightly muddles ordering with Step 4.5. Outputs, records and tool invocations are otherwise well specified."
+ }
+ ]
+ },
+ {
+ "judge": "review/SKILL.md workflow",
+ "bundle": "negative",
+ "verdict": "fail",
+ "mean": {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1
+ },
+ "samples": [
+ {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The bundle contains only section headings; no instruction bodies survive, so every step requires guessing. Example: review/SKILL.md 'Step 4.7: Exploratory QA (before Fix-First)' and qa/sections/exploratory.md '2. Probe loop'/'3. Parent handoff' define no charter, probe bound, incomplete-probe handling, or handoff format, so the agent cannot know what to run or report. Example: SKILL.md Step 5c/5d and '1. Re-review after edits' give no test_stub ASK gate rule or which evidence to rerun after approved repairs; review/sections/shared-code-reuse.md is entirely empty. Ordering is also inconsistent (Step 0 precedes the title; Steps 4.5/4.8 live in sections with no sequencing guidance). No deliverable schema is present beyond template headings."
+ },
+ {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The bundle contains only headings; no step bodies, decisions, or outputs survive, so the agent must guess every action. Example 1: review/SKILL.md 'Step 4.7: Exploratory QA (before Fix-First)' and qa/sections/exploratory.md '2. Probe loop'/'3. Parent handoff' define nothing about running without a plan or server, what counts as an incomplete probe, or what the parent receives — the report-only discovery rule and test_stub ASK gate appear nowhere. Example 2: 'Step 5.8: Persist Eng Review result' → '1. Re-review after edits' has no instruction to rerun affected QA evidence after approved repairs, and review/sections/shared-code-reuse.md is empty despite being indexed. The skeleton is navigable, but nothing is executable."
+ },
+ {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The bundle contains only headings; no step bodies, decisions, or outputs are present, so execution requires guessing everywhere. Example 1: review/SKILL.md places 'Step 0: Detect platform and base branch' before the title and section index, and Step 4.7 Exploratory QA, Step 4.5/4.6 Review Army, and Step 4.8 Adversarial sit across files with no visible Read triggers or ordering rule, leaving the agent unsure which pass runs when. Example 2: the stated test_stub ASK gate, report-only discovery rule, incomplete-probe honesty handling, and 'Re-review after edits' rerun-evidence requirement are named in headings only (Step 5c, Step 5.8 §1, exploratory.md §2) with no defined conditions or deliverables. shared-code-reuse.md is empty."
+ }
+ ]
+ },
+ {
+ "judge": "review/SKILL.md workflow",
+ "bundle": "defect",
+ "verdict": "pass",
+ "mean": {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4
+ },
+ "samples": [
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "Paths are explicit and cross-referenced: small diffs still run Step 4.7 smoke (review-army.md small-diff branch), test_stub forces ASK (Step 5a), incomplete probes map to blocked/not-run and COMPLETED:false (Step 5.8), and reruns after approved repairs are defined (Step 5.8.2, Step 4.7.4). Defects are minor: Step 5.0 says save actions in the 'Step 3 action list' but no Step 3 exists (only 3.4–3.6; elsewhere called 'invocation action list'), and Step 5.8 repeats 'Steps 3–5' similarly. Also 'not applicable' QA outcomes (exploratory.md §4) aren't mapped onto Step 5.8's COMPLETED rule, leaving nonbehavioral-diff completion slightly underspecified. Otherwise executable without guessing."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "Dense but coherent decision tree: paths for small diffs, missing plan/server, blocked probes, test_stub ASK gates, rerun-after-repair and nonconvergence are each explicitly resolved, and section Reads are ordered. Minor defects: SKILL.md Step 5.0 'Keep decisions through fix cycles' tells the agent to save actions in 'the Step 3 action list', which is never defined (elsewhere called the 'invocation action list'); and Step 5.8/shared-code-reuse depend on a captured REVIEW_START token whose creation never appears in the supplied steps, only `--finish REVIEW_START`. Step 4's 'Research runs alongside specialist dispatch' also blurs read-only Step 4 with Step 4.5 timing. None force guessing on a permitted action, but they cost locating effort."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The decision tree is dense but internally consistent: Step 4 reads scope/methods read-only, Step 4.7 runs bounded smoke plus required plan checks (5 min/12 probes, no DEADLINE reset, post-expiry rechecks not-run), QA defects return with test_stub forced to ASK (Step 5a), repairs require red-then-green regression plus original/adjacent probes (5d), and Step 5.8 reruns affected probes and marks COMPLETED false on any blocked/not-run required probe. Minor defects: Step 5.0 'Keep decisions' tells the agent to save actions in a 'Step 3 action list' though no Step 3 exists (only 3.4–3.6), and Step 5.8 says 'a pass covers Steps 3–5' with similar numbering drift; REVIEW_START capture is assumed from the preamble. Neither forces a wrong action, but they cost lookup effort."
+ }
+ ]
+ },
+ {
+ "judge": "setup-deploy/SKILL.md platform setup",
+ "bundle": "positive",
+ "verdict": "pass",
+ "mean": {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4
+ },
+ "samples": [
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "Steps 2–6 are concrete: detection script, per-platform branches, a field-collection rule before writing, a fixed CLAUDE.md template with explicit duplication rule, and non-blocking verification. Multi-platform detection and non-deploying projects are handled. Gaps: Fly computes the health check from the inferred `{app}.fly.dev` URL before asking the user to confirm a possibly custom domain, with no instruction to recompute, so the written health check can target the wrong host. Step 3 routes Heroku/Railway to the Custom questions (which ask the deploy trigger), yet Step 4 says Heroku/Railway leave the trigger unset, creating redundant or contradictory asks. Render checks RENDER_API_KEY but never uses the result, and the summary omits project type and hooks."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "Steps 2–6 form a coherent, executable pipeline: detection script, per-platform branches, explicit confirmation gating, a fully specified CLAUDE.md template with dual-field rules, verification, and summary. Decision points (multiple platforms, non-deploying projects, merge-method allowed via gh) are handled. Two gaps: Step 4 claims Heroku/Railway leave the deploy trigger unset, yet their branch routes through the Custom questions that explicitly collect the trigger—a small contradiction about what counts as confirmed. Vercel's 'production URL from vercel project settings' gives no command to obtain it, so the agent must fall back to asking without clear guidance. Otherwise unambiguous and deliverable-ready."
+ },
+ {
+ "clarity": 4,
+ "completeness": 4,
+ "actionability": 4,
+ "reasoning": "The supplied Steps 2-6 form a coherent, executable flow: a concrete detection script, per-platform sub-procedures with explicit confirmation gates, a CLAUDE.md template with a consistency rule for duplicated fields, non-blocking verification, and a summary. Multi-platform and project-type disambiguation are handled. Gaps remain: Step 4's field list omits `Deploy workflow`, so when fly.toml coexists with a GitHub Actions deploy workflow (or several workflows match), it is undefined which file is recorded or how to choose among them. Step 4 also says to ask for any missing status command while the template offers an 'HTTP health check' fallback, leaving Vercel/Netlify agents unsure whether to prompt or default. These are minor ambiguities in an otherwise well-specified workflow."
+ }
+ ]
+ },
+ {
+ "judge": "setup-deploy/SKILL.md platform setup",
+ "bundle": "negative",
+ "verdict": "fail",
+ "mean": {
+ "clarity": 1.3333333333333333,
+ "completeness": 1,
+ "actionability": 1
+ },
+ "samples": [
+ {
+ "clarity": 2,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The supplied workflow is only a skeleton of headings with no instructions beneath them. Step 2 'Detect platform' lists 'Platform config files' and 'GitHub Actions deploy workflows' but gives no file names, glob patterns, or precedence rules when multiple platforms are found. Step 4 'Write configuration' names a CLAUDE.md section header but specifies no fields (URLs, status commands, health checks) or format to persist. Step 1 is absent, and Steps 5-6 have no verification commands or summary content. The outline is readable, but an agent cannot detect a platform, gather config, or produce a CLAUDE.md deliverable from headings alone."
+ },
+ {
+ "clarity": 1,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The supplied workflow consists solely of section headings with no instructional body. For example, 'Step 2: Detect platform' lists 'Platform config files' and 'GitHub Actions deploy workflows' but gives no file names, glob patterns, or detection logic to distinguish Fly.io from Render or Vercel. Likewise 'Step 4: Write configuration' names a CLAUDE.md heading and 'Custom deploy hooks' but specifies no fields (URLs, status commands, health checks) or format to persist. Step 1 is absent entirely, and Steps 5–6 have no verification criteria or summary content. An agent cannot execute any step or produce the expected CLAUDE.md deliverable from this outline alone."
+ },
+ {
+ "clarity": 1,
+ "completeness": 1,
+ "actionability": 1,
+ "reasoning": "The supplied entrypoint is only a heading skeleton with no instruction body. For example, '### Step 2: Detect platform' lists '# Platform config files' and '# GitHub Actions deploy workflows' but gives no file names (fly.toml, render.yaml, vercel.json) or detection logic, so an agent cannot determine which platform applies. Likewise '### Step 4: Write configuration' names a '## Deploy Configuration' section and 'Custom deploy hooks' but provides no schema, fields, or prompts for URLs, status commands, or health checks. Step 1 is absent entirely, and Steps 5-6 have no verification criteria or summary format. Nothing here is executable; the agent would have to invent the whole workflow."
+ }
+ ]
+ },
+ {
+ "judge": "setup-deploy/SKILL.md platform setup",
+ "bundle": "defect",
+ "verdict": "fail",
+ "mean": {
+ "clarity": 4,
+ "completeness": 3,
+ "actionability": 3.3333333333333335
+ },
+ "samples": [
+ {
+ "clarity": 4,
+ "completeness": 3,
+ "actionability": 4,
+ "reasoning": "Platform sections are concrete and executable (Fly extraction/health-probe logic, Render key check that avoids leaking bytes), and Step 4 explicitly resolves which fields each path leaves unset, lists allowed merge methods via gh, and defines the no-deploy case. Two gaps weaken it: Step 2 says 'Run the platform detection from the deploy bootstrap:' followed by an empty block, so the agent has no command or expected output format to branch on in Step 3. Also Step 4 claims Heroku/Railway leave the deploy trigger unset, yet that path routes through the Custom/Manual questions which ask for the trigger; and the template defaults differ ('HTTP health check' vs 'poll production URL') despite the instruction that both fields hold identical values."
+ },
+ {
+ "clarity": 4,
+ "completeness": 3,
+ "actionability": 3,
+ "reasoning": "Platform-specific branches are precise (exact grep/sed, curl, and gh commands; explicit confirmation rules; well-defined CLAUDE.md template with duplicated hook fields explained). But Step 2 is hollow: 'Run the platform detection from the deploy bootstrap:' is followed by nothing, so the agent has no command or file reference to actually detect Fly/Render/Vercel/etc., undermining every downstream branch. Additionally, the Vercel path tells the agent to use 'the production URL from vercel project settings' without a command to retrieve it, and Heroku/Railway detection is routed to a section gated on 'If nothing detected', a minor ordering contradiction. Steps 4–6 are otherwise executable and produce the deliverable."
+ },
+ {
+ "clarity": 4,
+ "completeness": 3,
+ "actionability": 3,
+ "reasoning": "Platform sections, the CLAUDE.md template, and verify/summary steps are concrete and mostly unambiguous, with explicit handling for multi-platform detection, non-deploying projects, and secret-safe key checks. The decisive gap is Step 2: 'Run the platform detection from the deploy bootstrap:' is followed by nothing, so the agent has no command or criteria for how fly.toml/render.yaml/workflows are actually detected, undermining everything downstream. Secondary looseness: Vercel's health check is 'the production URL from vercel project settings' with no command given, and Render sets the health check to an inferred URL before user confirmation. The workflow is executable once detection is supplied, but as written it depends on an undefined step."
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/evals/judge-low-samples-2026-10.md b/docs/evals/judge-low-samples-2026-10.md
new file mode 100644
index 000000000..cf78cdc63
--- /dev/null
+++ b/docs/evals/judge-low-samples-2026-10.md
@@ -0,0 +1,66 @@
+# Workflow-judge low samples, October 2026 (C2)
+
+Source: the `skill-llm-eval` shards of the eight wave censuses (37174266054 …
+37198445662), 24 judges each. A panel mean below 4 on a dimension means at least one
+of its three samples scored below 4. Workflow judges gate on actionability ≥ 4
+(clarity and completeness ≥ 3), so a single actionability 3 fails the panel.
+
+## Panels with a low dimension (count of 8 censuses)
+
+| Judge | Low dimension(s) | Censuses | Panel failed |
+|---|---|---|---|
+| setup block | completeness, actionability | 8/8 | never |
+| qa health rubric | completeness | 8/8 | never |
+| qa anti-refusal | `would_browse` reported as 1 (boolean field, not a low score) | 8/8 | never |
+| qa workflow | clarity, actionability | 8/8 | 37186854666 |
+| benchmark perf collection | completeness | 8/8 | never |
+| plan-eng-review sections | clarity | 6/8 | never |
+| canary monitoring loop | completeness | 6/8 | never |
+| sync-gbrain read-only readiness | completeness | 6/8 | never |
+| plan-ceo-review modes | clarity | 5/8 | never |
+| qa-only workflow | clarity, actionability | 5/8 | 37198445662 |
+| design-review fix loop | completeness | 4/8 | never |
+| review workflow | clarity, actionability | 4/8 | 37193478719 |
+| ship workflow | clarity | 3/8 | never |
+| setup-deploy platform setup | actionability, completeness | 2/8 | 37176837432 |
+| plan-design-review passes | completeness | 1/8 | never |
+
+## Failing panels: each low rationale and its disposition
+
+| Panel | Rationale (abridged) | Disposition |
+|---|---|---|
+| setup-deploy, 37176837432 | `[ -f vercel.json ] \|\| [ -d .vercel ] && echo` precedence bug; default branch never resolved; Fly deploy trigger undefined | Fixed on main before this wave (braced detection, `gh repo view --json defaultBranchRef`); the precedence claim was also wrong for that form |
+| qa workflow, 37186854666 | undefined `{user}` in the outcome filename | **Fixed (C2):** `/qa` names the sources of `{user}`, `{branch}`, `{datetime}` |
+| qa workflow, 37186854666 | `PROBE_DIR` "chosen above" only implied; EARLIER_UTC format unspecified; `/devex-review` path never given | Requested from the resolver owner (`scripts/resolvers/qa.ts`) |
+| qa workflow, 37186854666 | prose density, two "Phases 1-6" numbering schemes | Density only; recorded |
+| review workflow, 37193478719 | `unverified` Codex mode had competing run/skip rules | Fixed on main before this wave (`ready` and `unverified` run them) |
+| review workflow, 37193478719 | "Post-expiry smoke rechecks are not-run" vs rerunning affected probes after fixes | Requested from the resolver owner (`scripts/resolvers/qa.ts`) |
+| review workflow, 37193478719 | GATE: MISSING COVERAGE says "preserve the existing user decision flow", which is undefined | Requested from the resolver owner (`scripts/resolvers/outside-voice-steps.ts`) |
+| review workflow, 37193478719 | plan-completion HIGH-impact gate has no spawned/non-interactive default | Requested from the resolver owner (plan-completion resolver) |
+| qa-only workflow, 37198445662 | report templates ask for "Duration / durationMs totals" while `reporting.md` defines Probe budget and Guarded command time | **Fixed (C2):** both QA report templates name the defined fields |
+| qa-only workflow, 37198445662 and the C2 panel | Browser Quick allots 30 s (`SECONDS=30`) for homepage plus five pages with a checkpoint Write per probe | Requested from the resolver owner (`scripts/resolvers/qa.ts:76`); recurs in two of the last three qa-only panels |
+| qa-only workflow, 37198445662 | exploratory §2 "write the report, not a checkpoint" vs annotations/materialize first; "JSON keeps full values" names no JSON | Requested from the resolver owners (`qa.ts`, `test-value.ts`) |
+
+Judges whose low samples never failed a panel carry density or out-of-bundle
+complaints (for example completeness 3 on the qa health rubric, which is judged
+without the workflow it serves). They are recorded, not chased.
+
+## Panels after the C2 fixes (2026-10-04, one 3-sample panel per changed judge)
+
+| Judge | Mean (clarity / completeness / actionability) | Verdict |
+|---|---|---|
+| qa workflow | 3.67 / 4.67 / 4.00 | pass |
+| qa health rubric | 4.00 / 3.00 / 4.00 | pass |
+| qa anti-refusal | would_browse true, confidence 5 | pass |
+| qa-only workflow | 3.67 / 4.33 / 3.67 | **fail**: one sample cites the 30 s Browser Quick budget (resolver request above) |
+| review workflow | 4.00 / 4.00 / 4.00 | pass |
+| ship workflow | 4.00 / 4.00 / 4.00 | pass |
+| plan-ceo-review modes | 3.67 / 4.67 / 4.00 | pass |
+| cross-skill greptile consistency | 4 | pass |
+| voice directive tone | all ≥ 4.33 | pass |
+
+The timing-field and `{user}` rationales no longer appear in these panels. After the
+ship and review wording was tightened to fit their parity size caps, the judges those
+files select ran once more on the final wording: review workflow 4.00 / 4.00 / 4.00,
+ship workflow 4.00 / 4.00 / 4.00, cross-skill consistency 4, voice directive tone ≥ 4.00
+(all pass).
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 73e67c7bc..9339d6e5a 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -242,6 +242,35 @@ contains no parsable version. /ship stops instead of inventing `0.0.0.0`.
**Expected result.** `gstack-version-bump classify` prints the current version.
+
+### `BASE_REFRESH: stale ` / `Base coverage: stale at `
+
+**Meaning.** /review could not fetch the base branch (offline, no credentials,
+or a read-only `.git` such as Codex's sandbox), so it reviewed against your
+local `origin/ ` at ``.
+
+**Fix.** Run `git fetch origin ` outside the sandbox and rerun /review if
+the base has moved.
+
+
+### `gstack-wtree: cannot create a temp index under ; set TMPDIR to a writable directory` / `gstack-review-log: cannot create a private object directory under ; no working-tree fingerprint. Set TMPDIR to a writable directory.`
+
+**Meaning.** /review fingerprints the working tree in a private temporary
+object directory (Codex's sandbox keeps `.git` read-only). The temp directory
+could not be created, so this review has no fingerprint and later steps cannot
+reuse its evidence.
+
+**Fix.** `export TMPDIR=`, then run /review again.
+
+
+### `gstack-wtree: cannot stage the working tree: `
+
+**Meaning.** The fingerprint's objects could not be written. Standalone
+`gstack-wtree` writes into the repository's object store.
+
+**Fix.** Run it through /review (`gstack-review-log` supplies a private object
+directory), or make `.git/objects` writable.
+
### `Plan completion audit: not run (no plan is bound to this branch and no docs/designs/ file matches). Fix: ...`
@@ -329,6 +358,15 @@ If the message names a missing timeout tool, install coreutils (`gtimeout`) or p
**Expected result.** The next design skill prints `DESIGN_READY`.
+
+### `GSTACK_DESIGN_IMAGE_MODEL="" is not a gpt-image model name (expected something like gpt-image-2); fix it or unset it to use gpt-image-2`
+
+**Meaning.** The design binary refuses an image tool model override that is not
+a `gpt-image-*` model name, before sending any request.
+
+**Fix.** `export GSTACK_DESIGN_IMAGE_MODEL=gpt-image-2` (or another gpt-image
+model your key can use), or `unset GSTACK_DESIGN_IMAGE_MODEL`.
+
### `TASTE_PROFILE_UNAVAILABLE: could not resolve the project slug (gstack-slug failed). Fix: run ./setup.`
@@ -412,6 +450,37 @@ stopped instead of touching your project.
## Setup and auto-update
+
+### `gstack needs Bun 1.3.3 or newer (1.4.2 recommended); found at . Nothing was installed or changed.`
+
+**Meaning.** Bun older than 1.3.3 silently ignores the build flags that stop
+gstack's compiled tools from reading a project's `.env`, so setup refuses it
+before writing anything. The same link appears on two warnings that do not stop
+setup: `warning: gstack is tested on Bun 1.4.2 (CI pin); found ` (1.3.3
+up to 1.4.2 works but is untested) and `warning: could not read the Bun version
+(...)` (setup continued; check `bun --version`).
+
+**Fix.**
+
+```bash
+bun upgrade
+./setup
+```
+
+**Expected result.** `bun --version` prints 1.4.2 or newer and setup finishes
+with no Bun warning.
+
+
+### `gstack auto-update: update held (bun-too-old: found Bun at ; gstack needs or newer); nothing was installed or changed. Fix now: ...`
+
+**Meaning.** Team-mode auto-update fetched a release whose setup needs a newer
+Bun than the one setup would run. It left your checkout and installed skills at
+the current revision and prints this line once per session start. (An older
+auto-updater running its first update cannot make this check.)
+
+**Fix.** `bun upgrade`; the next session start resumes the update. To update
+now: `bun upgrade && cd && git pull --ff-only && ./setup`.
+
### `gstack auto-update: setup did not finish (); installed skills may be out of date. gstack retries automatically. Fix now: cd && ./setup`
@@ -522,6 +591,18 @@ after `--`).
**Fix.** `cd && ./setup` (or `/unfreeze`).
+
+### `[browse] Could not record the Chromium PID (CDP SystemInfo.getProcessInfo: ); browse stop cannot reap a surviving Chromium.`
+
+**Meaning.** For a headless Chromium it launched, browse reads the browser's
+process id over CDP so `browse stop` can kill a browser that outlives the
+server. That read failed or took longer than 2 seconds, so this session's
+browser is not recorded. Browsers gstack did not launch are never recorded or
+killed.
+
+**Fix.** After `browse stop`, check for a leftover browser with
+`ps aux | grep -i chrom` and end it with `kill `.
+
---
## Memory and gbrain
@@ -597,6 +678,105 @@ gstack-gbrain-sync --prune-gone-worktrees
---
+## Eval reports and pass-rates
+
+These come from the paid eval census reports and `bun run eval:pass-rates`.
+The walkthrough for a red census is [docs/evals/census-red.md](evals/census-red.md).
+
+
+### `eval:pass-rates: unknown flag ` / `eval:pass-rates: unknown case (not an E2E, judge or paid test file id)`
+
+**Meaning.** The flag or case id is not recognized. The command exits 2 before
+fetching anything.
+
+**Fix.** `bun run eval:pass-rates --help` lists the flags. `--case` takes a
+registry id from `test/helpers/touchfiles-data.ts` or a paid test file path.
+
+
+### `unknown failure_cause "" (written by a newer gstack; update this checkout to read it)`
+
+**Meaning.** A trial record names a failure cause this checkout does not know.
+A newer gstack wrote it.
+
+**Fix.** `git pull` (or rebase your branch onto `main`), then rerun the command.
+
+
+### `--case : registered by , ; it needs exactly one`
+
+**Meaning.** More than one paid test file registers the case, so
+`test-paid-shards.ts --case` cannot pick one file.
+
+**Fix.** Run the red shard's file directly; the report's `after a repair:` line
+prints it: `EVALS=1 EVALS_TIER= bun test `.
+
+
+### `evidence: too large to fetch ( MB)`
+
+**Meaning.** The slice artifact holding this red's transcript is over 64 MB, so
+`eval:pass-rates --run` does not download it.
+
+**Fix.** Download it from the run page, or
+`gh api repos///actions/artifacts//zip > slice.zip`.
+
+
+### `[headroom] session : max of () over sample(s), above the 85% cap. Cut work in the skill or fixture; budgets are never raised ...`
+
+**Meaning.** `eval:pass-rates --gate` (the weekly report) found a case whose
+slowest session used more than 85% of the timeout it armed. It is one timeout
+away from a red.
+
+**Fix.** Cut work in the skill or fixture. Budgets are never raised. Check the
+case with `bun run eval:pass-rates --headroom --case `.
+
+
+### `cost unknown (no billing captured for any of trial(s))` / `cost $ known + of trial(s) cost unknown`
+
+**Meaning.** PTY and Codex sessions record no billing, so their cost is
+unknown. Only the known sum is shown.
+
+**Fix.** None needed; this is informational.
+
+
+### `· cause provider_stall: no stream event for s ...`
+
+**Meaning.** The session streamed partial messages, then received no event of
+any kind for at least 120 seconds while a model request was in flight and no
+tool, permission prompt, hook or subagent was outstanding. Under EVAL_POLICY v1
+it is still a failed trial.
+
+**Fix.** Inspect it with `bun run eval:pass-rates --run --case `.
+A paid rerun happens only after a repair.
+
+
+### Overlay record with `taskCorrect: false` and `answerError` (contract v4)
+
+**Meaning.** The trial completed with a wrong answer. Under overlay contract v4
+that is a valid measurement in the comparison; only an overlay-ON wrong answer
+fails the case.
+
+**Fix.** Read `answerError` in the trial JSON. No rerun is needed.
+
+
+### `[detector-corpora] stale entries (refresh from a current census when one fails)`
+
+**Meaning.** Some replay-corpus entries came from an older input series of
+their case. This is informational; the corpus still replays them.
+
+**Fix.** When that case goes red in a census, add the new capture to its corpus
+and check it with `bun test test/detector-corpus-.test.ts`.
+
+
+### `recommendation substance median < 4 over samples [,,] (boilerplate/weak)`
+
+**Meaning.** The auq-matrix case scores each captured question's recommendation
+with a 3-sample judge panel; fewer than 2 of the 3 samples reached 4, so their
+median was below 4.
+
+**Fix.** Read the logged samples and the captured question. A fix goes in the
+skill text, followed by one diagnostic run of auq-matrix.
+
+---
+
## Push guard (redaction)
diff --git a/freeze/SKILL.md.tmpl b/freeze/SKILL.md.tmpl
index b86711c62..efd888c25 100644
--- a/freeze/SKILL.md.tmpl
+++ b/freeze/SKILL.md.tmpl
@@ -52,7 +52,7 @@ Once the user provides a directory path:
Set the user-selected boundary with the shared state writer. It resolves the physical absolute path and serializes replacement with investigation cleanup:
```bash
-bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" set ""
+bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set ""
```
Only report success if the helper succeeds. On `FREEZE_BUSY` or unexpected state, preserve it and ask the user to inspect recovery after any active writer finishes; never write or delete the state file directly.
diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md
index 629518238..133d88be4 100644
--- a/gstack-upgrade/SKILL.md
+++ b/gstack-upgrade/SKILL.md
@@ -146,6 +146,8 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p
# re-renders to ~/.gstack/render), so discarding is lossless.
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
git fetch origin
+# Same pre-advance check as the auto-updater: the incoming release's Bun floor.
+HOLD=$(. bin/gstack-bun-version.sh 2>/dev/null && gstack_bun_incoming_hold . origin/main) && { echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2; exit 1; }
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
if git pull --ff-only --autostash origin main; then
@@ -164,6 +166,7 @@ belongs to another checkout or project; pass on its command, do not run it.
If the output ends with `FF_OK`, the upgrade is done — skip the fallback
below entirely.
+On `BUN_TOO_OLD`, STOP: tell the user to run `bun upgrade`, then /gstack-upgrade again.
On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED`, after inspecting the pull error; network/auth failures stop for repair, not reset.
**Fallback (ff-only refused — local commits or divergence).** `git reset
diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl
index 296ce14cf..b7c80d17f 100644
--- a/gstack-upgrade/SKILL.md.tmpl
+++ b/gstack-upgrade/SKILL.md.tmpl
@@ -143,6 +143,8 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p
# re-renders to ~/.gstack/render), so discarding is lossless.
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
git fetch origin
+# Same pre-advance check as the auto-updater: the incoming release's Bun floor.
+HOLD=$(. bin/gstack-bun-version.sh 2>/dev/null && gstack_bun_incoming_hold . origin/main) && { echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2; exit 1; }
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
if git pull --ff-only --autostash origin main; then
@@ -161,6 +163,7 @@ belongs to another checkout or project; pass on its command, do not run it.
If the output ends with `FF_OK`, the upgrade is done — skip the fallback
below entirely.
+On `BUN_TOO_OLD`, STOP: tell the user to run `bun upgrade`, then /gstack-upgrade again.
On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED`, after inspecting the pull error; network/auth failures stop for repair, not reset.
**Fallback (ff-only refused — local commits or divergence).** `git reset
diff --git a/guard/SKILL.md.tmpl b/guard/SKILL.md.tmpl
index f2f194920..206a38077 100644
--- a/guard/SKILL.md.tmpl
+++ b/guard/SKILL.md.tmpl
@@ -61,7 +61,7 @@ Once the user provides a directory path:
Set the user-selected boundary with the shared writer, which resolves a physical absolute path and serializes replacement with investigation cleanup:
```bash
-bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" set ""
+bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set ""
```
On helper failure, do not claim the boundary is active. Preserve the state and report recovery; never bypass the shared writer with a direct write or deletion.
diff --git a/hosts/codex.ts b/hosts/codex.ts
index 0f2594626..54edf1f16 100644
--- a/hosts/codex.ts
+++ b/hosts/codex.ts
@@ -42,7 +42,7 @@ const codex = defineHost({
// Mirrors create_codex_runtime_root in setup. design/dist and make-pdf/dist
// back $GSTACK_DESIGN and $GSTACK_MAKE_PDF (#2891).
runtimeRoot: {
- globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
+ globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
},
diff --git a/hosts/define-host.ts b/hosts/define-host.ts
index a90010e37..a4388a973 100644
--- a/hosts/define-host.ts
+++ b/hosts/define-host.ts
@@ -126,7 +126,7 @@ export function defineHost(overrides: HostOverrides):
toolRewrites,
suppressedResolvers = [...GBRAIN_RESOLVERS],
runtimeRoot = {
- globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
+ globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'TODOS-format.md'],
},
diff --git a/hosts/opencode.ts b/hosts/opencode.ts
index 038267a0f..63980a28c 100644
--- a/hosts/opencode.ts
+++ b/hosts/opencode.ts
@@ -14,7 +14,7 @@ const opencode = defineHost({
// OpenCode links a wider runtime asset set than the shared default
// (design binary, review specialists, qa templates/references, DX hall of fame).
runtimeRoot: {
- globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
+ globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
globalFiles: {
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
},
diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl
index f29a1ed9e..e172cad9b 100644
--- a/investigate/SKILL.md.tmpl
+++ b/investigate/SKILL.md.tmpl
@@ -121,14 +121,14 @@ After forming your root cause hypothesis, lock edits to the affected module to p
```bash
# $HOME-anchored like the careful/freeze frontmatter hooks: early skill bash runs
# before CLAUDE_SKILL_DIR exists, so a relative path would never resolve.
-_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"
+_FREEZE_SCRIPT="{{RUNTIME_ROOT}}/freeze/bin/check-freeze.sh"
[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"
```
**If FREEZE_AVAILABLE:** Identify the narrowest directory containing the affected files. Acquire a run-owned boundary; the helper resolves its physical absolute path and leaves any pre-existing user or other-run boundary untouched:
```bash
-bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" acquire ""
+bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" acquire ""
```
Substitute `` with the actual path (e.g., `src/auth/`). Retain the exact returned `FREEZE_OWNER` token in this run's context (including any checkpoint); never reconstruct it from the current state file. Only a returned token means this run owns a new lock. `FREEZE_PRESERVED` means keep the existing boundary and do not clean it up. On acquisition error, pause before edits and report it; never claim a lock was acquired. Relative legacy state is ambiguous: ask the user to re-establish an absolute boundary via `/freeze`, rather than guessing its original cwd.
@@ -138,7 +138,7 @@ Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write r
**Terminal cleanup:** On completion, explicit abort, or any known error that ends this investigation, run the following with this run's retained token, before the final response. Skip it when this run acquired no token:
```bash
-bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" release ""
+bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" release ""
```
The helper compares ownership and removes state under the same mutation lock used by `/freeze`, `/guard`, and `/unfreeze`; a replacement boundary is preserved, even at the same path. Report cleanup errors or `FREEZE_PRESERVED`, never retry by deleting state directly. A hard-killed session cannot run this cleanup: recovery is explicit `/unfreeze` (user-requested removal) or `/freeze` (user-selected replacement). If a mutation lock was abandoned, inspect it with the user after confirming no writer is active; never automatically delete an ambiguous lock.
diff --git a/lib/cso/process.ts b/lib/cso/process.ts
index 15d4d40f3..9bd170312 100644
--- a/lib/cso/process.ts
+++ b/lib/cso/process.ts
@@ -225,10 +225,13 @@ function safeMetadata(value: string, key: string): boolean {
return true;
return false;
}
+// Run ids are public CI metadata to the shared redactor; here only the validated
+// workflow slot may carry one.
+const RUN_ID_SEGMENT = /(\/actions\/runs\/)[0-9]+/g;
function sanitizeJson(value: unknown, key: string, seen: WeakSet, trustedMetadata: boolean): unknown {
if (typeof value === 'string') {
if (trustedMetadata && safeMetadata(value, key)) return value;
- return redact(value);
+ return redact(value).replace(RUN_ID_SEGMENT, '$1');
}
if (value === null || typeof value !== 'object') return value;
if (seen.has(value as object)) throw new CsoError('INVALID_SCHEMA', 'Cyclic JSON cannot be persisted');
diff --git a/lib/office-hours-review.ts b/lib/office-hours-review.ts
index 4d201a33c..c9a7e3f41 100644
--- a/lib/office-hours-review.ts
+++ b/lib/office-hours-review.ts
@@ -1,12 +1,17 @@
/** Office-hours review artifacts are the verdict; prose is rendered from them. */
+import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
export const OFFICE_HOURS_DIMENSIONS = ['completeness', 'consistency', 'clarity', 'scope', 'feasibility'] as const;
export type OfficeHoursDimension = typeof OFFICE_HOURS_DIMENSIONS[number];
+export const OFFICE_HOURS_SEVERITIES = ['blocking', 'minor'] as const;
+export type OfficeHoursSeverity = typeof OFFICE_HOURS_SEVERITIES[number];
export interface OfficeHoursFinding {
id: string;
dimension: OfficeHoursDimension;
+ severity: OfficeHoursSeverity;
+ changed_text: string | null;
problem: string;
remedy: string;
}
@@ -17,7 +22,7 @@ export interface OfficeHoursPriorStatus {
current_id: string | null;
}
export interface OfficeHoursReview {
- version: 1;
+ version: 2;
round: number;
document: string;
quality_score: number;
@@ -31,6 +36,8 @@ export interface OfficeHoursReviewMetrics {
issues_found: number;
issues_fixed: number;
remaining: number;
+ remaining_blocking: number;
+ remaining_minor: number;
quality_score: number | null;
attempted_fix_rounds: number;
}
@@ -48,7 +55,7 @@ function nonempty(value: unknown, label: string): string {
export function validateOfficeHoursReview(value: unknown, previous?: OfficeHoursReview): OfficeHoursReview {
const review = object(value, ['version', 'round', 'document', 'quality_score', 'dimensions', 'findings', 'prior'], 'artifact');
- if (review.version !== 1) fail('unsupported version');
+ if (review.version !== 2) fail('unsupported version; expected 2');
if (!Number.isInteger(review.round) || review.round < 1 || review.round > 3
|| review.round !== (previous?.round ?? 0) + 1) fail('rounds must be contiguous, starting at 1, with at most 3 rounds');
nonempty(review.document, 'document');
@@ -60,19 +67,24 @@ export function validateOfficeHoursReview(value: unknown, previous?: OfficeHours
if (!Array.isArray(review.findings) || !Array.isArray(review.prior)) fail('findings and prior must be arrays');
const ids = new Set();
for (const raw of review.findings) {
- const finding = object(raw, ['id', 'dimension', 'problem', 'remedy'], 'finding');
+ const finding = object(raw, ['id', 'dimension', 'severity', 'changed_text', 'problem', 'remedy'], 'finding');
if (typeof finding.id !== 'string' || !new RegExp(`^R${review.round}-[1-9][0-9]*$`).test(finding.id)
|| ids.has(finding.id)) fail('finding ids must be unique R- identifiers');
ids.add(finding.id);
if (!OFFICE_HOURS_DIMENSIONS.includes(finding.dimension)) fail(`invalid dimension for ${finding.id}`);
+ if (!OFFICE_HOURS_SEVERITIES.includes(finding.severity)) fail(`${finding.id} severity must be blocking or minor`);
nonempty(finding.problem, `${finding.id} problem`);
nonempty(finding.remedy, `${finding.id} remedy`);
+ if (finding.changed_text !== null && (review.round === 1 || typeof finding.changed_text !== 'string'
+ || finding.changed_text.replace(/\s/g, '').length < 8)) fail(`${finding.id} changed_text must be null in round 1, else null or an excerpt of at least 8 characters`);
}
for (const dimension of OFFICE_HOURS_DIMENSIONS) {
const expected = review.findings.some((finding: OfficeHoursFinding) => finding.dimension === dimension) ? 'ISSUES' : 'PASS';
if (dimensions[dimension] !== expected) fail(`${dimension} must be ${expected} for its canonical findings`);
}
const previousIds = new Set(previous?.findings.map(finding => finding.id) ?? []);
+ const blocking = new Set([...(previous?.findings ?? []), ...review.findings as OfficeHoursFinding[]]
+ .filter(finding => finding.severity === 'blocking').map(finding => finding.id));
const covered = new Set();
const currentLinks = new Set();
for (const raw of review.prior) {
@@ -88,16 +100,31 @@ export function validateOfficeHoursReview(value: unknown, previous?: OfficeHours
} else {
if (currentLinks.has(status.current_id)) fail('distinct prior findings cannot merge into one current finding');
currentLinks.add(status.current_id);
+ if (blocking.has(status.id) && !blocking.has(status.current_id)) fail(`${status.status} blocking ${status.id} cannot be downgraded to minor`);
}
}
if (covered.size !== previousIds.size) fail('prior must cover each preceding finding exactly once');
+ for (const finding of review.findings as OfficeHoursFinding[]) {
+ if (review.round > 1 && finding.severity === 'blocking' && !currentLinks.has(finding.id) && finding.changed_text === null) {
+ fail(`new blocking ${finding.id} must cite the changed design text that introduced or exposed it`);
+ }
+ }
return review as OfficeHoursReview;
}
-function stopFor(review: OfficeHoursReview): OfficeHoursReviewStop {
- if (review.findings.length === 0) return 'PASS';
- if (review.prior.some(status => status.status === 'persisting')) return 'CONVERGENCE';
- return review.round === 3 ? 'MAX_ITERATIONS' : 'CONTINUE';
+const blockingIn = (review: OfficeHoursReview) => review.findings.filter(finding => finding.severity === 'blocking');
+/** Minor findings are recorded, never a reason for another round. */
+/** Callers may lower the 3-round cap; reaching it stops at MAX_ITERATIONS. */
+export function officeHoursMaxRounds(value: unknown = 3): number {
+ const rounds = typeof value === 'string' && /^[0-9]+$/.test(value) ? Number(value) : value;
+ if (!Number.isInteger(rounds) || (rounds as number) < 1 || (rounds as number) > 3) fail('max rounds must be 1, 2, or 3');
+ return rounds as number;
+}
+function stopFor(review: OfficeHoursReview, maxRounds = 3): OfficeHoursReviewStop {
+ const blocking = new Set(blockingIn(review).map(finding => finding.id));
+ if (blocking.size === 0) return 'PASS';
+ if (review.prior.some(status => status.status === 'persisting' && blocking.has(status.current_id!))) return 'CONVERGENCE';
+ return review.round >= maxRounds ? 'MAX_ITERATIONS' : 'CONTINUE';
}
function metricsFor(rounds: readonly OfficeHoursReview[]): OfficeHoursReviewMetrics {
const last = rounds.at(-1);
@@ -106,33 +133,94 @@ function metricsFor(rounds: readonly OfficeHoursReview[]): OfficeHoursReviewMetr
issues_found: rounds.reduce((sum, review) => sum + review.findings.length, 0),
issues_fixed: rounds.reduce((sum, review) => sum + review.prior.filter(status => status.status === 'resolved').length, 0),
remaining: last?.findings.length ?? 0,
+ remaining_blocking: last ? blockingIn(last).length : 0,
+ remaining_minor: last ? last.findings.length - blockingIn(last).length : 0,
quality_score: last?.quality_score ?? null,
attempted_fix_rounds: Math.max(0, rounds.length - 1),
};
}
-export function assessOfficeHoursReviews(values: readonly unknown[]): {
+export function assessOfficeHoursReviews(values: readonly unknown[], maxRounds = 3): {
rounds: OfficeHoursReview[]; stop: OfficeHoursReviewStop; metrics: OfficeHoursReviewMetrics;
} {
- if (!Array.isArray(values) || values.length === 0 || values.length > 3) fail('supply 1 to 3 review rounds');
+ officeHoursMaxRounds(maxRounds);
+ if (!Array.isArray(values) || values.length === 0 || values.length > maxRounds) fail(`supply 1 to ${maxRounds} review rounds`);
const rounds: OfficeHoursReview[] = [];
for (const value of values) {
const previous = rounds.at(-1);
- if (previous && stopFor(previous) !== 'CONTINUE') fail('another round follows a terminal review outcome');
+ if (previous && stopFor(previous, maxRounds) !== 'CONTINUE') fail('another round follows a terminal review outcome');
rounds.push(validateOfficeHoursReview(value, previous));
}
- return { rounds, stop: stopFor(rounds.at(-1)!), metrics: metricsFor(rounds) };
+ return { rounds, stop: stopFor(rounds.at(-1)!, maxRounds), metrics: metricsFor(rounds) };
}
-export function loadOfficeHoursReviews(paths: readonly string[]): OfficeHoursReview[] {
+const flat = (text: string) => text.replace(/\s+/g, ' ').trim();
+/** The exact line diff between two reviewed design versions, with 2 lines of context per hunk. */
+export function officeHoursDesignChanges(before: string, after: string): { diff: string; changed: string[] } {
+ const a = before.split('\n'), b = after.split('\n');
+ const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) {
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
+ }
+ const lines: Array<{ mark: ' ' | '-' | '+'; text: string }> = [];
+ for (let i = 0, j = 0; i < a.length || j < b.length;) {
+ if (i < a.length && j < b.length && a[i] === b[j]) { lines.push({ mark: ' ', text: a[i++] }); j++; }
+ else if (i < a.length && (j === b.length || lcs[i + 1][j] >= lcs[i][j + 1])) lines.push({ mark: '-', text: a[i++] });
+ else lines.push({ mark: '+', text: b[j++] });
+ }
+ const near = lines.map((_, n) => lines.slice(Math.max(0, n - 2), n + 3).some(line => line.mark !== ' '));
+ const diff = lines.map((line, n) => near[n] ? `${line.mark}${line.text}` : near[n - 1] ? '@@' : null)
+ .filter((line): line is string => line !== null).join('\n').replace(/^@@\n?|\n@@$/g, '');
+ return { diff: diff || '(no changes)', changed: lines.filter(line => line.mark !== ' ' && flat(line.text)).map(line => line.text) };
+}
+/** Fail closed unless each cited excerpt lies within one changed (+/-) line. */
+export function verifyOfficeHoursCitations(review: OfficeHoursReview, changed: readonly string[]): void {
+ for (const finding of review.findings) {
+ if (finding.changed_text !== null && !changed.some(line => flat(line).includes(flat(finding.changed_text!)))) {
+ fail(`${finding.id} changed_text is not inside the design changes before round ${review.round}`);
+ }
+ }
+}
+/** Rounds 2+ need the design snapshots prepare captured beside each verdict. */
+export function officeHoursSnapshotChanges(verdictPath: string, round: number): { diff: string; changed: string[] } {
+ const snapshot = (n: number) => {
+ try { return fs.readFileSync(path.join(path.dirname(verdictPath), `round-${n}.design.md`), 'utf8'); }
+ catch { return fail(`round ${round} lacks its captured design snapshot round-${n}.design.md`); }
+ };
+ return officeHoursDesignChanges(snapshot(round - 1), snapshot(round));
+}
+export function loadOfficeHoursReviews(paths: readonly string[], maxRounds = 3): OfficeHoursReview[] {
const values = paths.map(file => {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (cause) { throw new Error(`Office-hours review: cannot read artifact ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); }
});
- return values.length ? assessOfficeHoursReviews(values).rounds : [];
+ const rounds = values.length ? assessOfficeHoursReviews(values, maxRounds).rounds : [];
+ for (const review of rounds.slice(1)) verifyOfficeHoursCitations(review, officeHoursSnapshotChanges(paths[review.round - 1], review.round).changed);
+ return rounds;
+}
+
+export interface OfficeHoursVerdictReceipt { round: number; sha256: string; path: string }
+/** The reviewer's entire response: one line binding its round to the exact saved bytes. */
+export function officeHoursVerdictReceipt(round: number, verdictPath: string, bytes: string | Uint8Array): string {
+ return `OFFICE_HOURS_VERDICT round=${round} sha256=${createHash('sha256').update(bytes).digest('hex')} path=${verdictPath}`;
+}
+export function parseOfficeHoursVerdictReceipt(response: string): OfficeHoursVerdictReceipt {
+ const match = /^OFFICE_HOURS_VERDICT round=([1-3]) sha256=([0-9a-f]{64}) path=(.+)$/.exec(response.trim());
+ if (!match || !path.isAbsolute(match[3])) fail('reviewer response is not exactly one verdict receipt line');
+ return { round: Number(match[1]), sha256: match[2], path: match[3] };
+}
+/** Fail closed unless the receipt names this round, this path, and these bytes. */
+export function verifyOfficeHoursVerdictReceipt(response: string, { round, verdictPath, bytes }: {
+ round: number; verdictPath: string; bytes: string | Uint8Array;
+}): OfficeHoursVerdictReceipt {
+ const receipt = parseOfficeHoursVerdictReceipt(response);
+ if (receipt.round !== round) fail(`receipt names round ${receipt.round}, not round ${round}`);
+ if (path.resolve(receipt.path) !== path.resolve(verdictPath)) fail('receipt names a different verdict path');
+ if (receipt.sha256 !== createHash('sha256').update(bytes).digest('hex')) fail('receipt hash does not match the saved verdict bytes');
+ return receipt;
}
/** The caller validates the complete history before supplying its last verdict. */
-export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previous }: {
- document: string; verdictPath: string; previous?: OfficeHoursReview;
+export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previous, changes, maxRounds = 3 }: {
+ document: string; verdictPath: string; previous?: OfficeHoursReview; changes?: string; maxRounds?: number;
}): string {
for (const [label, value] of [['document', document], ['verdictPath', verdictPath]]) {
nonempty(value, label);
@@ -140,12 +228,15 @@ export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previou
if (/[\r\n]/.test(value)) fail(`${label} must fit on one line`);
}
if (previous && path.resolve(previous.document) !== path.resolve(document)) fail('review prompt targets a different document');
- if (previous && stopFor(previous) !== 'CONTINUE') fail('cannot prepare another round after a terminal review');
+ if (previous && stopFor(previous, officeHoursMaxRounds(maxRounds)) !== 'CONTINUE') fail('cannot prepare another round after a terminal review');
+ if ((previous === undefined) !== (changes === undefined)) fail('later rounds, and only later rounds, need the captured design changes');
const round = (previous?.round ?? 0) + 1;
+ const fence = '`'.repeat(Math.max(3, ...[...(changes ?? '').matchAll(/`+/g)].map(run => run[0].length + 1)));
const example = {
- version: 1, round, document, quality_score: 7,
+ version: 2, round, document, quality_score: 7,
dimensions: { completeness: 'PASS', consistency: 'PASS', clarity: 'ISSUES', scope: 'PASS', feasibility: 'PASS' },
- findings: [{ id: `R${round}-1`, dimension: 'clarity', problem: "The fallback's user-visible behavior is unspecified.",
+ findings: [{ id: `R${round}-1`, dimension: 'clarity', severity: 'blocking',
+ changed_text: previous ? 'Excerpt of a changed (+/-) line that shows the defect' : null, problem: "The fallback's user-visible behavior is unspecified.",
remedy: 'Choose and document whether the fallback warns the user or is intentionally silent.' }],
prior: [],
};
@@ -154,16 +245,40 @@ export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previou
Document: ${document}
Verdict: ${verdictPath}
-Use only Read and Write for this review. Read the design at ${JSON.stringify(document)} with Read and review all 5 dimensions independently, including new defects. Do not use Bash or Edit, and do not change the design.
-Use Write only to save your complete verdict as JSON to ${JSON.stringify(verdictPath)}, then return that identical JSON as your entire response (no Markdown fences or prose). The parent runs the formatter to validate your saved JSON.
+Use only Read, Write, and the one Bash seal command for this review. Read the design at ${JSON.stringify(document)} with Read and ${previous
+ ? `perform a delta re-review against the preceding verdict and the exact design changes captured below.`
+ : 'review all 5 dimensions independently, including new defects.'} Do not use Edit, and do not change the design.
+Use Write to save your complete verdict as JSON to ${JSON.stringify(verdictPath)}. Then run the \`Seal:\` command from your dispatch message with Bash, exactly as given; it validates the saved file and prints one receipt line. If it reports an error, correct the saved JSON with Write and run the same command again. Use Bash for nothing else.
+Return only that printed \`OFFICE_HOURS_VERDICT round=${round} sha256= path=\` line, unchanged, as your entire response: no JSON, Markdown fences, or prose. The parent verifies the receipt against the saved bytes.
The saved JSON is your sole findings inventory: include every unresolved problem and necessary remedy, including minor findings that a short conclusion might omit.
Use one finding per distinct obligation. An exact duplicate shares a finding; a shared component does not combine separate decisions, behavior, or effort.
+## Severity
+
+Give every finding a severity. Only blocking findings send the design back for another round; minor findings are recorded for the user and never require another round on their own.
+- **blocking**: a contradiction; a safety or correctness risk; an unsupported claim the recommendation depends on; missing behavior the committed approach needs; or a persisting blocking prior obligation. Example: the design promises the roster is never stored, yet its sync step saves it nightly.
+- **minor**: clarity, wording, or polish that does not change a decision or behavior. Example: the Recommended Approach repeats the problem statement's wording and could be shorter.
+When unsure whether a gap changes a decision or behavior, it is blocking.
+${previous ? `
+## Delta re-review scope
+
+This is round ${round}. Round 1 already reviewed the whole design. Raise a NEW blocking finding only when the changes since round ${round - 1} (a) introduced it, as a regression, or (b) exposed it in text that changed. Set its changed_text to a verbatim excerpt of at least 8 characters from one changed (+ or -) line of the diff below. Anything else you notice is minor and never forces another round; give it changed_text null unless it also concerns changed text.
+A persisting or unverified prior obligation keeps its current finding without a citation.
+
+The helper captured this exact line diff between the design reviewed in round ${round - 1} and the current design ("-" removed, "+" added, "@@" separates hunks):
+
+${fence}diff
+${changes}
+${fence}
+` : `
+Every round-1 finding has changed_text null.
+`}
This is an /office-hours design and coaching document, produced before engineering planning. The startup-mode 'The Assignment' and both modes' 'What I noticed about how you think' sections are intentional: evaluate their evidence and usefulness; do not remove them merely because they are coaching content. Unknown customer facts may remain explicit Open Questions or assignments; do not invent answers.
-Still flag unsupported claims, contradictions, safety/correctness risks, and missing behavior needed by the approach the document actually commits to. Labeling a contradiction or a required behavior an open question does not resolve it.
+Still flag unsupported claims, contradictions, safety/correctness risks, and missing behavior needed by the approach the document actually commits to. Labeling a contradiction or a required behavior an open question does not resolve it.${previous ? ' In this delta round, such problems outside the changed text are minor.' : ''}
On re-review, classify EVERY preceding finding as resolved, persisting, or unverified. Cite the specific document decision/behavior proving the status or the missing evidence. Absence from the new findings list is not confirmation.
A new refinement of an accepted fix is new unless the same specific original obligation demonstrably remains unmet. For persisting/unverified issues, include that unmet obligation in the current findings and reference its current ID. Distinct prior obligations must retain distinct current findings.
+Classify minor and blocking preceding findings alike. A persisting or unverified blocking finding stays blocking until resolved; never relabel it minor.
Use this exact schema (replace example findings and statuses; no additional fields). The round and document below are assigned values:
@@ -171,7 +286,7 @@ Use this exact schema (replace example findings and statuses; no additional fiel
${JSON.stringify(example, null, 2)}
\`\`\`
-Finding IDs are R${round}-; dimension names are the five lowercase keys above. Supply a quality score from 1 to 10. A dimension is ISSUES exactly when it has findings; otherwise PASS.
+Finding IDs are R${round}-; dimension names are the five lowercase keys above; severity is blocking or minor. Supply a quality score from 1 to 10. A dimension is ISSUES exactly when it has findings; otherwise PASS.
Round 1 has an empty prior array. In later rounds, replace the example's empty prior array with one status for EVERY finding in the complete preceding verdict below:
{"id":"","status":"resolved","evidence":"Specific document decision proving resolution","current_id":null}
or {"id":"","status":"persisting","evidence":"Same original obligation still unmet at this document passage","current_id":"R${round}-1"}.
@@ -200,13 +315,13 @@ const marker = (kind: 'concerns' | 'report', edge: 'start' | 'end') => ` sort names once at ---> every list-shaped\n enumeration; diffs are the listing path; output in the tool\n noisy; tests order- tests pin mixed-case is deterministic and\n dependent + enumeration order diff-friendly\n```\nThe plan moves toward the ideal without over-reaching.\n\n### 0D. Alternatives\nNo new approach decision was needed. The plan names a single approach; the only\nopen implementation choice (sort-key rule) is a Section-level finding for the\nsubstantive review, not a Step 0 blocker.\n\n## Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| MODE (reviewer) | 0E mode selection; `plan-ceo-review-mode` preference check returned `AUTO_DECIDE` (exit 0) | HOLD SCOPE | — | approved | Auto-decided by stored preference `never-ask`; logged to question-log.jsonl with user_choice=HOLD SCOPE, recommended=HOLD SCOPE, auto_decided=true, session 1531-1791100258-3092a930 |\n\n## 0E. Mode selection\n\n- Review depth: implementation-ready (default; user did not restrict it).\n- File count: estimated 2 changed files (one listing-path source edit, one test\n file). Estimate; codebase not in checkout.\n- Category: fix/refactor of an existing path with formats held constant → rule\n recommends HOLD SCOPE.\n- Recommendation: HOLD SCOPE because the draft pins its own ceiling (membership,\n aliases, metadata and output formats unchanged), so the review's value is in\n rigor on the sort rule, enumeration-order test, and nil/empty paths, not in\n expansion.\n- Resolution: `gstack-question-preference --check plan-ceo-review-mode` →\n `AUTO_DECIDE`; recommendation selected without asking.\n- Handoff line (from `gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions none --auto`):\n `Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.`\n- Provenance: successful preference check; `plan-ceo-review-mode` logged with\n `auto_decided: true`.\n\n## Deferred by user instruction (not skipped silently)\n\n- /office-hours prerequisite offer: user will handle separately.\n- Routing-rules setup (CLAUDE.md skill routing section): user will handle\n separately; also blocked in plan mode (CLAUDE.md edit + commit).\n- Cross-project learnings config: already set (`false`); no question needed.\n\n## Next\n\nResume with HOLD SCOPE route: 0G (complexity check, minimum changes, invariants)\n→ 0I (temporal interrogation) → `sections/review-sections.md` Sections 1–10,\nSection 11 no-UI skip, required outputs, GSTACK REVIEW REPORT.\n"
+ }
+ },
+ {
+ "sessionId": "bb393dbc-4a5c-4710-a917-071bdf778306",
+ "timestamp": "2026-10-04T07:52:39.381Z",
+ "toolUseId": "toolu_016pABSQS4x7wPFAH6PD5Wvm",
+ "kind": "result",
+ "isError": false,
+ "content": "File created successfully at: /plans/keep-this-draft-plan-typed-…[elided: not consumed]"
+ }
+ ],
+ "options": {
+ "skillName": "plan-ceo-review",
+ "sessionId": "bb393dbc-4a5c-4710-a917-071bdf778306",
+ "commandStartedAt": 1791100250785,
+ "now": 1791100373204,
+ "proseQuestionObserved": false,
+ "stateEvidence": {
+ "questionId": "plan-ceo-review-mode",
+ "preference": "never-ask",
+ "records": [
+ {
+ "skill": "plan-ceo-review",
+ "question_id": "plan-ceo-review-mode",
+ "question_summary": "Select plan-ceo-review mode for deterministic skill-list ordering plan",
+ "category": "routing",
+ "door_type": "two-way",
+ "options_count": 4,
+ "user_choice": "HOLD SCOPE",
+ "recommended": "HOLD SCOPE",
+ "auto_decided": true,
+ "session_id": "1531-1791100258-3092a930",
+ "source": "agent",
+ "ts": "2026-10-04T07:51:52.956Z"
+ }
+ ]
+ }
+ }
+ },
+ "screen": {
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37186854666-1-eval-slices-20/plan-ceo-review-1791100373205-nby65b/terminal.visible.log",
+ "tail_chars": 6000,
+ "text": "ectory enumeration or with tests.\r \rThis draft is the review target, notthe urent branch. For this nvocation,\rI want only the review-mode decision; I will handle optional Office Hours and\rsetup separately,and run the substanive reviewlater. No eviewmode has \rbeen selected. For this mode-only handoff, use the full selected mode name in\rthe normal udit record'susr_choice andcommended fields and in the skill's\rnormal mode handff lne. A meu letter alone would notidentifythe chosn mode.\r\r● Received. I have the draft plan for deterministic skill-list ordering as context, along with your constraints:\rreview-modedecisiononly,fullmodenameintheauditrecordandhandoffline,noOfficeHoursorsetupinthispass.\r I'll waitfor your slash commd before dog anything ese.\r\r✻Brewed for 4s · done 7:50 AM\r❯ /plan-ceo-review \r● Running the plan-ceo-review preamble now, then I'll work toward the mode decision only, per your handoff instructions.\r Read 1 file, ran 6 shell commands \r●Mode decision is made and logged. WritingashortStep0checkpointtotheplanfilesothelatersubstantivereview\rcanresumefromit.\r\r●Updated plan\r65 \rDraft: deterministic skill-list ordering\r \rUsers compare skill listings in scripts and reviews. Make the existing listing\rpath sort regitered skill names deterministically beforrendrng them. \rKeepkill membership, aiases, metadata and text/JSON output formats unchanged.\rCover mxed-cas namesnd differing directory enumeration order wihtests. \r \rThis draft is the review target, not the current branch. For this invocation,\rI want only the review-mode dcisin; I will handle optionalOffice Hurs ad\rsetup separaely, and run the substantive review ater. No review mode has \rben selected. For this mode-only hdoff, use the fullselected mode name in\rth normal audit ecord's user_choice and recommended fields anin the skill's\rnormal ode handoff line.A menu letter alone woul not identifythe choen mode.\r\r●Received.Ihavethedraftplanfordeterministicskill-listorderingascontext,alongwithyourconstraints:\r rview-modedecisiononly,fullmde name the audt record and handoff lin, no Office Hours or setup thispass.\rI'll ait for your slash command before dog anythingelse.\r\r✻Brewed for 4s · done 7:50 AM\r\r❯ /plan-ceo-review \r\r●Runningtheplan-ceo-reviewpreamblenow,thenI'llworktowardthemodedecisiononly,peryourhandoffinstructions.\r\rRead 1 file, ran 6 shell commands \r\r●Modedecisionismadeandlogged.WritingashortStep0checkpointtotheplanfilesothelatersubstantivereview\r can resume from it.\r\r●Updated plan\r ⎿ /plan to preview\r·\r✢\rCerebrating…71\rCerebrating…\r*\r✶\r✻Cerebrating…50\r✽Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✻Cerebrating…\r✶Cerebrating…\r*\r✢Cerebrating…1\r·Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✢Cerebrating…\r*Cerebrating…\r✶\r✻Cerebrating…2\r✽Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✻Cerebrating…\r✶Cerebrating…\r*\r✢Cerebrating…3\r·Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✢Cerebrating…\r*Cerebrating…\r✶\r✻Cerebrating…4\r✽Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✻Cerebrating…\r✶Cerebrating…\r*\r✢\rCerebrating…5\r·Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✢Cerebrating…\r*Cerebrating…\r✶\r✻\rCerebrating…6\r✽Cerebrating…\rCerebrating…\rCerebrating…\rCerebrating…\r✻Cerebrating…\r✶Cerebrating…\r*\r✢\rCerebrating…7\r·Cerebrating…\rCerebrating…\rCerebrating…\r✢Cerebrating…\rCerebrating…\r*Cerebrating…\r✶\r✻\rCerebrating…8\r✽Cerebrating…\rCerebrating…\rCerebrating…\r✻Cerebrating…\r✶Cerebrating…\r*\r✢Cerebrating…\r9\r·Cerebrating…\rCerebrating…\rCerebrating…\r✢Cerebrating…\rCerebrating…\r*\r✶\r✻20s · ↓ 7.1k tokens)\r✽\r✻\r✻Cerebrating… \r✶\r*\r✢1\r·\r\r● Mode decision is made and logged. Writing a short Step 0 checkpoint to the plan file so the later substantive review\r can resume from it.\r\r● Updated plan\r ⎿ /plan to preview\r\r● Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.\r\r Mode application and rationale. The draft is a fix to an existing path with its own ceiling already pinned:\r membership, aliases, metadata and both output formats stay unchanged. That is the fix/refactor category, with an\r estimated two changed files, so the 0E rule recommends HOLD SCOPE. The review's value here is rigor on the sort rule,\r the enumeration-order test, and the empty and nil paths, not expansion. No new approach decision was needed.\r Provenance.Yourstored preference for the mode question never-ask, o the preferece check retured AUTO_DECIDE\rand the recommendation wasseecte without a mnu. The auditrecord is writtnand read back, with the full mode name\rin both fields as you asked:\r question_id=plan-ceo-review-mode user_choice=HOLD SCOPE recommended=HOLD SCOPE\rauto_decided=true session_id=1531-1791100258-3092a930\r\rWhat I did not do, per your instructions.IskippedtheOfficeHoursprerequisiteofferandtheskill-start\r rout-rules setup pompt,since you said youwihandle bothseparatly. The routingstup would alsoneed a\rCLAUDE.mdeditandcommit,whichplanmodeforbids.Thedesigndoccheckfoundnoneforthisbranch.\r\rCheckpoint written.Thehostplanfilenowholdsthesystemaudit,0Ato0Cevidence,thedecisionledgerwiththe\r mrow, andthe resume route. The substantive reviwresumes at 0G, then0I, then thereview sections. One itm\rflaggd for that review:theplanrequiresmixed-casecoveragebutdoesnotnamethesort-keyrule,socase-sensitive\rbyteorderversuscase-insensitivewithastabletiebreakisanopenimplementationchoice.\r\r The review workflowispausedratherthancomplete,soIdidnotrunend-of-skilltelemetryorcallExitPlanMode.No\rdurablelearningsthissession.\r\r✻ Cooked for 2m 1s· done7:52 AM\r❯ \r← for agents"
+ }
+ }
+}
diff --git a/test/fixtures/detector-corpora/auto-decide-preserved/37193478719-t1-pass.json b/test/fixtures/detector-corpora/auto-decide-preserved/37193478719-t1-pass.json
new file mode 100644
index 000000000..01d97b81c
--- /dev/null
+++ b/test/fixtures/detector-corpora/auto-decide-preserved/37193478719-t1-pass.json
@@ -0,0 +1,212 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "auto-decide-preserved",
+ "detector": "test/helpers/native-auto-decide.ts#findNativeAutoDecision",
+ "screen_detector": "test/helpers/pty/classify.ts#classifyVisible",
+ "series_identity": "7678e372b3345f9b",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "periodic",
+ "artifact": "paid-slice-20-a1",
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37193478719-1-eval-slices-20/plan-ceo-review-1791107751754-kWX5bO/observation.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "classifyVisible accepted the sent AUTO_DECIDE line on screen. The archived native poll predates that message (last assistant message is \"Now running the handoff and audit record\"), and the handoff ran inside a compound command, so the native snapshot holds no current declaration.",
+ "screen_expected": "pass",
+ "native_expected": "refuse",
+ "derived": "options.stateEvidence rebuilt from the session's gstack-question-log argument with source \"agent\" and ts = that call's tool-result timestamp (the bound question-log.jsonl itself is not archived)",
+ "input": {
+ "native": {
+ "transcript": {
+ "status": "ready",
+ "calls": [],
+ "assistantMessages": [
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "text": "Received. I have the draft plan for deterministic skill-list ordering as context, along with your constraints: review-mode decision only, full mode name in the audit record and handoff line, no Office Hours or setup in this invocation, substantive review deferred. Waiting for your slash command.",
+ "timestamp": "2026-10-04T09:54:41.190Z"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "text": "Running the plan-ceo-review preamble, then working through Step 0 only as far as the mode decision you asked for.",
+ "timestamp": "2026-10-04T09:54:45.147Z"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "text": "Audit complete: single-commit repo with no remote, stashes, TODOs, design docs, or prior learnings; the mode preference resolved to AUTO_DECIDE, so I'm proceeding on the recommendation without needing a question. Now running the handoff and audit record.\n\n",
+ "timestamp": "2026-10-04T09:55:35.536Z"
+ }
+ ]
+ },
+ "tools": [
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:54:46.344Z",
+ "toolUseId": "toolu_019XB6AY8pzt53jEujDBLsFD",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-skill-start --skill \"plan-ceo-review\" --model \"claude\"",
+ "description": "Run gstack skill-start preamble"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:54:47.584Z",
+ "toolUseId": "toolu_019XB6AY8pzt53jEujDBLsFD",
+ "kind": "result",
+ "isError": false,
+ "content": "SKILL_START_PROTO: 1\nBRANCH: main\nPROACTIVE: true\nPROACTIVE_PROMPTED: yes\nSKILL_PREFIX: false\nREPO_MODE: unknown\nSESSION_KIND: interactive\nCONDUCTOR_SESSION: true\nACTIVATED: yes\nFIRST_LOOP_SHOWN: yes\nFIRST_TASK: \nLAKE_INTRO: yes\nTELEMETRY: off\nTEL_PROMPTED: yes\nSESSION_ID: 1599-1791107687-bb89148d\nTEL_START: 1791107687\nEXPLAIN_LEVEL: default\nQUESTION_TUNING: true\nUPDATE_CHECK: false\nLEARNINGS: 0\nHAS_ROUTING: no\nROUTING_DECLINED: false\nVENDORED_GSTACK: no\nMODEL_OVERLAY: claude\nGSTACK_PLAN_MODE: active\nARTIFACTS_SYNC: off\n"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:54:59.811Z",
+ "toolUseId": "toolu_01YYncvLTNru4zwNR1w1WBys",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-context-recovery 2>&1 | head -40",
+ "description": "Recover recent project context"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:03.002Z",
+ "toolUseId": "toolu_012XobDGNx98ppKFQMXKWvMt",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "echo \"REMOTE: $(git remote get-url origin 2>&1)\"; echo \"HEAD: $(git symbolic-ref refs/remotes/origin/HEAD 2>&1)\"; echo \"--- log\"; git log --oneline -30; echo \"--- stash\"; git stash list; echo \"--- status\"; git status --short; echo \"--- files\"; git ls-files; echo \"--- todos\"; grep -rn \"TODO\\|FIXME\\|HACK\\|XXX\" --exclude-dir=node_modules --exclude-dir=vendor --exclude-dir=.git . | head -30",
+ "description": "Audit repo history, remote, stashes, file list and TODO markers"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:08.318Z",
+ "toolUseId": "toolu_01W6wFMKDjEggUcsFxt5KZRP",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"); BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch'); DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find \"$SLUG\" \"$BRANCH\"); [ -n \"$DESIGN\" ] && echo \"Design doc found: $DESIGN\" || echo \"No design doc found\"; echo \"SLUG=$SLUG BRANCH=$BRANCH\"; _CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo \"unset\"); echo \"CROSS_PROJECT: $_CROSS_PROJ\"; ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>&1 | head -20; echo \"--- pref check\"; printf '%s' \"Select plan-ceo-review mode for deterministic skill-list ordering plan\" | ~/.claude/skills/gstack/bin/gstack-question-preference --check \"plan-ceo-review-mode\" --summary-stdin; echo \"exit=$?\"",
+ "description": "Check design doc, prior learnings, and mode question preference"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:10.632Z",
+ "toolUseId": "toolu_019mgJbvW16gMdxAFCeZsvnZ",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) || true; for d in product goals recent-decisions; do echo \"### $d\"; ~/.claude/skills/gstack/bin/gstack-brain-cache get $d --project \"$SLUG\" 2>/dev/null || echo \"(no $d digest available yet)\"; done; echo \"### user-profile\"; ~/.claude/skills/gstack/bin/gstack-brain-cache get user-profile 2>/dev/null || echo \"(no user-profile digest available yet)\"",
+ "description": "Load brain context digests"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:11.260Z",
+ "toolUseId": "toolu_01EgWh7Xf2Z8NwJwT3uJVJwx",
+ "kind": "use",
+ "name": "Read",
+ "input": {
+ "file_path": "/PLAN.md"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:11.310Z",
+ "toolUseId": "toolu_01YYncvLTNru4zwNR1w1WBys",
+ "kind": "result",
+ "isError": false,
+ "content": "--- RECENT ARTIFACTS ---\n{\"skill\":\"plan-ceo-review\",\"event\":\"started\",\"branch\":\"main\",\"session\":\"1599-1791107687-bb89148d\",\"ts\":\"2026-10-04T09:54:47.602Z\"}\n--- END ARTIFACTS ---"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:11.354Z",
+ "toolUseId": "toolu_012XobDGNx98ppKFQMXKWvMt",
+ "kind": "result",
+ "isError": false,
+ "content": "REMOTE: error: No such remote 'origin'\nHEAD: fatal: ref refs/remotes/origin/HEAD is not a symbolic ref\n--- log\n3f10c21 Seed review plan\n--- stash\n--- status\n---…[elided: not consumed]"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:11.473Z",
+ "toolUseId": "toolu_01W6wFMKDjEggUcsFxt5KZRP",
+ "kind": "result",
+ "isError": false,
+ "content": "No design doc found\nSLUG=gstack-plan-count-3Pak5B BRANCH=main\nCROSS_PROJECT: false\n--- pref check\nAUTO_DECIDE\nexit=0"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:11.584Z",
+ "toolUseId": "toolu_019mgJbvW16gMdxAFCeZsvnZ",
+ "kind": "result",
+ "isError": false,
+ "content": "### product\n(no product digest available yet)\n### goals\n(no goals digest available yet)\n### recent-decisions\n(no recent-decisions digest available yet)\n### user-profile\n(no user-profile digest available yet)"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:11.593Z",
+ "toolUseId": "toolu_01EgWh7Xf2Z8NwJwT3uJVJwx",
+ "kind": "result",
+ "isError": false,
+ "content": "1\t# Draft: deterministic skill-list ordering\n2\t\n3\tUsers compare skill listings in scripts and reviews. Make the existing listing\n4\tpath sort registered skill na…[elided: not consumed]"
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:37.327Z",
+ "toolUseId": "toolu_01HTu27YVDX3G3wtcuQXGvNL",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions \"none\" --auto; echo \"handoff_exit=$?\"; ~/.claude/skills/gstack/bin/gstack-question-log '{\"skill\":\"plan-ceo-review\",\"question_id\":\"plan-ceo-review-mode\",\"question_summary\":\"Select plan-ceo-review mode for deterministic skill-list ordering plan\",\"category\":\"routing\",\"door_type\":\"two-way\",\"options_count\":4,\"user_choice\":\"HOLD SCOPE\",\"recommended\":\"HOLD SCOPE\",\"auto_decided\":true,\"session_id\":\"1599-1791107687-bb89148d\"}'; echo \"log_exit=$?\"",
+ "description": "Run mode handoff and log the auto-decided mode record"
+ }
+ },
+ {
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "timestamp": "2026-10-04T09:55:38.015Z",
+ "toolUseId": "toolu_01HTu27YVDX3G3wtcuQXGvNL",
+ "kind": "result",
+ "isError": false,
+ "content": "Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.\nhandoff_exit=0\nlog_exit=0"
+ }
+ ],
+ "options": {
+ "skillName": "plan-ceo-review",
+ "sessionId": "e50ce1aa-1167-4be3-b288-01d65e8724e2",
+ "commandStartedAt": 1791107681517,
+ "now": 1791107751753,
+ "proseQuestionObserved": false,
+ "stateEvidence": {
+ "questionId": "plan-ceo-review-mode",
+ "preference": "never-ask",
+ "records": [
+ {
+ "skill": "plan-ceo-review",
+ "question_id": "plan-ceo-review-mode",
+ "question_summary": "Select plan-ceo-review mode for deterministic skill-list ordering plan",
+ "category": "routing",
+ "door_type": "two-way",
+ "options_count": 4,
+ "user_choice": "HOLD SCOPE",
+ "recommended": "HOLD SCOPE",
+ "auto_decided": true,
+ "session_id": "1599-1791107687-bb89148d",
+ "source": "agent",
+ "ts": "2026-10-04T09:55:38.015Z"
+ }
+ ]
+ }
+ }
+ },
+ "screen": {
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37193478719-1-eval-slices-20/plan-ceo-review-1791107751754-kWX5bO/terminal.visible.log",
+ "tail_chars": 6000,
+ "text": " full mode name in the audit record and handoff line, no Office Hours or setup in this\r invocation, substantive review deferred. Waiting for your slash command.\r\r✻Brewed for 3s · done 9:54 AM\r\r❯ /plan-ceo-review \r\r●Runningtheplan-ceo-reviewpreamble,thenworkingthroughStep0onlyasfarasthemodedecisionyouaskedfor.\r\rRead 1 file, ran 5 shell commands \r\r●Auditcomplete:single-commitrepowithnoremote,stashes,TODOs,designdocs,orpriorlearnings;themode\r preference resolved to AUTO_DECIDE, so I'mproceeding n the recommendation without neeing a questin. Now running\rthehandoffandauditrecord. · summary\r\r● Running 1 shellcommand…\r✶Billowing… 39thought for 21s)\r✻4\r \r✽\r✻\r✶\r●\r*\r✢5\r·\r \r…\r✢g\r*path sort registered skill names deterministically before rendering them.\rKeep skill membership, aliases, metadata and text/JSON output formats unchanged.\rCover mixed-case names and differing directory enumeration order with tests.\r \rThisdraft is th review target,no the current branch. For this invocation,\rI want ony the evew-mode decision; I willhandle optional Ofice Hors d \rsetupseparately, nd ru the substantv review later. Noreview mode has \rbeen selected. For this mode-only handoff, use the full selected mode name in\rte nomalaudit record's user_choice and recommended fieldsand in the skill's\rnormalmodehandoff line. Amnu letteralone woudno identiy the choen mode.\r\r● Received. I have the draft plan for deterministic skill-list ordering as context, along with your constraints:\r review-mode decision only, full mode name in the audit record and handoff line, no Office Hours or setup in this\r invocation, substantive review deferred. Waiting for your slash command.\r✻Brewed for 3s · done 9:54 AM\r\r❯ /plan-ceo-review \r●Running the plan-ceo-review preamble,thenworkingthroughStep0onlyasfarasthemodedecisionyouaskedfor.\r Read 1 file, ran 5 shell commands \rAdit complete: single-commit repo with noremote,stashes, TODOs, designdocs, or prir learnings; the mode\rpreferenceresolvedtoAUTO_DECIDE,soI'mproceedingontherecommendationwithoutneedingaquestion.Nowrunning\rthe handoff and audit record. · summary\r Rnning 1 shellcommand…\r ⎿ $ ~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions \"none\" --auto; echo \r \"handoff_exit=$?\"; ~/.claude/skills/gstack/bin/gstack-question-log \r'{\"skill\":\"plan-ceo-review\",\"question_id\":\"plan-ceo-review-mode\",\"question_summary\":\"Select plan-ceo-review mode \r for deterministic sk…\rn40\r✶i…\r●\r)\r✻6\r✽wg1\ron\rmode handff andlogtheauto-decidedmoderecord\rBillowing…\rBillowing…\r \r✻Billowing…\r✶Billowing…2\r*\r✢Billowing…7\r●\r·Billowing…\rBillowing…\rBillowing…\rBillowing…\r✢Billowing…\r*Billowing…\r \r✶\r✻Billowing…8\r✽Billowing…\rBillowing…\r●\rBillowing…\rBillowing…\r✻Billowing…\r✶Billowing…\r*\r✢\r \rBillowing…9\r·Billowing…\rBillowing…\rBillowing…\rBillowing…\r●\r✢Billowing…\r*Billowing…\r✶\r✻\rBillowing…1m 0s· ↓ 4.2k tokens)\r✽Billowing…\r \rBillowing…\rBillowing…\rBillowing…1\r✻Billowing…\r✶Billowing…\r●\r*\r✢\rBillowing…\r·Billowing…\rBillowing…\r \rBillowing…\r✢Billowing…2\rBillowing…\r*Billowing…\r✶\r✻\r●\rBillowing…\r✽Billowing…\rBillowing…\rBillowing…\r✻Billowing…3\r \r✶Billowing…\r*\r✢Billowing…\r·Billowing…\r●\rBillowing…\rBillowing…\r✢Billowing…4\r*Billowing…\r \r✶\r✻Billowing…\r✽Billowing…\rBillowing…\rBillowing…\r●\r✻Billowing…5\rBillowing…\r✶\r*\r✢Billowing…\r \rBillowing…\r·Billowing…\rBillowing…\rBillowing…\r✢Billowing…6\r●\rBillowing…\r*\r✶\r✻Billowing…\r✽Billowing…\rBillowing…\r \rBillowing…\rBillowing…\r✻7\r✶\r●\r*\r✢\r·\r·Billowing… \r \r✢8\r*\r✶\r✻\r●\r✽\r9\r✻\r✶\r \r✶Billowing… \r*\r✢\r·\r●\r10s · ↓ 4.2k tokens)\r✢\r*\r \r✶● Audit complete: single-commit repo with no remote, stashes, TODOs, design docs, or prior learnings; the mode\r preference resolved to AUTO_DECIDE, so I'm proceeding on the recommendation without needing a question. Now running\r the handoff and audit record. · summary\r\r Ran 1 shell command \r ⎿ $ ~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions \"none\" --auto; echo \r● Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none. This plan\r is a fix to an existing codepath: sort registered skill names before rendering, with membership, aliases, metadata and\r output formats explicitly frozen. The estimated change is 2 to 3 files, the listing module plus tests. That fits the\r fix/refactor rule, and HOLD SCOPE gives it the rigor it actually needs, namely tracing mixed-case ordering,\renumeration-orderindependence,andthetext/JSONformatinvariantsratherthantemptingexpansionsonadeterminism\r fix.\r\rNo new approach decision was needed. Theplan prescibes sorting in the existinglistingpath,whichisalreadythe\rsmallestscopedalternative,soStep0Dhadnorequiredchoice.\r\rMode provenance.Preferencecheckforplan-ceo-review-modeexited0withAUTO_DECIDE.Theauditrecordwasloggedwith\r user_choice and recommended both set to the full name HOLD SCOPE, auto_decided true, under this session's ID. The\rhandoffcommandandthelogbothexited0.\r\rStep 0 evidence gathered so far\r\r-0Apremise:thepainisnon-reproduciblelistingswhenusersdiffoutputacrossmachinesorruns.Sortingaddresses\r it directly, not a proxy.\r- 0B lverag: th plan reuses the existing listing path. Nobuild propsed.\r- 0C dream state: a detrministic listingisaprerequisiteforscriptedcomparisonandsnapshottests.Theplanmoves\rtowardthat.\r\r Deferred per your instructions\r\r-Office Hours offer: no design doc found, offer skipped. Re-offer suppressed for this session.\r-Setup: the preamble firedaone-timerouting-rulesonboardingpromptforCLAUDE.md.Deferred,sinceyousaidsetup\risseparateandplanmodeforbidstheCLAUDE.mdeditandcommititrequires.Itsgateismarker-based,soit\r re-fires on the nexthealthy run.\r - Substantive review: HOLD SCOPE route is 0G then 0I then the 11 review sections. Not started.Nothingwaswrittento\r"
+ }
+ }
+}
diff --git a/test/fixtures/detector-corpora/auto-decide-preserved/37195203538-t1-pass.json b/test/fixtures/detector-corpora/auto-decide-preserved/37195203538-t1-pass.json
new file mode 100644
index 000000000..1a36653e8
--- /dev/null
+++ b/test/fixtures/detector-corpora/auto-decide-preserved/37195203538-t1-pass.json
@@ -0,0 +1,217 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "auto-decide-preserved",
+ "detector": "test/helpers/native-auto-decide.ts#findNativeAutoDecision",
+ "screen_detector": "test/helpers/pty/classify.ts#classifyVisible",
+ "series_identity": "7678e372b3345f9b",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "paid-slice-20-a1",
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37195203538-1-eval-slices-20/plan-ceo-review-1791109765127-W8O99c/observation.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "The observer recorded nativeAutoDecide: the logged never-ask record plus the sent AUTO_DECIDE line in the same session after the preamble; the screen phrase was not needed.",
+ "screen_expected": "refuse",
+ "native_expected": "pass",
+ "derived": "options.stateEvidence rebuilt from the session's gstack-question-log argument with source \"agent\" and ts = that call's tool-result timestamp (the bound question-log.jsonl itself is not archived)",
+ "input": {
+ "native": {
+ "transcript": {
+ "status": "ready",
+ "calls": [],
+ "assistantMessages": [
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "text": "Received. I have the draft plan for deterministic skill-list ordering in context, along with your handoff constraints: review-mode decision only, no Office Hours or setup, full mode name in the audit record's user_choice and recommended fields and in the mode handoff line. I'll wait for your slash command before starting anything or calling tools.",
+ "timestamp": "2026-10-04T10:27:58.559Z"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "text": "Starting the plan-ceo-review preamble now, then I'll go straight to the mode decision as you asked.",
+ "timestamp": "2026-10-04T10:28:08.100Z"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "text": "Context gathered. The mode preference check came back AUTO_DECIDE, so I'll compute the recommendation, run the handoff, log the audit record and telemetry in one pass.",
+ "timestamp": "2026-10-04T10:28:54.126Z"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "text": "Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none. The plan is a fix to existing behavior with output formats, membership, aliases and metadata explicitly pinned unchanged, so the review's value lies in rigor on the sort key, both renderers and the enumeration-order tests rather than in expanding or cutting scope.\n\n**Mode provenance.** The `plan-ceo-review-mode` preference check returned AUTO_DECIDE, so no question was asked. The question log recorded user_choice and recommended both as the full name \"HOLD SCOPE\" with auto_decided true, on session 1545-1791109690-7adaeb43.\n\n**Step 0 evidence, kept brief**\n\n- Premise: diffing skill listings in scripts and reviews breaks on enumeration-order noise. Sorting at the listing path fixes the pain directly.\n- Leverage: reuse the existing listing path with one sort step before render. No rebuild.\n- Estimated changed files: 2, so no file-count threshold applies. Category is fix/refactor.\n- No approach decision was needed, so 0D was not called.\n\n**Deferred per your request**\n\n- Office Hours offer: no design doc found, offer skipped.\n- Setup: skill-start emitted a routing-rules instruction asking to edit and commit CLAUDE.md. Plan mode forbids that edit, and you said setup is handled separately, so it is deferred untouched.\n- Substantive review: HOLD SCOPE route is 0G, 0I, then Sections 1 to 11. Not started.\n\n**Records**\n\n- Plan file written with the mode decision and the open questions for the later review, at `.claude/plans/keep-this-draft-plan-frolicking-lake.md`.\n- Telemetry recorded with outcome abort, since the review workflow stopped by design after mode selection. Telemetry is off in this install, so the write is local only.\n- No durable learnings this session.\n\nSTATUS: DONE. Mode-only handoff complete. ExitPlanMode not called because the review workflow is intentionally paused here.",
+ "timestamp": "2026-10-04T10:29:24.679Z"
+ }
+ ]
+ },
+ "tools": [
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:09.022Z",
+ "toolUseId": "toolu_012JwPUvKc5i3fi7pzQgiEyV",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-skill-start --skill \"plan-ceo-review\" --model \"claude\"",
+ "description": "Run gstack skill-start preamble"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:10.573Z",
+ "toolUseId": "toolu_012JwPUvKc5i3fi7pzQgiEyV",
+ "kind": "result",
+ "isError": false,
+ "content": "SKILL_START_PROTO: 1\nBRANCH: main\nPROACTIVE: true\nPROACTIVE_PROMPTED: yes\nSKILL_PREFIX: false\nREPO_MODE: unknown\nSESSION_KIND: interactive\nCONDUCTOR_SESSION: true\nACTIVATED: yes\nFIRST_LOOP_SHOWN: yes\nFIRST_TASK: \nLAKE_INTRO: yes\nTELEMETRY: off\nTEL_PROMPTED: yes\nSESSION_ID: 1545-1791109690-7adaeb43\nTEL_START: 1791109690\nEXPLAIN_LEVEL: default\nQUESTION_TUNING: true\nUPDATE_CHECK: false\nLEARNINGS: 0\nHAS_ROUTING: no\nROUTING_DECLINED: false\nVENDORED_GSTACK: no\nMODEL_OVERLAY: claude\nGSTACK_PLAN_MODE: active\nARTIFACTS_SYNC: off\n"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:21.357Z",
+ "toolUseId": "toolu_01Lo2NJVntk2HCrHJV7s4Z1j",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "echo \"--- remote\"; git remote get-url origin 2>&1; echo \"--- head\"; git symbolic-ref refs/remotes/origin/HEAD 2>&1; git rev-parse --verify origin/main 2>&1; echo \"--- log\"; git log --oneline -30; echo \"--- stash\"; git stash list; echo \"--- files\"; git ls-files; echo \"--- todos\"; grep -rn \"TODO\\|FIXME\\|HACK\\|XXX\" --exclude-dir=.git . 2>/dev/null | head -30; echo \"--- recent\"; git log --since=30.days --name-only --format=\"\" | sort | uniq -c | sort -rn | head -20",
+ "description": "Audit repo state: remote, base branch, history, files"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:25.858Z",
+ "toolUseId": "toolu_01LLpuyDZpfMtkeDLcVxGoMf",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-context-recovery 2>&1; echo \"--- design doc\"; SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"); BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch'); DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find \"$SLUG\" \"$BRANCH\"); [ -n \"$DESIGN\" ] && echo \"Design doc found: $DESIGN\" || echo \"No design doc found\"; echo \"--- learnings\"; _CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo \"unset\"); echo \"CROSS_PROJECT: $_CROSS_PROJ\"; ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>&1 | head -20",
+ "description": "Recover context, check design doc and prior learnings"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:29.796Z",
+ "toolUseId": "toolu_01CUoUKTjEbjBoXRzXnVBR86",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "printf '%s' \"Select plan-ceo-review mode for deterministic skill-list ordering plan: SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, or SCOPE REDUCTION\" | ~/.claude/skills/gstack/bin/gstack-question-preference --check \"plan-ceo-review-mode\" --summary-stdin; echo \"exit=$?\"; echo \"--- brain\"; SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) || true; for d in product goals recent-decisions; do echo \"### $d\"; ~/.claude/skills/gstack/bin/gstack-brain-cache get $d --project \"$SLUG\" 2>/dev/null || echo \"(no $d digest available yet)\"; done; echo \"### user-profile\"; ~/.claude/skills/gstack/bin/gstack-brain-cache get user-profile 2>/dev/null || echo \"(no user-profile digest available yet)\"",
+ "description": "Check mode question preference and load brain context"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:30.430Z",
+ "toolUseId": "toolu_014CMt1fkSaWpH9PTAEkgVXz",
+ "kind": "use",
+ "name": "Read",
+ "input": {
+ "file_path": "/PLAN.md"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:30.867Z",
+ "toolUseId": "toolu_01Lo2NJVntk2HCrHJV7s4Z1j",
+ "kind": "result",
+ "isError": false,
+ "content": "--- remote\nerror: No such remote 'origin'\n--- head\nfatal: ref refs/remotes/origin/HEAD is not a symbolic ref\nb57232966b60b0a7c3cefc5aebcb5dac0b435124\n--- log\nb5…[elided: not consumed]"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:30.974Z",
+ "toolUseId": "toolu_01LLpuyDZpfMtkeDLcVxGoMf",
+ "kind": "result",
+ "isError": false,
+ "content": "--- RECENT ARTIFACTS ---\n{\"skill\":\"plan-ceo-review\",\"event\":\"started\",\"branch\":\"main\",\"session\":\"1545-1791109690-7adaeb43\",\"ts\":\"2026-10-04T10:28:10.588Z\"}\n--- END ARTIFACTS ---\n--- design doc\nNo design doc found\n--- learnings\nCROSS_PROJECT: false"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:31.122Z",
+ "toolUseId": "toolu_01CUoUKTjEbjBoXRzXnVBR86",
+ "kind": "result",
+ "isError": false,
+ "content": "AUTO_DECIDE\nexit=0\n--- brain\n### product\n(no product digest available yet)\n### goals\n(no goals digest available yet)\n### recent-decisions\n(no recent-decisions digest available yet)\n### user-profile\n(no user-profile digest available yet)"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:31.130Z",
+ "toolUseId": "toolu_014CMt1fkSaWpH9PTAEkgVXz",
+ "kind": "result",
+ "isError": false,
+ "content": "1\t# Draft: deterministic skill-list ordering\n2\t\n3\tUsers compare skill listings in scripts and reviews. Make the existing listing\n4\tpath sort registered skill na…[elided: not consumed]"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:28:58.435Z",
+ "toolUseId": "toolu_01LvfeR9remcV4fbVwEPDvmv",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "echo \"--- handoff\"; ~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions \"none\" --auto 2>&1; echo \"--- qlog\"; ~/.claude/skills/gstack/bin/gstack-question-log '{\"skill\":\"plan-ceo-review\",\"question_id\":\"plan-ceo-review-mode\",\"question_summary\":\"Select plan-ceo-review mode for deterministic skill-list ordering plan\",\"category\":\"routing\",\"door_type\":\"two-way\",\"options_count\":4,\"user_choice\":\"HOLD SCOPE\",\"recommended\":\"HOLD SCOPE\",\"auto_decided\":true,\"session_id\":\"1545-1791109690-7adaeb43\"}' 2>&1 && echo \"qlog ok\"; echo \"--- tel\"; ~/.claude/skills/gstack/bin/gstack-skill-end --skill \"plan-ceo-review\" --outcome abort --session-id \"1545-1791109690-7adaeb43\" --tel-start \"1791109690\" --used-browse no --error-message \"\" --failed-step \"\" 2>&1; echo \"tel exit=$?\"",
+ "description": "Run mode handoff, log mode decision, record telemetry"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:29:11.007Z",
+ "toolUseId": "toolu_01RRksiyxRReq6E6AVqfYhZn",
+ "kind": "use",
+ "name": "Write",
+ "input": {
+ "file_path": "/plans/keep-this-draft-plan-frolicking-lake.md",
+ "content": "# plan-ceo-review: mode decision for \"deterministic skill-list ordering\"\n\n## Context\n\nReview target: PLAN.md draft (deterministic skill-list ordering). The user asked for\nthe review-mode decision only. Office Hours, setup (CLAUDE.md routing rules), and\nthe substantive Sections 1-11 review are deferred to a later invocation.\n\n## Step 0 evidence (brief)\n\n- 0A Premise: scripts and reviewers diff skill listings; nondeterministic order from\n directory enumeration produces spurious diffs. Sorting at the existing listing path\n fixes the pain directly, not a proxy.\n- 0B Leverage: reuse the existing listing/render path; add a sort step before render.\n No rebuild justified.\n- 0C Dream state: current = enumeration-order output; this plan = stable sorted\n output in text and JSON; 12-month ideal = all list-like CLI output stable and\n diff-friendly. The plan moves toward it.\n- 0D: no approach choice required.\n\n## 0E Mode selection\n\n- Estimated changed files: 2 (listing module, test file). Estimate.\n- Category: fix/refactor of existing behavior; membership, aliases, metadata and\n output formats pinned unchanged.\n- Recommended: HOLD SCOPE.\n- Provenance: `plan-ceo-review-mode` preference check returned AUTO_DECIDE\n (session 1545-1791109690-7adaeb43). Selected mode: HOLD SCOPE. No question asked.\n Question log recorded with user_choice and recommended both set to the full mode\n name \"HOLD SCOPE\", auto_decided true.\n\n## Open for the later substantive review (HOLD SCOPE route: 0G -> 0I -> Sections 1-11)\n\n- Sort key: byte order vs case-insensitive with a deterministic tie-break for\n mixed-case names (e.g. `Foo` vs `foo`); locale independence.\n- Apply the same sort to both text and JSON renderers, or sort once upstream.\n- Tests: mixed-case names; shuffled directory enumeration order yields identical output.\n- Alias handling: aliases must keep pointing at the same skill after sorting.\n"
+ }
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:29:12.065Z",
+ "toolUseId": "toolu_01LvfeR9remcV4fbVwEPDvmv",
+ "kind": "result",
+ "isError": false,
+ "content": "--- handoff\nAuto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.\n--- qlog\nqlog ok\n--- tel\nSKILL_END: recorded outcome=abort duration_s=62\ntel exit=0"
+ },
+ {
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "timestamp": "2026-10-04T10:29:12.102Z",
+ "toolUseId": "toolu_01RRksiyxRReq6E6AVqfYhZn",
+ "kind": "result",
+ "isError": false,
+ "content": "File created successfully at: /plans/keep-this-draft-plan-frolic…[elided: not consumed]"
+ }
+ ],
+ "options": {
+ "skillName": "plan-ceo-review",
+ "sessionId": "94e3f44b-b75d-4187-a3e3-0fc829a7d75d",
+ "commandStartedAt": 1791109678876,
+ "now": 1791109765126,
+ "proseQuestionObserved": false,
+ "stateEvidence": {
+ "questionId": "plan-ceo-review-mode",
+ "preference": "never-ask",
+ "records": [
+ {
+ "skill": "plan-ceo-review",
+ "question_id": "plan-ceo-review-mode",
+ "question_summary": "Select plan-ceo-review mode for deterministic skill-list ordering plan",
+ "category": "routing",
+ "door_type": "two-way",
+ "options_count": 4,
+ "user_choice": "HOLD SCOPE",
+ "recommended": "HOLD SCOPE",
+ "auto_decided": true,
+ "session_id": "1545-1791109690-7adaeb43",
+ "source": "agent",
+ "ts": "2026-10-04T10:29:12.065Z"
+ }
+ ]
+ }
+ }
+ },
+ "screen": {
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37195203538-1-eval-slices-20/plan-ceo-review-1791109765127-W8O99c/terminal.visible.log",
+ "tail_chars": 6000,
+ "text": "hem. \rKep skill membership, aliass, medata and txt/JSON output formats unchanged.\rCover mixed-case names and differing irectoryenumeration ordrwithtests. \r \rThis draft is the revew targt,no the current brach. For this invocation, \r I want only the review-mode decision; I will handle optional Office Hours and \r setup separately, and run the substantive review later. No review mode has \r been selected. For this mode-only handoff, use the full selected mode name in \r the normal audit record's user_choice and recommended fields and in the skill's \r normal mode handoff line. A menu letter alone would not identify the chosen mode. \r\r●Received.Ihavethedraftplanfordeterministicskill-listorderingincontext,alongwithyourhandoffconstraints:\r review-mode decision only, no Office Hours or setup, full mode name in the audit record's user_choice and recommended\rfieldsandinthemodehandoffline.I'llwaitforyourslashcommandbeforestartinganythingorcallingtools.\r\r✻Cooked for 4s · done 10:27 AM\r\r❯ /plan-ceo-review \r\r●Startingtheplan-ceo-review preamble now, then I'll go straighttothemodedecisionasyouasked.\r Read 1 file, ran 4 shell commands \r\r● Context gathered. The mode preference check came back AUTO_DECIDE, so I'll compute the recommendation, run the\rhandoff, log the audit record and telemetry in one pass.\r\rRan 1 shell command \r \rUsers compare skill listings in scripts and reviews. Make the existing listing\rpath sort regitered skill names deterministically beforrendrng them. \rKeepkill membership, aiases, metadata and text/JSON output formats unchanged.\rCover mxed-cas namesnd differing directory enumeration order wihtests. \r \rThis draft is the review target, not the current branch. For this invocation,\rI want only the review-mode dcisin; I will handle optionalOffice Hurs ad\rsetup separaely, and run the substantive review ater. No review mode has \rben selected. For this mode-only hdoff, use the fullselected mode name in\rth normal audit ecord's user_choice and recommended fields anin the skill's\rnormal ode handoff line.A menu letter alone woul not identifythe choen mode.\r\r●Received.Ihavethedraftplanfordeterministicskill-listorderingincontext,alongwithyourhandoffconstraints:\r rview-modedecisiononly,no Ofice Hous or setup, ful mode name in the audi recrd's user_choice andrecommended\rfields an in the mode handoff line.I'll wait for your slash comand before starting anything or calligtools.\r\r✻Cooked for 4s · done 10:27 AM\r\r❯ /plan-ceo-review \r\r●Startingtheplan-ceo-reviewpreamblenow,thenI'llgostraighttothemodedecisionasyouasked.\r\rRead 1 file, ran 4 shell commands \r\r●Contextgathered.ThemodepreferencecheckcamebackAUTO_DECIDE,soI'llcomputetherecommendation,runthe\r hadoff, log the audit record and telemetryin one pass.\r\rRan 1 shell command \r\r●Updated plan\r ⎿ /plan to preview\r52\r✻\rSwirling…\r✽Swirling…\rSwirling…4\rSwirling…\rSwirling…\r✻Swirling…\r✶Swirling…\r*\r✢\rSwirling…\r·Swirling…\rSwirling…5\rSwirling…\r✢Swirling…\rSwirling…\r*Swirling…\r✶\r✻\rSwirling…\r✽Swirling…\rSwirling…6\rSwirling…\r✻Swirling…\r✶Swirling…\r*\r✢Swirling…\r·Swirling…7\rSwirling…\rSwirling…\r✢Swirling…\r*Swirling…\r✶\r✻Swirling…\r✽Swirling…8\rSwirling…\rSwirling…\r✻Swirling…\rSwirling…\r✶\r*\r✢Swirling…\rSwirling…\r·Swirling…9\rSwirling…\rSwirling…\r✢Swirling…\rSwirling…\r*\r✶\r✻Swirling…\r✽Swirling…\rSwirling…20\rSwirling…\rSwirling…\r✻Swirling…\rSwirling…\r✶\r*\r✢Swirling…\r·Swirling…\rSwirling…1\rSwirling…\rSwirling…\r✢Swirling…\rSwirling…\r*\r✶\r✻Swirling…\r✽Swirling…\rSwirling…2\rSwirling…\rSwirling…\r✻Swirling…\r✶Swirling…\r*\r✢Swirling…\r·Swirling…\rSwirling…3\rSwirling…\rSwirling…\r✢Swirling…\r*Swirling…\r✶Swirling…\r✻\r✽\r4\r✻\r✶\r*\r*Swirling… \r✢\r·\r5\r✢\r*\r✶\r✻\r✽\r✽Swirling… 6\r✽Swirling… a fix to existing behavior with output formats, membership, aliases and metadata explicitly pinned unchanged, so the\r review's value lies in rigor on the sort key, both renderers and the enumeration-order tests rather than in expanding\r or cutting scope.\r\r Mode provenance. The plan-ceo-review-mode preference check returned AUTO_DECIDE, so no question was asked. The\r question log recorded user_choice and recommended both as the full name \"HOLD SCOPE\" with auto_decided true, on\r session 1545-1791109690-7adaeb43.\r\r Step 0 evidence, kept brief\r\r - Premise: diffing skill listings in scripts and reviews breaks on enumeration-order noise. Sorting at the listing\r path fixes the pain directly.\r-Leverage:reusetheexistinglistingpathwithonesortstepbeforerender.Norebuild.\r - Estimatedchanged files:2, so nofil-count threshod applies. Category is fix/refactor.\r- No approach decsion waseeded, so 0D was not called.\r\rDeferred per your request\r\r-OfficeHoursoffer:nodesigndocfound,offerskipped.\r - Setup: skill-start emitted a routing-rules instruction asking to edit and commit CLAUDE.md. Plan mode forbids that\redit,andyousaidsetupishandledseparately,soitisdeferreduntouched.\r - Substanive review: HOLD SCOPE route is 0G, 0I, then Sections 1 to 11. Notstarted.\rRecords\r - Plan file witten with the mode decision and the open questions forthe later rview, at\r .claude/plans/keep-this-draft-plan-frolicking-lake.md\r-Telemetryrecordedwithoutcomeabort,sincethereviewworkflowstoppedbydesignaftermodeselection.Telemetry\r is off in this install,sothewriteislocalonly.\r-Nodurablelearningsthissession.\r\r STATUS: DONE. Mode-onlyhandoffcomplete.ExitPlanModenotcalledbecausethereviewworkflowisintentionallypaused\rhere.\r\r✻ Cooked for 1m 26s ·done 10:29 AM\r❯ \r← for agents"
+ }
+ }
+}
diff --git a/test/fixtures/detector-corpora/auto-decide-preserved/37198445662-t1-pass.json b/test/fixtures/detector-corpora/auto-decide-preserved/37198445662-t1-pass.json
new file mode 100644
index 000000000..baffd9241
--- /dev/null
+++ b/test/fixtures/detector-corpora/auto-decide-preserved/37198445662-t1-pass.json
@@ -0,0 +1,323 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "auto-decide-preserved",
+ "detector": "test/helpers/native-auto-decide.ts#findNativeAutoDecision",
+ "screen_detector": "test/helpers/pty/classify.ts#classifyVisible",
+ "series_identity": "7678e372b3345f9b",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "periodic",
+ "artifact": "paid-slice-20-a1",
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37198445662-1-eval-slices-20/plan-ceo-review-1791113328798-6JFWyh/observation.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "classifyVisible accepted the sent AUTO_DECIDE line on screen. The archived native poll predates that message (last assistant message announces the handoff), and the handoff ran inside a compound command, so the native snapshot holds no current declaration.",
+ "screen_expected": "pass",
+ "native_expected": "refuse",
+ "derived": "options.stateEvidence rebuilt from the session's gstack-question-log argument with source \"agent\" and ts = that call's tool-result timestamp (the bound question-log.jsonl itself is not archived)",
+ "input": {
+ "native": {
+ "transcript": {
+ "status": "ready",
+ "calls": [],
+ "assistantMessages": [
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "text": "Received. I have the draft plan for deterministic skill-list ordering as context, along with your constraints: this is a mode-only handoff, no review mode selected yet, use the full mode name in the audit record and handoff line. I'll wait for your slash command before starting or calling any tools.",
+ "timestamp": "2026-10-04T11:25:02.647Z"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "text": "I'll run the skill preamble first, then work through Step 0 to the mode decision only, as you asked.",
+ "timestamp": "2026-10-04T11:25:07.190Z"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "text": "I'll defer the CLAUDE.md setup prompt since you're handling it separately and plan mode blocks edits anyway—it'll reappear next run. Now gathering the audit context in one pass.\n\n",
+ "timestamp": "2026-10-04T11:25:34.122Z"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "text": "The fixture repo only has CLAUDE.md and PLAN.md—no remote, stash, TODOs, design doc, or prior context, and Aside isn't installed. Since mode preference is set to auto-decide, I'll skip that question and run the landscape check via WebSearch instead.\n\n",
+ "timestamp": "2026-10-04T11:26:36.166Z"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "text": "Confirmed the Reproducible Builds guidance aligns with the plan: avoid relying on readdir order and sort entries bytewise to prevent locale-based inconsistencies. Now writing the Step 0 ledger to the plan file while running the mode handoff and audit log in parallel.\n\n",
+ "timestamp": "2026-10-04T11:27:37.493Z"
+ }
+ ]
+ },
+ "tools": [
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:08.567Z",
+ "toolUseId": "toolu_01Wf6kvaTmd5c4BVKu8m21EF",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-skill-start --skill \"plan-ceo-review\" --model \"claude\"",
+ "description": "Run gstack skill preamble"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:10.032Z",
+ "toolUseId": "toolu_01Wf6kvaTmd5c4BVKu8m21EF",
+ "kind": "result",
+ "isError": false,
+ "content": "SKILL_START_PROTO: 1\nBRANCH: main\nPROACTIVE: true\nPROACTIVE_PROMPTED: yes\nSKILL_PREFIX: false\nREPO_MODE: unknown\nSESSION_KIND: interactive\nCONDUCTOR_SESSION: true\nACTIVATED: yes\nFIRST_LOOP_SHOWN: yes\nFIRST_TASK: \nLAKE_INTRO: yes\nTELEMETRY: off\nTEL_PROMPTED: yes\nSESSION_ID: 1562-1791113109-05c92427\nTEL_START: 1791113109\nEXPLAIN_LEVEL: default\nQUESTION_TUNING: true\nUPDATE_CHECK: false\nLEARNINGS: 0\nHAS_ROUTING: no\nROUTING_DECLINED: false\nVENDORED_GSTACK: no\nMODEL_OVERLAY: claude\nGSTACK_PLAN_MODE: active\nARTIFACTS_SYNC: off\n"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:36.494Z",
+ "toolUseId": "toolu_01WfcV1ucLDK2iPD1sy2MgZP",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-context-recovery 2>&1; echo \"---REMOTE---\"; git remote get-url origin 2>&1; echo \"---HEAD---\"; git symbolic-ref refs/remotes/origin/HEAD 2>&1; git rev-parse --verify origin/main 2>&1; echo \"---LOG---\"; git log --oneline -30; echo \"---STASH---\"; git stash list; echo \"---TODO---\"; grep -r \"TODO\\|FIXME\\|HACK\\|XXX\" -l --exclude-dir=node_modules --exclude-dir=vendor --exclude-dir=.git . | head -30; echo \"---TOUCHED---\"; git log --since=30.days --name-only --format=\"\" | sort | uniq -c | sort -rn | head -20; echo \"---FILES---\"; git ls-files | head -100",
+ "description": "Recover context and audit repo history"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:50.500Z",
+ "toolUseId": "toolu_01NLsv1JwFa2z6QM2DjX2iKh",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "setopt +o nomatch 2>/dev/null || true\nSLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\")\nBRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch')\nDESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find \"$SLUG\" \"$BRANCH\")\n[ -n \"$DESIGN\" ] && echo \"Design doc found: $DESIGN\" || echo \"No design doc found\"\necho \"---LEARNINGS---\"\n_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo \"unset\")\necho \"CROSS_PROJECT: $_CROSS_PROJ\"\nif [ \"$_CROSS_PROJ\" = \"true\" ]; then\n { _LE=$(~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>&1 >&3 3>&-); _LR=$?; } 3>&1\nelse\n { _LE=$(~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>&1 >&3 3>&-); _LR=$?; } 3>&1\nfi\n[ \"$_LR\" = 0 ] || { _LE=${_LE%%$'\\n'*}; echo \"LEARNINGS: unavailable (${_LE:-exit $_LR})\"; }\necho \"---BRAIN---\"\nSLUG2=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) || true\nfor d in product goals recent-decisions; do echo \"### $d\"; ~/.claude/skills/gstack/bin/gstack-brain-cache get $d --project \"$SLUG2\" 2>/dev/null || echo \"_(no $d digest available yet)_\"; done\necho \"### user-profile\"; ~/.claude/skills/gstack/bin/gstack-brain-cache get user-profile 2>/dev/null || echo \"_(no user-profile digest available yet)_\"\necho \"---ASIDE---\"\n_gs_d() { if command -v gtimeout >/dev/null; then gtimeout 30 \"$@\"; elif command -v timeout >/dev/null; then timeout 30 \"$@\"\nelif command -v perl >/dev/null; then perl -e 'alarm(shift);exec(@ARGV)' 30 \"$@\"; else return 125; fi; }\n_A=aside; command -v aside >/dev/null || _A=$(command -v ~/.local/bin/aside)\nif [ \"${GSTACK_SKIP_ASIDE:-}\" = \"1\" ] || [ -z \"$_A\" ]; then\n echo \"NEEDS_ASIDE: ${GSTACK_PLATFORM:-$(uname)}\"\nelse\n _rc=0; _o=$(_gs_d \"$_A\" repl 'console.log(\"ASIDE_READY \" + pwd)' 2>&1) || _rc=$?\n case \"$_rc\" in\n 124|142) echo \"ASIDE_TIMEOUT: probe deadline exceeded\" ;;\n 125) echo \"ASIDE_UNAVAILABLE: bounded probe unavailable\" ;;\n 0) if printf '%s\\n' \"$_o\" | grep -q '^ASIDE_READY '; then echo \"READY: $_A\"\n else echo \"ASIDE_NOT_RUNNING: no readiness marker\"; fi ;;\n *) echo \"ASIDE_CLI_ERROR: exit $_rc; inspect aside --help locally\" ;;\n esac\nfi\necho \"---QPREF---\"\nprintf '%s' \"Select plan-ceo-review mode for deterministic skill-list ordering plan: SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, or SCOPE REDUCTION\" | ~/.claude/skills/gstack/bin/gstack-question-preference --check \"plan-ceo-review-mode\" --summary-stdin; echo \"QPREF_EXIT: $?\"",
+ "description": "Check design doc, learnings, brain context, Aside, and mode preference"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:51.497Z",
+ "toolUseId": "toolu_01Jg384kG7A8kPWZHjzUixKa",
+ "kind": "use",
+ "name": "Read",
+ "input": {
+ "file_path": "/PLAN.md"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:53.678Z",
+ "toolUseId": "toolu_01QtvhyccyZBEYf9hbVcKxvb",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "ls -la ; echo \"---TODOS---\"; cat /TODOS.md 2>&1 | head -50; echo \"---ETHOS---\"; sed -n '1,60p' ~/.claude/skills/gstack/ETHOS.md 2>&1",
+ "description": "List repo files, read TODOS.md and ETHOS.md"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:54.352Z",
+ "toolUseId": "toolu_01WfcV1ucLDK2iPD1sy2MgZP",
+ "kind": "result",
+ "isError": false,
+ "content": "--- RECENT ARTIFACTS ---\n{\"skill\":\"plan-ceo-review\",\"event\":\"started\",\"branch\":\"main\",\"session\":\"1562-1791113109-05c92427\",\"ts\":\"2026-10-04T11:25:10.051Z\"}\n--- END ARTIFACTS ---\n---REMOTE---\nerror: No such remote 'origin'\n---HEAD---\nfatal: ref refs/remotes/origin/HEAD is not a symbolic ref\n9b45bea985d4de4cc1c1b2619d16faa32cd42f91\n---LOG---\n9b45bea Seed review plan\n---STASH---\n---TODO---\n---TOUCHED---\n 1 PLAN.md\n 1 CLAUDE.md\n---FILES---\nCLAUDE.md\nPLAN.md"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:54.644Z",
+ "toolUseId": "toolu_01NLsv1JwFa2z6QM2DjX2iKh",
+ "kind": "result",
+ "isError": false,
+ "content": "No design doc found\n---LEARNINGS---\nCROSS_PROJECT: false\n---BRAIN---\n### product\n_(no product digest available yet)_\n### goals\n_(no goals digest available yet)_\n### recent-decisions\n_(no recent-decisions digest available yet)_\n### user-profile\n_(no user-profile digest available yet)_\n---ASIDE---\nNEEDS_ASIDE: Linux\n---QPREF---\nAUTO_DECIDE\nQPREF_EXIT: 0"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:54.680Z",
+ "toolUseId": "toolu_01Jg384kG7A8kPWZHjzUixKa",
+ "kind": "result",
+ "isError": false,
+ "content": "1\t# Draft: deterministic skill-list ordering\n2\t\n3\tUsers compare skill listings in scripts and reviews. Make the existing listing\n4\tpath sort registered skill na…[elided: not consumed]"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:25:54.689Z",
+ "toolUseId": "toolu_01QtvhyccyZBEYf9hbVcKxvb",
+ "kind": "result",
+ "isError": false,
+ "content": "total 20\ndrwx------ 3 runner runner 4096 Oct 4 11:24 .\ndrwxr-xr-x 7 runner runner 4096 Oct 4 11:25 ..\ndrwxr-xr-x 8 runner runner 4096 Oct 4 11:24 .git\n-rw-r--r-- 1 runner runner 1132 Oct 4 11:24 CLAUDE.md\n-rw-r--r-- 1 runner runner 826 Oct 4 11:24 PLAN.md\n---TODOS---\ncat: /TODOS.md: No such file or directory\n---ETHOS---\n# gstack Builder Ethos\n\nThese are the principles that shape how gstack thinks, recommends, and builds.\nSkill preambles carry condensed forms of some of them (completeness, voice,\nsearch before building), and the Search Before Building section points here for\nthe full text. They reflect what we believe about building software in 2026.\n\n---\n\n## The Golden Age\n\nA single person with AI can now build what used to take a team of twenty.\nThe engineering barrier is gone. What remains is taste, judgment, and the\nwillingness to do the complete thing.\n\nThis is not a prediction — it's happening right now. 10,000+ usable lines of\ncode per day. 100+ commits per week. Not by a team. By one person, part-time,\nusing the right tools. The compression ratio between human-team time and\nAI-assisted time ranges from 3x (research) to 100x (boilerplate):\n\n| Task type | Human team | AI-assisted | Compression |\n|-----------------------------|-----------|-------------|-------------|\n| Boilerplate / scaffolding | 2 days | 15 min | ~100x |\n| Test writing | 1 day | 15 min | ~50x |\n| Feature implementation | 1 week | 30 min | ~30x |\n| Bug fix + regression test | 4 hours | 15 min | ~20x |\n| Architecture / design | 2 days | 4 hours | ~5x |\n| Research / exploration | 1 day | 3 hours | ~3x |\n\nThis table changes everything about how you make build-vs-skip decisions.\nThe last 10% of completeness that teams used to skip? It costs seconds now.\n\n---\n\n## 1. Boil the Ocean\n\n\"Don't boil the ocean\" was the right advice when engineering time was the\nbottleneck. That era is over. AI-assisted coding makes the marginal cost of\ncompleteness near-zero, so the old caution has quietly turned into an excuse.\nWhen the complete implementation costs minutes more than the shortcut — do the\ncomplete thing. Every time.\n\n**Ocean, lakes first:** The ocean is the destination — 100% test coverage for a\nmodule, full feature implementation, all edge cases, complete error paths. You\nget there one lake at a time: each lake is a boilable unit, not the ceiling.\n\"That's boiling the ocean\" is no longer a reason to ship a shortcut — boiling\nthe ocean is the goal. The only thing still out of scope is genuinely unrelated\nwork: a multi-quarter platform migration that has nothing to do with the task at\nhand. Flag that as separate scope. Boil everything else.\n\n**Completeness is cheap.** When evaluating \"approach A (full, ~150 LOC) vs\napproach B (90%, ~80 LOC)\" — always prefer A. The 70-line delta costs\nseconds with AI coding. \"Ship the shortcut\" is legacy thinking from when\nhuman engineering time was the bottleneck.\n\n**Anti-patterns:**\n- \"Choose B — it covers 90% with less code.\" (If A is 70 lines more, choose A.)\n- \"Let's defer tests to a follow-up PR.\" (Tests are the cheapest lake to boil.)\n- \"This would take 2 weeks.\" (Say: \"2 weeks human / ~1 hour AI-assisted.\")"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:26:36.168Z",
+ "toolUseId": "toolu_013JNLGRRstXZfcntr9ahtSc",
+ "kind": "use",
+ "name": "ToolSearch",
+ "input": {
+ "query": "select:WebSearch",
+ "max_results": 1
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:26:36.171Z",
+ "toolUseId": "toolu_013JNLGRRstXZfcntr9ahtSc",
+ "kind": "result",
+ "isError": false,
+ "content": [
+ {
+ "type": "tool_reference",
+ "tool_name": "WebSearch"
+ }
+ ]
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:26:36.532Z",
+ "toolUseId": "toolu_01GNkE1RxcdKUAUirZHVX6aL",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff --help 2>&1 | head -30",
+ "description": "Show mode handoff helper usage"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:26:37.103Z",
+ "toolUseId": "toolu_01GNkE1RxcdKUAUirZHVX6aL",
+ "kind": "result",
+ "isError": false,
+ "content": "gstack-ceo-mode-handoff: unknown mode: --help"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:26:48.243Z",
+ "toolUseId": "toolu_019yK3jaeJGwVALSWypGcXzd",
+ "kind": "use",
+ "name": "WebSearch",
+ "input": {
+ "query": "deterministic sorted output CLI listing reproducible scripts filesystem enumeration order best practice"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:27:25.578Z",
+ "toolUseId": "toolu_019yK3jaeJGwVALSWypGcXzd",
+ "kind": "result",
+ "isError": false,
+ "content": "Web search results for query: \"deterministic sorted output CLI listing reproducible scripts filesystem enumeration order best practice\"\n\nLinks: [{\"title\":\"Nix: …[elided: not consumed]"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:17.198Z",
+ "toolUseId": "toolu_01Si5mXg44rZxfPohQa5QFqc",
+ "kind": "use",
+ "name": "Write",
+ "input": {
+ "file_path": "/plans/keep-this-draft-plan-indexed-beaver.md",
+ "content": "# Working plan: deterministic skill-list ordering\n\nSource: PLAN.md in the fixture repo (commit 9b45bea \"Seed review plan\").\nReview: /plan-ceo-review, mode-only handoff requested by the user. The\nsubstantive review (Sections 1–11, outputs, report) is deliberately NOT run in\nthis invocation; the user will run it later.\n\n## Plan under review (verbatim from PLAN.md)\n\nUsers compare skill listings in scripts and reviews. Make the existing listing\npath sort registered skill names deterministically before rendering them.\nKeep skill membership, aliases, metadata and text/JSON output formats unchanged.\nCover mixed-case names and differing directory enumeration order with tests.\n\n## Review depth\n\nImplementation-ready (default; the user did not ask for strategy-only or a\nsingle narrow decision). Stated limits recorded from the plan:\n\n| Measure | Value | Unit | Prerequisite |\n|---|---|---|---|\n| Skill membership | unchanged | set of registered names | existing registry |\n| Aliases | unchanged | mapping | existing registry |\n| Metadata | unchanged | per-skill fields | existing registry |\n| Output formats | unchanged | text and JSON | existing renderers |\n| Ordering | deterministic | sort of registered names before render | existing listing path |\n| Tests | required | mixed-case names; differing directory enumeration order | test harness |\n\n## Pre-review system audit\n\n- Platform: unknown (no `origin` remote). Base branch: `main` (git-native fallback).\n- History: one commit, `9b45bea Seed review plan`. No stash. No TODO/FIXME/HACK markers.\n- Repo contents: `CLAUDE.md`, `PLAN.md` only. The listing code the plan modifies is\n not present in this fixture, so code-level leverage below is stated as unknowns\n for the implementation owner to confirm.\n- TODOS.md: absent. No prior deferrals.\n- Design doc: none found. User is handling /office-hours separately; offer not made.\n- Retrospective check: no prior review refactors or reverts (single commit).\n- DESIGN_SCOPE: none. CLI text/JSON listing only; no UI screens or components. Section 11 skips.\n- Taste calibration: not applicable (recommended mode is HOLD SCOPE, not an expansion mode).\n- Prior learnings: cross_project_learnings=false; 0 project learnings found.\n- Brain context: all four digests cold (no product, goals, recent-decisions, user-profile).\n- Landscape check: Aside not installed (NEEDS_ASIDE); ran one read-only WebSearch.\n - [Layer 1] Tried and true: never rely on readdir/listdir/glob order; apply an\n explicit sort; sort bytewise (codepoint or `LC_ALL=C`) so locale collation\n cannot change results. Source: reproducible-builds.org \"Stable order for inputs\"\n and \"Commandments\".\n - [Layer 2] Search results: Python glob deliberately stays unsorted (bpo-30461);\n systemd and mono both landed \"sort the input file list\" fixes; disorderfs and\n reprotest exist to shuffle/reverse directory order in CI to catch exactly this bug.\n - [Layer 3] First principles: the plan says \"deterministic\", not \"alphabetical\".\n Those differ for mixed case. Codepoint order (`Zeta` before `alpha`) is\n deterministic and locale-free but reads oddly; case-insensitive order needs a\n stable tiebreak (e.g. fold then raw) or two names differing only by case\n produce enumeration-dependent output again. This is the one real design\n choice inside the plan and belongs to the implementation review, not mode\n selection. No eureka to log.\n\n## Step 0 ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| S0-PREMISE (user) | PLAN.md lines 3–6: listings are diffed by scripts and reviewers; nondeterministic order creates false diffs | Listing renders names in registry/enumeration order (unverified: code not in fixture) | Sort registered names before render; membership, aliases, metadata, formats unchanged | approved (plan as stated) | PLAN.md as handed over; no change proposed by this review |\n| S0-COLLATION (implementation owner) | Plan requires mixed-case test coverage; \"deterministic\" does not specify codepoint vs case-insensitive order | unknown | pending: codepoint sort vs case-folded sort with raw tiebreak | unresolved | Not a Step 0 approach choice; defer to Review Sections (implementation-ready depth). No approval requested here |\n| S0-MODE (user) | 0E recommendation; preference check `plan-ceo-review-mode` returned AUTO_DECIDE | none selected | HOLD SCOPE | approved (auto-decided) | Preference check exit 0 AUTO_DECIDE; see handoff provenance below |\n\n### 0A. Premise Challenge\n\nReal problem: two runs of the same listing can print the same skills in different\norder because the registry is populated from directory enumeration, which POSIX\nleaves unspecified. Target outcome: identical input produces byte-identical text\nand JSON listings, so `diff`, snapshot tests and review comments stop flagging\nnoise. Do-nothing cost: every script that compares listings needs its own\n`sort`, and JSON consumers that compare arrays cannot. The plan solves the pain\ndirectly (it fixes the producer, not each consumer). It is not a proxy fix.\n\n### 0B. Existing Code Leverage\n\n| Sub-problem | Reusable code | Note |\n|---|---|---|\n| Collect registered names | existing registry / listing path | Reuse as-is; plan says \"existing listing path\" |\n| Sort | standard library sort | Reuse ladder rung 2; no new dependency |\n| Render text/JSON | existing renderers | Unchanged by contract |\n| Simulate enumeration order in tests | existing test harness fixtures | Owner must confirm whether a fake directory walker or injected name list exists |\n\nNo rebuild proposed. The fixture repo contains no code, so the implementation\nowner must confirm the listing entry point and test helpers.\n\n### 0C. Dream State Mapping\n\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n Listing order follows ---> Names sorted once at the ---> Every user-facing\n readdir order; scripts listing boundary; text enumeration (skills,\n and reviews see spurious and JSON stable; tests aliases, any future\n diffs between machines pin mixed-case and catalog) is sorted at\n and runs shuffled enumeration the producer; CI shuffles\n directory order to prove it\n```\n\nThe plan moves toward the ideal. It is the first and smallest lake: one boundary,\none sort, two test classes.\n\n### 0D. Alternatives\n\nNo required approach choice. The plan names a single, bounded approach (sort at\nthe existing listing path) with explicit invariants. The only open design question\n(S0-COLLATION) is an implementation detail inside that approach and is recorded\nas pending for the Review Sections. No new approach decision was needed.\n\n### 0E. Mode Selection\n\nPlanned changed files (estimate; code not in fixture): 1 source edit on the\nlisting path + 1 test file edit or addition = ~2 files. Category: fix/refactor\nof existing behavior with pinned invariants. Not >15 files; category is clear.\n\nRecommendation: HOLD SCOPE because the plan explicitly freezes membership,\naliases, metadata and both output formats, so the value here is rigor on the\none changed behavior (collation rule, locale independence, case-only collisions,\nshuffled-enumeration tests) rather than any new capability.\n\nPreference check: `gstack-question-preference --check plan-ceo-review-mode`\nexited 0 with AUTO_DECIDE. Per 0E step 3 the recommendation is selected without\na question. Selected mode: HOLD SCOPE.\n\n## Route after mode\n\nHOLD SCOPE route is 0G → 0I → Review Sections. Per the user's instruction this\ninvocation stops after the mode handoff. 0G, 0I and Review Sections 1–11,\nrequired outputs and the GSTACK REVIEW REPORT are pending for the later run.\n\n## NOT in scope\n\nNothing cut or deferred by this invocation.\n"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:17.279Z",
+ "toolUseId": "toolu_01Si5mXg44rZxfPohQa5QFqc",
+ "kind": "result",
+ "isError": false,
+ "content": "File created successfully at: /plans/keep-this-draft-plan-indexe…[elided: not consumed]"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:20.265Z",
+ "toolUseId": "toolu_01RXKeSPQ9g8Bxx8y1hTfwAk",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions \"none\" --auto; echo \"HANDOFF_EXIT: $?\"; echo \"---QLOG---\"; ~/.claude/skills/gstack/bin/gstack-question-log '{\"skill\":\"plan-ceo-review\",\"question_id\":\"plan-ceo-review-mode\",\"question_summary\":\"Select plan-ceo-review mode for deterministic skill-list ordering plan\",\"category\":\"routing\",\"door_type\":\"two-way\",\"options_count\":4,\"user_choice\":\"HOLD SCOPE\",\"recommended\":\"HOLD SCOPE\",\"auto_decided\":true,\"session_id\":\"1562-1791113109-05c92427\"}'; echo \"QLOG_EXIT: $?\"",
+ "description": "Run mode handoff and log auto-decided mode"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:21.195Z",
+ "toolUseId": "toolu_01RXKeSPQ9g8Bxx8y1hTfwAk",
+ "kind": "result",
+ "isError": false,
+ "content": "Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.\nHANDOFF_EXIT: 0\n---QLOG---\nQLOG_EXIT: 0"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:32.356Z",
+ "toolUseId": "toolu_01DmtVBPoyAUHhn5N8613NYK",
+ "kind": "use",
+ "name": "Edit",
+ "input": {
+ "replace_all": false,
+ "file_path": "/plans/keep-this-draft-plan-indexed-beaver.md",
+ "old_string": "## Route after mode",
+ "new_string": "### Mode handoff and provenance\n\nHandoff line (from `gstack-ceo-mode-handoff \"HOLD SCOPE\" --decisions none --auto`, exit 0):\n\n> Auto-decided review mode → HOLD SCOPE (your preference). Change with /plan-tune. Approved decisions: none.\n\nProvenance: successful preference check (AUTO_DECIDE) on question_id\n`plan-ceo-review-mode`; recommendation HOLD SCOPE. Audit record written with\n`gstack-question-log` (exit 0): user_choice=HOLD SCOPE, recommended=HOLD SCOPE,\nauto_decided=true, session 1562-1791113109-05c92427. No question was asked.\n\n## Route after mode"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:32.384Z",
+ "toolUseId": "toolu_01DmtVBPoyAUHhn5N8613NYK",
+ "kind": "result",
+ "isError": false,
+ "content": "The file /plans/keep-this-draft-plan-indexed-beaver.md has been …[elided: not consumed]"
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:34.202Z",
+ "toolUseId": "toolu_01BzKWpwYPt8hK4PSkzYDGJs",
+ "kind": "use",
+ "name": "Bash",
+ "input": {
+ "command": "~/.claude/skills/gstack/bin/gstack-skill-end --skill \"plan-ceo-review\" --outcome abort --session-id \"1562-1791113109-05c92427\" --tel-start \"1791113109\" --used-browse no --error-message \"\" --failed-step \"\" 2>/dev/null || true; echo \"SKILL_END_EXIT: $?\"",
+ "description": "Log skill-end telemetry for this invocation"
+ }
+ },
+ {
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "timestamp": "2026-10-04T11:28:34.904Z",
+ "toolUseId": "toolu_01BzKWpwYPt8hK4PSkzYDGJs",
+ "kind": "result",
+ "isError": false,
+ "content": "SKILL_END: recorded outcome=abort duration_s=205\nSKILL_END_EXIT: 0"
+ }
+ ],
+ "options": {
+ "skillName": "plan-ceo-review",
+ "sessionId": "77c8d5e3-f546-4efd-b4bf-1eb8f149ba7e",
+ "commandStartedAt": 1791113102899,
+ "now": 1791113328797,
+ "proseQuestionObserved": false,
+ "stateEvidence": {
+ "questionId": "plan-ceo-review-mode",
+ "preference": "never-ask",
+ "records": [
+ {
+ "skill": "plan-ceo-review",
+ "question_id": "plan-ceo-review-mode",
+ "question_summary": "Select plan-ceo-review mode for deterministic skill-list ordering plan",
+ "category": "routing",
+ "door_type": "two-way",
+ "options_count": 4,
+ "user_choice": "HOLD SCOPE",
+ "recommended": "HOLD SCOPE",
+ "auto_decided": true,
+ "session_id": "1562-1791113109-05c92427",
+ "source": "agent",
+ "ts": "2026-10-04T11:28:21.195Z"
+ }
+ ]
+ }
+ }
+ },
+ "screen": {
+ "path": "shards/skill-e2e-auto-decide-preserved/pty-count/ci-37198445662-1-eval-slices-20/plan-ceo-review-1791113328798-6JFWyh/terminal.visible.log",
+ "tail_chars": 6000,
+ "text": "de → HOLDSCOPE(yourpreference).Changewith/plan-tune.Approveddecisions:none.Theplan\rfreezesskillmembership,aliases,metadataandbothoutputformats,sothereview'svalueisrigorontheonechanged\rbehavior, not new capability.Theestimatedchangetouchesabouttwofiles,afixtoexistingbehavior,whichmapsto\rHOLDSCOPEunderthemoderules.Nonewapproachdecisionwasneeded.\r\r What happened in Step 0\r - Mode selection di not need a questio. Your stored preferencefor the mode question retundAUTO_DECIDE, so the\r rcommendation was selected and the handoff ran. The audt record usesthe full modnamein bot the ser_choice\r and recmmended fields, wth uto_decided set totrue.\r-AuditfoundafixturewithonlyCLAUDE.mdandPLAN.md,noremote,nostash,noTODOs,nodesigndoc,andcoldbrain\r context. Thelistingcodeitselfisnotinthisrepo,socodeleverageisrecordedasunknownsforthe\r implementation owner.\r-Landscape checkranthroughWebSearchbecauseAsideisnotinstalled.ReproducibleBuildsguidancematchestheplan:\r never trust directoryenumerationorder,sortexplicitly,andsortbytewisesolocalecollationcannotchangethe\rresult.SeeStableorderforinputs(https://reproducible-builds.org/docs/stable-inputs/),Commandments\r (https://reproducible-builds.org/docs/commandments/),Pythongloborderingissue\r (https://bugs.python.org/issue30461),systemdsortfix(https://github.com/systemd/systemd/pull/31080),and\rdisorderfs(https://manpages.ubuntu.com/manpages/stonking/man1/disorderfs.1.html).\r - One pending design question recrded forthelaterreview,notdecidedhere.\"Deterministic\"and\"alphabetical\"\r differ for mixed case. Codepoint order is locale-free but puts uppercase names first. Case-insensitive orderneedsa\rraw-name tiebreak or two names differing only by case become enumeration-dependent again.\r\rArtifacts\r\r -\r\rWorkingplanwiththeStep0ledger,0Ato0Eevidence,andhandoffprovenanceissavedat\rRan 1 shell command \r\r●Updated plan\r ⎿ /plan to preview\r\rRan 1 shell command \r\r●Auto-decidedreviewmode→HOLDSCOPE(yourpreference).Changewith/plan-tune.Approveddecisions:none.Theplan\r freezes skill membership, aliases, metadataand both output formats,so the review's value is rgor o theonchged\rbehavior, notnw capability. The estimatedchange touches abut wofiles,a fx toexistingbehavir, which maps to\rHOLD SCOPE under the mode rules. No new approach decision ws needed.\r\rWhat happened in Step 0\r\r-Mode selectiondidnotneedaquestion.YourstoredpreferenceforthemodequestionreturnedAUTO_DECIDE,sothe\r recommendationwasselectnd the handoff ran. The audit record usesthe full modename in both the user_choic\rand recommended fields, with auto_decided set totrue.\r-Audit fund a fixture with only CLAUDE.mandPLAN.md,noremote,nostash,noTODOs,nodesigndoc,andcoldbrain\r context. The listing code itself is not in this repo, so code lverage i recrded asunknowns for the\rimplmentation owner.\r-Landscape check ran throughWebSearchbecauseAsideisnotinstalled.ReproducibleBuildsguidancematchestheplan:\r never trust directory enmeration order, sortexplictly, and sort bytewise so locale collaton anno change the\rrsult. SeeStable ordr fo inputs (http://rproducible-builds.org/docs/stable-inputs/), Commandmens\r(https://rproducible-builds.org/docs/commandmnts/), Python glob ordering issu\rbugs.python.org/isue30461), system sort fix (https://github.com/sytmd/systemd/pull/31080),and\rdisorderfs (https://manpage.ubuntu.com/manpages/stonking/man1disorderfs.1.html).\r-One pending design question is recorded for th laer review, not decided here. \"Deterministic\"and\"alphabetical\"\r differ for mixed case. Codepoint order is locale-frebut puts upperas names firs. Case-insensitive order needsa\rraw-name tiebrak or two names differing only by casecome enmeration-depndent again.\r\rArtifacts\r\r-WorkingplanwiththeStep0ledger,0Ato0Eevidence,andhandoffprovenanceissavedat\r .claude/plans/keep-this-draft-plan-indexed-beaver.md under te with-skills home. PLAN.md inthefixtureis\r●Updated plan\r ⎿ /plan to preview\r\r Ran 1 shellcommand \r●Auto-decided review mode→HOLDSCOPE(yourpreference).Changewith/plan-tune.Approveddecisions:none.Theplan\rfreezesskillmembership,aliases,metadataandbothoutputformats,sothereview'svalueisrigorontheonechanged\r behavior, not new capability. The estimatedchange touches bout twofies, a fix to existing behavior, whic maps to\rHOLD SCOPE under the moderule. No new pproach decision was needed.\r\rWhat happened in Step 0\r- Mode selectio did notneedaquestion.YourstoredpreferenceforthemodequestionreturnedAUTO_DECIDE,sothe\rrecommendationwasselectedandthehandoffran.Theauditrecordusesthefullmodenameinboththeuser_choice\r and recommended fields, with auto_decided set true.\r-Audit found a fixtur wih onlyCLAUDE.md and PLAN.md, no remote,no tash, no TODOs, no design doc, and cold brain\rcontext. The listng code itself is not in this repo, socodeleverageisrecordedasunknownsforthe\r implementation owne.\r-Landscape check ra through WbSearch because Aside is not installed. ReproducibleBuilds guidancematchestheplan:\rnever trus directoyenumerationorder,sortexplicitly,andsortbytewisesolocalecollationcannotchangethe\r result. See Stable order for inputs (https://reproducible-builds.org/docs/stable-inpts/), Commandmens\r(https://reproduible-builds.org/dcs/commandmnts/), Ptho glb ordering isue\r(https://bugs.python.org/issue30461), systemd sot fix (https://github.om/systmd/systemd/pull/31080),and\rdisorderfs (https://manpageubuntu.com/anpags/tonking/man1/disfs.1.html).\r-One pending design question is recorded for the later review, not decided here. \"Deterministic\" and \"alphabetical\"\rffer formixed case. Codpoint order is locale-free but puts uppecase names first.Case-insensitiveorderneedsa\r raw-name tiebreak or two namediffering nly by casebcome eumeraton-dependent again.\r\rArtifacts\r- WorkingplanwiththeStep0ledger,0Ato0Eevidence,andhandoffprovenanceissavedat\r.claude/plans/keep-this-draft-plan-indexed-beaver.mdunderthewith-skillshome.PLAN.mdinthefixtureis\r untouched.\r-\rSkill-endtelemetryrecordedthisinvocationasabort,meaningstoppedbeforethereviewbyyourrequest.Telemetry"
+ }
+ }
+}
diff --git a/test/fixtures/detector-corpora/auto-decide-preserved/inventory.json b/test/fixtures/detector-corpora/auto-decide-preserved/inventory.json
new file mode 100644
index 000000000..7a096f7c1
--- /dev/null
+++ b/test/fixtures/detector-corpora/auto-decide-preserved/inventory.json
@@ -0,0 +1,82 @@
+[
+ {
+ "run": "37174266054",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "6c9ea4bac7c0bc81",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37174266054-t1-pass.json",
+ "reason": null
+ },
+ {
+ "run": "37176837432",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "6c9ea4bac7c0bc81",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37176837432-t1-fail.json",
+ "reason": null
+ },
+ {
+ "run": "37179171083",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "6c9ea4bac7c0bc81",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37179171083-t1-fail.json",
+ "reason": null
+ },
+ {
+ "run": "37182865432",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "e3bf31932dcbfd91",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37182865432-t1-fail.json",
+ "reason": null
+ },
+ {
+ "run": "37186854666",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "7678e372b3345f9b",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37186854666-t1-pass.json",
+ "reason": null
+ },
+ {
+ "run": "37193478719",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "7678e372b3345f9b",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37193478719-t1-pass.json",
+ "reason": null
+ },
+ {
+ "run": "37195203538",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "7678e372b3345f9b",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37195203538-t1-pass.json",
+ "reason": null
+ },
+ {
+ "run": "37198445662",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "7678e372b3345f9b",
+ "artifact": "paid-slice-20-a1",
+ "available": true,
+ "entry": "37198445662-t1-pass.json",
+ "reason": null
+ }
+]
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37174266054-t1-pass.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37174266054-t1-pass.json
new file mode 100644
index 000000000..c7aa34165
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37174266054-t1-pass.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "a7f8ef2d01d4716d",
+ "source": {
+ "run": "37174266054",
+ "sha": "a8ace39030dd92745b45ac16e5d733e63c936c0f",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Genuine pass via the stale-row fallback: the R1 ledger row was left 'unresolved' while the decision record selected A, so the ledger reader refuses, and the asserted stale-fill trace satisfies hasStaleFillRaceFinding.",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nwere the originally proposed read/write ordering rules; the original text proposed\nno additional version checks or coordination between a cache fill and a write.\n**Amended by CEO review decision R1 (D1 → A): see \"Amendment R1\" below; the\nsketch stays as the baseline being amended, not the approved ordering.**\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n### Amendment R1 (accepted, D1 → A): stale-fill guard\nRequired guarantee: a cache fill produced by a readProfile that began before a\nwriteProfile completed must never be observable by a readProfile begun after that\nwrite completed. The wrapper must detect, synchronously at fill time, that a write\nfor the same key completed after the read began, and skip (or neutralize) that\nfill; the read still returns its snapshot (allowed by lines 44-46). Feasibility:\ncache operations and the JS event loop are synchronous and atomic (line 28), so a\nper-key write-completion marker checked in the same tick as cache.set cannot\nrace with cache.delete; no new coordination with repository internals is needed.\nTradeoffs: one extra branch in readProfile and one bookkeeping step in\nwriteProfile; a racing key costs one additional DB read; the per-key marker is\nbounded by live-read keys and must be released when its readers finish. Exact\ndata structure and code belong to engineering planning. Added telemetry: one\n`stale_fill_suppressed` counter on the existing dashboard, no key labels.\nDeterministic regression scenarios (existing harness, controlled pause/release):\n1. read begins → write commits+completes → read resumes: next read sees new version.\n2. write completes → read begins → read fills: fill is kept (no false suppression).\n3. missing record: read sees absent → create completes → read resumes: next read sees record, no 10 s absent sentinel.\n4. rejected write during a pending read: fill is kept; cached value equals the unchanged store.\n5. rejected read: no fill, no marker leak; a later read fills normally.\n6. two overlapping writes then a pending read resumes: no stale fill; next read sees the last committed version.\n7. controller instance change mid-read: old-instance read cannot fill the new instance (existing contract), marker state does not cross instances.\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing, plus the Amendment R1 regression scenarios 1-7.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n# CEO REVIEW (plan-ceo-review, HOLD SCOPE)\n\n## Step 0 — Setup, scope and decision ledger\n\n**Startup.** Depth: implementation-ready for behavior and verification; data structures, function bodies and executable tests stay with engineering (PLAN.md lines 117-120). Working plan and output: this PLAN.md. Base branch: `main` (git commands prohibited this run; taken from supplied git status). Skipped per run rules: system audit, design-doc/office-hours offer, landscape search, brain context, prior learnings, telemetry. Outside review: `codex_reviews: disabled` in `.gstack-section-state-Ve1QVj/config.yaml`, so the outside-review step and its native fallback are skipped and coverage is reported as disabled. DESIGN_SCOPE: none (no UI, line 9). Retrospective: no prior review artifacts in this fixture.\n\n**0A Premise.** Real problem: ~900 hot keys re-read in one process; DB CPU 70%, read p95 120 ms (lines 4-6). Target: ≥60% hits, DB CPU <50%, p95 <60 ms, SLOs unchanged (lines 7-9). Do-nothing cost: DB headroom keeps shrinking and latency stays 2x the target. The plan attacks the pain directly (removes the repeated reads at the repository boundary); not a proxy.\n\n**0B Leverage.** Reused as-is: repository and typed error mapping, LRU adapter with limits and absent sentinel (lines 22-27), adapter failure bypass (28-30), per-key single-flight (30-36), transactional store (37-41), rollout controller and flag (77-87), telemetry/dashboards/runbooks (88-101), contract test harness (122). New: wrapper read/write ordering only. No rebuild.\n\n**0C Dream state.**\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n every read hits DB; 70% CPU ---> process-local LRU on repo ---> same repo interface, swappable\n p95 120 ms, 900 hot keys ≥60% hits, <50% CPU, p95 <60 ms cache backend if multi-process\n single process correctness SLOs unchanged ever arrives (line 107-108)\n```\nPlan moves toward the ideal without adding a cache framework.\n\n**0E Mode.** Explicit user instruction: HOLD SCOPE. No mode question asked; provenance: user instruction \"Hold the current scope (HOLD SCOPE mode)\". Route: 0G → 0I. File count (estimate): 2-3 changed files (wrapper module, wrapper tests, flag/controller wiring); well under the HOLD complexity threshold.\n\n**Limits recorded.** LRU 1000 entries; 16 MiB bytes; 30 s TTL; 10 s absent-sentinel TTL; rollout 10%/50%/100% with one healthy hour each; alerts: SLO breach, p95 >120 ms for 5 min, bypass persisting 1 min; targets ≥60% hits (admitted requests), DB CPU <50%, p95 <60 ms (service-wide). None changed by this review.\n\n### Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| R1 — plan author (wrapper ordering) | Lines 46-48: every read begun after writeProfile completes must observe the committed version; TTL is not a substitute. Lines 52-53 and 55-68: readProfile fills `cache.set(key, value)` after an awaited read with no fill/write coordination. Lines 35-36: single-flight does not inspect cache fills. Lines 86-87: controller does not coordinate an ordinary write with a fill in the same instance. | Wrapper as proposed; a read that began before a write can fill a stale snapshot after the write completes (trace below) | A) stale-fill guard requirement + regression scenarios; B) keep as-is (TTL-bounded); C) leave gap to engineering planning | unresolved | — |\n\n**Demonstrated gap trace (R1).** All steps use only accepted contracts:\n```\nt0 R1: readProfile(k) -> cache.get(k) miss -> await repository.read(k) [snapshot v0 in flight]\nt1 W : writeProfile(k) -> repository.write commits v1 -> cache.delete(k)\n -> promise fulfills [write COMPLETE, line 43-44]\nt2 R1 resumes with v0 (already-started reader, line 35) -> cache.set(k, v0) [stale fill, no guard: line 52-53]\nt3 R2: readProfile(k) begun after t1 -> cache.get(k) hits v0 [violates line 46-48 for up to 30 s;\n 10 s if v0 was the absent sentinel]\n```\nThis is a contradiction inside the plan text, not an unavailable implementation. Missing-record creation is the same race with the absent-result DTO (lines 23-27).\n\n## currentDecision (R1)\nCommitment comparison:\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead begun after write completion observes committed version (lines 46-48) | approved fixture contract | required | preserved via stale-fill guard | weakened to TTL-bounded | required but left unaddressed in plan\nCoordination between cache fill and write (lines 52-53, 86-87) | pending (wrapper unapproved, line 113) | none | guarantee required; structure chosen by engineering | none | none; flagged as TODO\nAdapter limits 1000 / 16 MiB / 30 s / 10 s sentinel (lines 22-26) | approved | unchanged | unchanged | unchanged | unchanged\nSingle-flight and write-atomicity contracts (lines 30-41) | approved | unchanged | unchanged | unchanged | unchanged\nRegression scenarios: both completion orders, missing-record creation, rejected reads/writes, overlapping writes, instance isolation (lines 122-125) | approved acceptance requirement | required, not yet specified | specified deterministically | read-after-write scenario would fail | deferred\nTelemetry for added branch on current dashboard, no key labels (lines 125-126) | approved | n/a | one counter for suppressed stale fills | n/a | n/a\nAcceptance targets and rollout stages (lines 7-9, 77-101) | approved | unchanged | unchanged | unchanged | unchanged\n```\n\nQuestion: D1 — R1: How should the plan resolve the stale cache fill that breaks read-after-write visibility?\nProject/branch/task: main — cache profile summaries in one process; CEO review of the proposed wrapper ordering.\nELI10: A read that starts before a write can finish after the write and put the old answer back in the cache. Anyone reading afterward gets the old profile for up to 30 seconds. The plan promises that never happens once a write completes, and says TTL does not count as a fix.\nStakes if we pick wrong: users see a profile they just updated revert, the correctness SLO breaks under normal traffic, and the rollout alert disables the flag.\nRecommendation: A because it restores the retained contract with a small synchronous guard inside the single event loop, keeps every limit unchanged, and the regression scenarios are deterministic on the existing harness.\nCompleteness: A=10/10, B=3/10, C=5/10\nPros / cons:\nA) Require stale-fill guard + regression scenarios (recommended)\n ✅ Restores lines 46-48 exactly: no read begun after a completed write can observe an older fill\n ✅ Feasible with existing contracts: cache ops are synchronous/atomic (line 28), so a per-key write-completion marker checked at fill time cannot race\n ❌ Adds one branch and one counter to the wrapper; a fill is skipped after a racing write, costing one extra DB read for that key\nB) Keep wrapper as proposed; accept TTL-bounded staleness\n ✅ Zero implementation work beyond the plan as written\n ✅ Simplest code path for the hit/miss happy case\n ❌ Contradicts line 48 and weakens the correctness SLO; not authorizable under the author policy\nC) Leave the gap as an engineering-planning TODO without amending the plan\n ✅ Keeps the CEO review short and defers structure choice to engineering\n ✅ No plan text changes beyond a TODO entry\n ❌ Approves a plan that contradicts its own retained contract; the required regression scenario has no required behavior to test against\nNet: A fixes the contract at the cost of one small guard; B and C both leave a known correctness bug in an approved plan.\nHeader: R1 stale fill\nA) Require stale-fill guard + regression scenarios (recommended)\nEffort S, risk low. Reuse: synchronous atomic adapter ops, single-flight cohorts, existing harness; coverage 10/10. Amends the plan with the required guarantee (a fill from a read begun before a write completed is never observable by a read begun after that completion), its feasibility, tradeoffs, deterministic regression scenarios, and one suppressed-fill counter on the existing dashboard. Engineering picks the exact structure. ✅ Restores lines 46-48 exactly. ✅ Feasible with existing synchronous atomic cache ops. ❌ One extra DB read per racing key.\nB) Keep wrapper as proposed; accept TTL-bounded staleness\nEffort S with zero implementation work, risk high; coverage 3/10. Reads begun after a completed write may see a stale value for up to 30 s (10 s for absent). ✅ Zero additional work. ✅ Simplest code path. ❌ Contradicts line 48; weakens correctness, which the author policy forbids.\nC) Leave the gap as an engineering-planning TODO without amending the plan\nEffort S with zero implementation work now, risk medium; coverage 5/10. Records a TODO but no required guarantee. ✅ Short review. ✅ No plan text changes. ❌ Approves a self-contradictory plan with an untestable acceptance scenario.\n\n### Decision record R1 (D1)\nSelected: **A) Require stale-fill guard + regression scenarios**. Authority: run-rule author policy (\"Authorize complete remedies and required verification that restore its retained contracts and acceptance targets; use the recommended option\"); PLAN.md lines 114-117 (\"amend the plan with the required guarantee, a feasible remedy, its tradeoffs and deterministic regression scenarios\"). Auto-decided; no human present. Exact scope: amend the plan with the guarantee, feasibility, tradeoffs, seven regression scenarios and one dashboard counter; no data-structure or code selection, no limit, consistency or alert changes. Ledger R1 status → approved. Applied: \"Amendment R1\" and the amended wrapper paragraph in the plan body; scenarios added to the verification list.\n\n**0G HOLD SCOPE checks.** Complexity: 2-3 files, 0 new services, 1 wrapper module → no challenge. Minimum change for the goal: wrapper + R1 guard + tests; nothing deferrable without blocking acceptance (lines 122-125 require the overlapping-order scenarios). Invariants and acceptance criteria kept unchanged. No deferral proposals, so no defer/keep questions.\n\n**0I Temporal interrogation.**\n```\nHOUR 1 foundations: adapter sentinel/undefined semantics (lines 23-27); write-completion boundary (43-44); flag predicate parity (80-81)\nHOUR 2-3 core logic: R1 marker lifetime (release when readers finish); absent-DTO fill is subject to the same guard; rejected write must not mark completion\nHOUR 4-5 integration: controller rotation awaits old writes but not old reads (82-84); marker state must be per instance; bypass mode must skip marker bookkeeping cleanly\nHOUR 6+ tests: pause/release points inside repository.read and repository.write on the existing harness; hit-rate metric must exclude suppressed fills from \"hits\" and count them as misses that were admitted\n```\nFeasibility blockers: none after R1. Pending (engineering): marker data structure, exact release point, test fixture names. Effort: human ~2 days / CC+gstack ~40 min.\n\n## Review Sections (HOLD SCOPE, implementation-ready)\n\nCurrent scope: mode HOLD SCOPE (user instruction). Governing rows: R1 approved (D1 → A, author-policy auto-decision; scope: guarantee + 7 scenarios + 1 counter). Accepted: original plan + Amendment R1. Deferred: none. Rejected: D1 options B and C. Pending (engineering): marker structure, release point, fixture names.\n\n### Section 1: Architecture Review — 1 issue (F1 = R1, resolved)\n```\n caller ──> auth/authz ──> [flag predicate] ──> wrapper{readProfile,writeProfile} ──> repository.read/write ──> in-proc txn store\n │ │ cache.get/set/delete │ single-flight (per key)\n │ └──> LRU adapter (1000 / 16 MiB / 30 s / 10 s sentinel)\n └──> rollout controller: rotates wrapper+cache instance on flag change\n NEW: wrapper ordering + R1 per-key write-completion marker (inside wrapper, same tick as cache.set)\n```\nBoundaries unchanged; new coupling is wrapper→adapter only. Scaling: 10x load → hit rate rises, LRU bounded by byte cap; 100x → DB saturates on misses (cold start) and single-flight bounds concurrent misses per key. SPOF: the single process (already true). Security: no new surface (Section 3). Production failure: adapter failure → bypass (line 28-29); repo failure → typed error. Rollback: flag off, controller rotation; seconds. **F1 (CRITICAL GAP → resolved by R1):** stale fill after write completion, trace in Step 0. Apply: plan amended; no further change.\n\n### Section 2: Error & Rescue Map — 7 paths, 0 GAPS after R1\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS / RESULT\n readProfile#cache.get | adapter failure | AdapterError → adapter bypass (existing)\n readProfile#repository.read| DB failure / timeout | typed RepositoryError (existing mapping)\n | missing record | absent-result DTO (not an error)\n readProfile#cache.set | adapter failure; byte cap; stale fill(F1)| AdapterError → bypass; evict; R1 suppress\n writeProfile#repository.write | rejected (rolled back) | typed RepositoryError; cache untouched\n writeProfile#cache.delete | adapter failure | AdapterError → bypass until reinit\n controller rotation | old write never settles | existing controller contract (awaits writes)\n\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n AdapterError | Y | bypass cache, counter, alert at 1 min | normal latency from DB\n RepositoryError | Y | existing typed API error | existing API error\n absent-result DTO | n/a | sentinel 10 s | 404-equivalent as today\n stale fill (F1) | Y (R1) | suppress fill, counter | fresh data on next read\n```\nNo catch-alls; adapter and repository errors keep their classes. Apply: no change.\n\n### Section 3: Security & Threat Model — 0 issues\nNo new endpoint, input, param, dependency or secret. Keys are tenant+profile encoded post-authz (lines 18-21); cached DTOs immutable; no key/secret logging (line 20, 79). Threat: cross-tenant cache read — likelihood Low, impact High, mitigated by key encoding and existing tenant-isolation contract tests (line 72-73). Threat: stale authz result — cached values are data not decisions (line 21); unchanged. Audit: no sensitive mutation added. No issues found.\n\n### Section 4: Data Flow & Interaction Edge Cases — 6 edge cases mapped, 0 unhandled after R1\n```\n INPUT(key) -> [authz done upstream] -> cache.get -> miss -> repository.read -> cache.set -> OUTPUT\n nil/empty key: rejected upstream by key validation (line 73) tested: existing\n absent record: absent DTO -> sentinel 10 s -> same DTO on hit tested: planned (absence)\n adapter error: bypass -> DB read -> OUTPUT tested: planned (fallback)\n repo error: typed error, no fill tested: planned (rejected reads)\n oversize value: adapter byte cap evicts/skips tested: planned (byte limits)\n stale fill (F1): R1 suppress tested: R1 scenarios 1-7\n```\nAsync ordering, invariant: a read begun after writeProfile fulfills observes the committed version (lines 42-48).\n```\n time | R1 readProfile(k) | W writeProfile(k) | R2 readProfile(k) | cache[k]\n t0 | get miss; await read(v0) | | | ∅\n t1 | | write commits v1; delete; done | | ∅\n t2 | resume v0; set(k,v0) ✗ | | | v0 <- violates (original)\n t2' | resume v0; R1: marker says write completed after t0 -> skip | | ∅ <- holds (amended)\n t3 | | | begin; miss; read v1| v1\n ---- other order ----\n t0 | | write commits v1; delete; done | | ∅\n t1 | begin; miss; await read(v1)| | | ∅\n t2 | resume v1; set(k,v1) ✓ | | | v1 (no write after t1; fill kept)\n```\nOverlapping writes W1,W2 then pending read: both deletes run; R1 marker records latest completion; pending fill skipped; next read sees last commit. Rejected write: no completion marked; pending fill kept (store unchanged). Mechanism excluding other orders: single JS event loop with synchronous atomic cache ops and marker check in the same tick as cache.set. Regression proof: Amendment R1 scenarios 1-7 with pause/release at repository.read / repository.write. Interaction edge cases: no user-visible interaction added (backend only). Apply: covered by R1.\n\n### Section 5: Code Quality Review — 0 issues\nWrapper fits the read-through pattern; names (readProfile/writeProfile) describe behavior; no duplication (single-flight stays in repository, line 32). Branches in readProfile after R1: miss, error, suppress → under 5. No over-engineering; the marker is the minimum mechanism for the retained contract. No issues found.\n\n### Section 6: Test Review — diagram produced, 0 gaps (R1 scenarios required)\n```\n NEW THING | TYPE | HAPPY | FAILURE | EDGE\n readProfile hit/miss | unit | hit returns cached | adapter error -> bypass | absent sentinel decode\n readProfile fill | unit | miss fills | repo rejects -> no fill | byte cap, TTL expiry\n writeProfile invalidate | unit | commit -> delete | reject -> cache preserved | overlapping writes\n R1 stale-fill guard | unit(harness)| scenario 2 fill kept | scenario 1,3,6 suppressed | scenario 4,5 (rejects), 7 (instances)\n single-flight interaction | integration | concurrent misses coalesce | release on failure | cohort retire on write\n controller rotation | integration | old-writer/new-reader order| predicate parity | disabled instance bypass\n```\nAssertion check: every behavior maps to lines 72-76, 85-86, 122-125 or Amendment R1; wrong result rejected = stale v0 observed after completion. Existing deterministic harness (line 122); no time/randomness dependence (TTL via controlled clock). 2 am test: scenario 1 + 3. Hostile QA: scenario 6 with a rejected write interleaved. Chaos: adapter throws mid-set → bypass and counter. Pyramid: unit-heavy, few integration, no E2E. Load: cold-start miss storm bounded by single-flight; covered by rollout stage gates. Apply: no new choice; scenarios already approved under R1.\n\n### Section 7: Performance Review — 0 issues\nNo new queries or indexes. Memory bounded: ≤1000 entries / 16 MiB plus marker state bounded by in-flight read keys (released when readers finish). Slow paths: miss + DB read (today's p95), fill-suppressed re-read (one extra DB read, rare), cold start. No pool pressure change. No issues found.\n\n### Section 8: Observability & Debuggability — 0 gaps\nExisting metrics: hit/miss, eviction, bytes, fallback errors, DB CPU, p95 (line 78-79). Added under R1: `stale_fill_suppressed` counter on the existing dashboard, no key labels; a suppressed fill counts as an admitted miss. Alerts unchanged (lines 88-90). Debuggability: counters plus existing structured repository logs reconstruct bypass/suppression episodes. Runbook: existing (lines 99-100); suppression spike reads as \"write-heavy keys\", not an incident. No new observability project. No gaps.\n\n### Section 9: Deployment & Rollout — 0 risks beyond existing plan\nNo migration. Flag staged 10/50/100 with one healthy hour and the stage criteria at lines 94-98. Rollback: flag off → controller drains writes and publishes a bypass instance → seconds. Deploy window: old code has no cache, new code flag-off → identical behavior. Post-deploy: first 5 min watch fallback errors and p95; first hour watch hit rate, DB CPU, suppressed-fill counter. Smoke: existing contract tests plus wrapper tests in CI. Apply: no change.\n\n### Section 10: Long-Term Trajectory — reversibility 5/5, debt items 0\nDebt: none beyond a documented marker that disappears with any future distributed cache (out of scope, line 106-108). Path dependency: interface preserved. Knowledge: Amendment R1 plus the schedule diagram document the why. Ecosystem fit: same repository pattern. One-year: obvious.\n\n### Section 11: Design & UX Review — SKIPPED (no UI scope)\n\n## Closing sequence\n**Outside Voice:** `codex_reviews: disabled` (config). Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`. No native fallback. Disabled-coverage review-log write: gstack binaries not installed in this fixture → **not persisted** (fields: skill codex-plan-review, status skipped, source none, outside_provider codex, outside_status disabled, phase plan-review).\n**TODO choices:** none remain (HOLD SCOPE; no evidenced deferred gap).\n**Approval readiness: PASS** — R1: approved via D1 → A (author-policy auto-decision recorded above); plan applies only that scope. No other rows.\n\n## NOT in scope\nDeferred: none. Rejected: D1-B (TTL-bounded staleness; contradicts line 48), D1-C (unamended TODO; untestable acceptance). Pre-existing exclusions retained (lines 105-108).\n\n## What already exists\nRepository + typed errors, LRU adapter with limits and absent sentinel, single-flight, transactional store, rollout controller/flag, telemetry/dashboards/runbooks, contract harness — all reused unchanged.\n\n## Dream state delta\nAfter this plan: hot-key reads served in-process with the retained consistency contract intact; the repository interface still allows a backend swap later. Remaining gap to the 12-month ideal: multi-process support (explicitly out of scope).\n\n## Error & Rescue Registry\nSee Section 2 table (7 codepaths, 4 exception classes, 0 CRITICAL GAPS after R1).\n\n## Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n readProfile.cache.get | adapter failure | Y bypass | Y planned | normal latency | Y counter\n readProfile.repo.read | DB failure | Y typed | Y existing | API error | Y existing\n readProfile.cache.set | stale fill (F1) | Y (R1) | Y scen 1-7 | fresh data | Y counter\n readProfile.cache.set | byte cap / evict | Y | Y planned | miss | Y metric\n writeProfile.repo.write | rejected/rolled back | Y typed | Y existing | API error | Y existing\n writeProfile.cache.delete| adapter failure | Y bypass | Y planned | normal latency | Y counter\n controller rotation | old write pending | Y await | Y existing | brief pause | Y existing\n```\n0 CRITICAL GAPS.\n\n## Diagrams\n1. System architecture — Section 1. 2. Data flow with shadow paths — Section 4. 3. State machine (cache entry):\n```\n ∅ --fill(ok, no newer write)--> VALUE(30 s) --TTL/evict/delete--> ∅\n ∅ --fill(absent)--> SENTINEL(10 s) --TTL/delete--> ∅\n ∅ --fill(newer write completed)--> ∅ (R1 suppress) invalid: VALUE->VALUE via stale fill (prevented by R1)\n any --adapter failure--> BYPASS --reinit--> ∅\n```\n4. Error flow — Section 2. 5. Deployment sequence:\n```\n deploy(flag off) -> 10% -> 1 h healthy -> 50% -> 1 h healthy -> 100% -> absolute targets hold\n```\n6. Rollback flowchart:\n```\n breach/alert? --yes--> flag off -> controller drains writes -> bypass instance -> runbook incident -> resume criteria\n --no---> continue stage\n```\nStale Diagram Audit: no ASCII diagrams exist in touched files; none stale.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above.\n\n- [ ] **T1 (P1, human: ~1 day / CC: ~30min)** — profile-summary wrapper — Implement the R1 stale-fill guard per Amendment R1 (structure chosen in engineering planning)\n - Surfaced by: Section 1/4 — F1 stale fill after write completion (Step 0 trace)\n - Files: to be determined\n - Verify: Amendment R1 scenarios 1-7 pass on the existing deterministic harness\n- [ ] **T2 (P1, human: ~4h / CC: ~10min)** — telemetry — Add `stale_fill_suppressed` counter to the existing dashboard, no key labels; count suppressions as admitted misses\n - Surfaced by: Section 8 — R1 telemetry requirement\n - Files: to be determined\n - Verify: counter visible on existing dashboard in staging; hit-rate formula excludes suppressed fills from hits\n- _No new tasks from Sections 3, 5, 7, 9, 10, 11._\nTask JSONL: gstack state root unavailable in this fixture → **not persisted**.\n\n## Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run rules; fixture repo |\n | Step 0 | HOLD SCOPE; R1 approved (D1 → A) |\n | Section 1 (Arch) | 1 issue found (F1, resolved by R1) |\n | Section 2 (Errors) | 7 error paths mapped, 0 GAPS |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 6 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 0 gaps found |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 0 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (2 rejected, 0 deferred) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 7 rows, 0 CRITICAL GAPS |\n | Failure modes | 7 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (HOLD) |\n | CEO plan | skipped by mode |\n | Outside voice | codex — disabled |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 |\n +====================================================================+\n```\nUnresolved Decisions: none. Review Log / decision log / dashboard read: gstack binaries unavailable in this fixture → **not persisted** (fields: skill plan-ceo-review, status clean, unresolved 0, critical_gaps 0, mode HOLD_SCOPE, scope 0/0/0, commit 90be9a3). Learnings: no durable learnings this session. Next step: /plan-eng-review (required gate).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps (1 finding F1 resolved by R1) |\n| Outside Review | codex (`codex_reviews: disabled`) | Independent 2nd opinion | 0 | DISABLED | codex_reviews disabled; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |\n\n**OUTSIDE COVERAGE:** codex, plan-review phase, disabled by config; no completed external review; 0 outside findings (none run).\n**VERDICT:** CEO CLEARED — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37179171083-t1-pass.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37179171083-t1-pass.json
new file mode 100644
index 000000000..6faae79d5
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37179171083-t1-pass.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "c040a0214e4f3172",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Genuine pass: approved R1 ledger row with a stale-fill currentDecision, and the prose finding also matches; both witnesses agree.",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nare the complete new read/write ordering rules; no additional version checks or\ncoordination between a cache fill and a write are proposed in the original draft. **Amended by CEO review decision R1 (D1 → A):** the wrapper MUST NOT install a cache fill from a read that began before a write to the same key committed and whose writeProfile has since run (or is running). Required guarantee: at fill time the wrapper synchronously checks a per-key write generation (or equivalent) recorded while the miss is in flight; if any write to that key committed since the miss began, the fill is skipped, the value is still returned to the caller, and the skipped fill is counted. Coordination state is bounded by in-flight misses and removed when the last in-flight miss for the key settles. Exact data structures and function bodies are left to engineering planning. The original draft below is retained as the baseline being amended:\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\n**Added by R1 (D1 → A), deterministic scenarios on the existing contract test\nharness with pause/release points:** (S1) read misses and pauses inside\nrepository.read; write commits and writeProfile fulfills; read resumes with the\nearlier snapshot: assert no fill installed, a fresh read observes the committed\nversion, suppressed-fill count +1. (S2) same but the read resumes BEFORE the write\ncommits and fills: assert writeProfile's cache.delete removes it and the next read\nobserves the committed version. (S3) missing record: read obtains the absent DTO,\ncreate commits and completes, read resumes: assert no absent sentinel installed\nand the next read observes the created record. (S4) overlapping writes W1, W2 with\na read in flight: assert the next read after both complete observes W2's version.\n(S5) rejected read leaves cache untouched; rejected write leaves cache and\ngeneration unchanged. (S6) controller instance swap during an in-flight miss:\nold read cannot fill the new instance (existing contract) and the new instance\nserves the committed version. The suppressed-fill branch is recorded through the\nexisting telemetry on the current dashboard with no key labels; no new alert or\nmetric project.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n# CEO REVIEW (plan-ceo-review, HOLD SCOPE) — 2026-10-04\n\n## Step 0: setup, ledger and scope challenge\n\n**Review depth:** implementation-ready at the wrapper codepath level (readProfile /\nwriteProfile, adapter, controller). Per the author's acceptance requirements, exact\ndata structures, full function bodies and executable test code are reserved for\nengineering planning; this review names required guarantees, codepaths, rescue\nbehavior and regression scenarios only.\n**Storage policy:** working plan = this file (requested output). Appends for new\nreview content, scoped Edits for plan amendments. gstack state/log/telemetry\nwrites are out of this run's scope (hermetic fixture); such fields are shown\n**not persisted**. System audit, environment setup and telemetry skipped per run\ninstructions. Outside review: `codex_reviews: disabled` in\n`.gstack-section-state-b2PTft/config.yaml`.\n**Stated limits recorded:** LRU 1000 entries; 16 MiB byte cap; 30 s value TTL;\n10 s absent-sentinel TTL; targets hits >= 60% (admitted requests), DB CPU < 50%,\nread p95 < 60 ms at 100%; stage baselines 70% CPU / 120 ms p95; alerts: p95 > 120 ms\nfor 5 min, bypass persisting 1 min, any correctness/error-SLO breach; one healthy\nhour per stage at 10% / 50% / 100%. Deliverables counted against scope: 1 wrapper\nmodule (new), 1 wrapper test file (new), controller wiring reuse (unchanged).\nEstimated changed files: 2-3 (estimate).\n\n### Decision ledger (continued through all sections)\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| R1 — Section 1 (Architecture), carried to Section 4 | PLAN.md \"Existing contracts retained\": every read begun after writeProfile fulfills must observe the committed version; TTL is not a substitute. PLAN.md \"Proposed wrapper integration\": `cache.set(key, value)` after `await repository.read` with no fill/write coordination; fixture fact \"already-started readers may finish with their earlier snapshot\" and \"this admission rule does not inspect cache fills\". | Wrapper as proposed: unconditional fill after read resolves. | A: suppress fills that overlap a committed write; B: install then delete; C: keep as proposed (rejected: weakens retained contract). | approved | D1 auto-decided → A under the run's author policy (\"authorize complete remedies and required verification that restore its retained contracts ... use the recommended option\"). Scope: wrapper fill-time guard with bounded in-flight state, suppressed-fill count on existing dashboard (no key labels), scenarios S1–S6 on the existing harness. Not approved: adapter/repository/controller changes, new metrics projects, data-structure or test-code selection. |\n\n### 0A. Premise Challenge\nReal problem: ~900 hot profile-summary keys are re-read so often that one DB\ncarries 70% CPU and 120 ms read p95. Target outcome: >= 60% of admitted reads served\nfrom process memory, DB CPU < 50%, read p95 < 60 ms, SLOs unchanged. Do-nothing\ncost: DB stays near saturation; any traffic growth pushes p95 and error rate up.\nThe plan attacks the pain directly (fewer repeated store reads), not a proxy.\nPremise holds under HOLD SCOPE; one consistency contract (R1) is at risk and is\nreviewed, not assumed away.\n\n### 0B. Existing Code Leverage\n| Sub-problem | Reused existing code | Note |\n|---|---|---|\n| Bounded in-memory storage, TTL, absent sentinel, failure bypass | existing LRU cache adapter | fixture fact; no rebuild |\n| Coalescing concurrent misses | per-key single-flight inside repository.read | fixture fact; does not inspect fills |\n| Atomic commit / rollback | repository.write on in-process transactional store | fixture fact |\n| Staged rollout, instance isolation, metrics | existing rollout controller + telemetry + dashboards | fixture fact |\n| New: read-through ordering + write invalidation | new wrapper (readProfile/writeProfile) | the only new code |\nNo rebuild is proposed; refactoring the existing path is not needed.\n\n### 0C. Dream State Mapping\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n single process, every read ---> process-local LRU in front ---> same single-process service,\n hits the store; DB CPU 70%, of repository; >=60% hits, read-through boundary stable\n read p95 120 ms DB CPU <50%, p95 <60 ms, enough that a later replacement\n consistency contract intact (if ever needed) is a swap\n behind the same interface\n```\nThe plan moves toward the ideal without adding a cache framework. Distributed\ncaching stays out of scope by author decision.\n\n### 0D. Alternatives\nNo approach choice was required at Step 0: the requested approach (local LRU\nwrapper over the existing repository) is retained. 0D is used later for R1.\n\n### 0E. Mode Selection\nExplicit user choice: HOLD SCOPE (\"Hold the current scope (HOLD SCOPE mode)\").\nSteps 2-3 skipped. Mode handoff: `Mode: HOLD SCOPE; approved decisions: none.`\nApplication: preserve scope, maximum rigor on failures, edge cases, error paths,\ntests and observability; repairs needed to meet retained invariants are in scope.\nProvenance: explicit user instruction; no question asked, no question log.\nNo new approach decision was needed.\n\n### 0G. HOLD SCOPE checks\n1. Complexity: 2-3 changed files, 1 new module, 0 new services. Below the\n 8-file / 2-service threshold. No fewer-moving-parts alternative exists; the\n wrapper is the minimum.\n2. Minimum change for the goal: the two wrapper functions plus tests. Nothing in\n the accepted scope is deferrable without blocking acceptance (the contract\n tests, rollout stages and alerts are launch scope under Prime Directive 5).\n3. Invariants and acceptance criteria kept as stated; any repair required to meet\n them (R1) is in scope.\nDeferral menu: no item proposed for deferral; nothing to ask.\n\n### 0I. Temporal Interrogation\n```\n HOUR 1 (foundations): implementer needs the retained contracts verbatim (absent DTO,\n sentinel TTL, single-flight cohort rule, write-completion boundary).\n HOUR 2-3 (core logic): ambiguity: what happens to a fill from a read that began before a\n write committed and resolves after writeProfile fulfilled (R1).\n HOUR 4-5 (integration): surprise: controller instance swaps isolate old readers, but do\n NOT coordinate a fill and a write inside one active instance.\n HOUR 6+ (polish/tests): wish they had planned pause/release harness hooks for both\n completion orders of overlapping reads and writes, and for\n missing-record creation under the absent sentinel.\n```\nEffort: human ~2-3 days / CC+gstack ~1-2 h for wrapper + tests (tests ~50x ratio).\nFeasibility blockers: R1 (resolved below before the sections proceed). Pending\nchoices: none other than R1.\n\n## Review Sections\n\n**Current scope (published before Section 1):** Mode HOLD SCOPE (explicit user\ninstruction). Governing rows: R1 unresolved (pending below). Accepted work: the\nplan as written above. Deferred: none. Rejected: none. Pending: R1.\n\n### Section 1: Architecture Review\nDependency graph (new component marked *):\n```\n caller --> rollout controller (predicate, instance swap)\n |\n v\n *wrapper {readProfile, writeProfile}\n | |\n v v\n LRU cache adapter repository.read / repository.write\n (1000 / 16 MiB / | (single-flight cohort inside read)\n 30 s, sentinel 10 s) v\n in-process transactional store\n```\nData flow, readProfile:\n```\n happy : get(key) hit -> return DTO | miss -> await read -> set -> return DTO\n nil : record missing -> read returns absent DTO -> set stores sentinel (10 s) -> get decodes to absent DTO\n empty : key validation runs before repository access (fixture); empty/invalid key never reaches wrapper\n error : read rejects -> no set, cache untouched -> typed API error (existing mapping)\n adapter failure -> adapter bypasses cache until reinit -> read goes to store\n```\nData flow, writeProfile:\n```\n happy : await write (commit) -> delete(key) -> fulfill (write \"completes\" at fulfill)\n error : write rejects (rolled back) -> no delete -> reject with typed error; cache preserved (correct)\n adapter failure on delete -> adapter bypass (fixture); write already committed; wrapper fulfills\n```\nCoupling: wrapper depends on adapter + repository only; controller owns lifecycle.\nJustified; no new cross-module coupling. Scaling: 10x load keeps the hot set at\n~900 keys < 1000 entries; 100x load with the same key set is served from memory;\na key set beyond 1000 entries thrashes the LRU and the hit target fails loudly on\nthe dashboard (no silent failure). SPOF: unchanged (single process, single DB).\nSecurity: no new endpoint or mutation surface; auth precedes repository access\n(fixture). Rollback: flag disable -> controller publishes a disabled instance\n(bypass both paths); seconds.\n\n**Production failure scenario (CRITICAL GAP, row R1):** a read misses, awaits\nrepository.read and obtains snapshot v0; a write commits v1, cache.delete runs\n(no entry yet), writeProfile fulfills; the read then resolves with v0 and\n`cache.set(key, v0)` installs a stale entry. A read begun after the write\ncompleted hits v0 for up to 30 s (or 10 s for an absent sentinel after a create).\nThis violates the retained contract \"every read begun after that write completes\nmust observe the committed version\". Evidence: the plan states no fill/write\ncoordination; the fixture cohort rule explicitly lets already-started readers\nfinish with their earlier snapshot and does not inspect fills; controller\nisolation only applies across instance swaps. This is a contract-level\ncontradiction, not an unavailable-implementation guess.\n\nOther outcome: **OK** on boundaries, coupling, scaling, SPOF, security, rollback.\n**Decision gate:** Resolve path 1 (new choice). 0D plan-decision route, row R1.\n\n## currentDecision (R1)\nCommitment comparison:\n```text\nCommitment | Source/approval or pending | Current (plan as written) | A | B | C\nReads after a completed write see committed | PLAN.md retained contracts (fixed) | violated by stale fill (gap) | met: fill suppressed when a write to key | met: fill suppressed by re-deleting key after | not met: contract would be weakened\nversion | | | committed during the in-flight miss | every fill whose read overlapped a write | (not authorizable in this review)\nWrapper ordering rules | pending (R1) | get -> await read -> set | add synchronous per-key write-generation | add per-key \"write-seen\" flag consulted after | unchanged\n | | | check at fill time (in wrapper instance) | set to delete; still momentarily installs |\nMemory for coordination state | pending (R1) | none | bounded by in-flight misses (entries | bounded by in-flight misses | none\n | | | removed when last miss settles) | |\nAdapter / repository / controller contracts | fixture facts (fixed) | unchanged | unchanged | unchanged | unchanged\nRegression scenarios (both completion orders,| author acceptance reqs (fixed) | listed as required, not yet | deterministic pause/release scenarios | same scenarios | same scenarios, but one must be\nmissing-record create, rejected ops, | | specified against R1 | specified below; fill-suppressed branch | | changed to expect stale data\noverlapping writes, instance isolation) | | | asserted | | (forbidden)\nTelemetry for added branch | author acceptance reqs (fixed) | n/a | count suppressed fills on existing | same | n/a\n | | | dashboard, no key labels | |\nChanged files | estimate | 2-3 | 2-3 (same files) | 2-3 (same files) | 2-3\n```\n\nQuestion: D1 — R1: How should the wrapper stop a stale cache fill from a read that began before a write committed?\nProject/branch/task: main branch, PLAN.md \"cache profile summaries in one process\", CEO review in HOLD SCOPE.\nELI10: A reader asks the database for a profile, and while it waits, a writer saves a newer profile and clears the cache. The reader's old answer then arrives and gets stored in the cache, so everyone after sees the old profile for up to 30 seconds. The plan promises that never happens, so the wrapper needs a rule that refuses to store an answer that is older than a write that already finished.\nStakes if we pick wrong: users read a profile they just updated and see the previous version; the correctness SLO breaks; the rollout trips its alert and rolls back.\nRecommendation: A because a synchronous fill-time check against a per-key write generation is the smallest explicit mechanism that satisfies the contract in a single event loop, with bounded state and one testable branch (explicit over clever).\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Suppress fills that overlap a committed write (recommended)\n ✅ Meets the retained contract in both completion orders without touching the adapter, repository or controller.\n ✅ Adds one synchronous branch with bounded state; the suppressed-fill count is observable on the existing dashboard.\n ❌ A hot key under constant writes may rarely fill, so its reads keep going to the store (visible as lower hit rate, not as incorrectness).\nB) Install the fill, then delete it when a write overlapped\n ✅ Also keeps the adapter and repository unchanged and reuses cache.delete.\n ✅ Simple to reason about: every overlapped fill is removed before the reader returns.\n ❌ The stale entry exists for a moment between set and delete; a concurrent synchronous get in that window is impossible, but the design relies on strict ordering inside one function and is harder to prove and test than never installing.\nC) Keep the wrapper as proposed and accept TTL-bounded staleness\n ✅ Zero implementation work beyond the plan as written (effort S, no new state).\n ✅ Simplest code path.\n ❌ Contradicts the retained consistency contract and the \"TTL is not a substitute\" clause; not authorizable by this review.\nNet: A pays a tiny amount of bounded in-process state to make the contract hold by construction; B pays the same but with a weaker proof; C is not an option under the retained requirements.\n\nHeader: R1 stale cache fill\nA) Suppress fills that overlap a committed write (recommended)\nEffort S, risk low. Reuse: existing adapter, repository and controller unchanged; one new synchronous check in the wrapper instance keyed by the same cache key, tracking a per-key write generation (or equivalent) only while a miss is in flight, removed when the last in-flight miss for that key settles. Verification coverage: both completion orders of overlapping read/write, missing-record creation under the absent sentinel, overlapping writes, rejected reads/writes, instance isolation, suppressed-fill branch asserted and counted. ✅ Meets the retained contract in both completion orders without touching the adapter, repository or controller. ✅ Adds one synchronous branch with bounded state; the suppressed-fill count is observable on the existing dashboard. ❌ A hot key under constant writes may rarely fill, so its reads keep going to the store (visible as lower hit rate, not as incorrectness).\nB) Install the fill, then delete it when a write overlapped\nEffort S, risk medium. Reuse: adapter, repository and controller unchanged; wrapper installs the value, then calls cache.delete when a per-key write flag was raised during the miss. Verification coverage: same scenarios as A plus an ordering assertion that no get can observe the entry between set and delete. ✅ Also keeps the adapter and repository unchanged and reuses cache.delete. ✅ Simple to reason about: every overlapped fill is removed before the reader returns. ❌ The stale entry exists for a moment between set and delete; a concurrent synchronous get in that window is impossible, but the design relies on strict ordering inside one function and is harder to prove and test than never installing.\nC) Keep the wrapper as proposed and accept TTL-bounded staleness\nEffort S, risk high; zero implementation work. Reuse: everything as written. Verification coverage: would require changing a required scenario to expect stale data, which the retained contract forbids. ✅ Zero implementation work beyond the plan as written (effort S, no new state). ✅ Simplest code path. ❌ Contradicts the retained consistency contract and the \"TTL is not a substitute\" clause; not authorizable by this review.\n\n**D1 answer record (R1):** Auto-decided D1 → A (authority: this run's author policy\nauthorizes the recommended complete remedy restoring retained contracts; no human\npresent). Applied: \"Proposed wrapper integration\" amended with the required\nguarantee; \"Verification and rollout\" amended with scenarios S1–S6 and the\nsuppressed-fill telemetry branch; ledger row R1 → approved. Residual risk: a key\nunder constant writes may rarely fill (visible as lower hit rate, not stale data).\n\n### Section 2: Error & Rescue Map\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS\n ---------------------------|--------------------------------------------|---------------------------\n readProfile#cache.get | adapter failure | adapter internal (bypass)\n readProfile#repository.read| store read fails / rejects | existing typed repository error\n | record missing | none (absent DTO, not an error)\n | stale snapshot resolves after completed | none (logic gap) -> R1 guard\n | write |\n readProfile#cache.set | adapter failure / byte cap exceeded | adapter internal (bypass / evict)\n writeProfile#repository.write | write rejects (rolled back) | existing typed repository error\n writeProfile#cache.delete | adapter failure after commit | adapter internal (bypass)\n controller swap | in-flight old reads during swap | none (isolation contract)\n ---------------------------|--------------------------------------------|---------------------------\n\n EXCEPTION / CONDITION | RESCUED? | RESCUE ACTION | USER SEES\n ------------------------------|-----------------|---------------------------------------|------------------\n adapter failure (any op) | Y (adapter) | bypass cache until empty reinit; | normal result, slower;\n | | bypass alert at 1 min | owner paged\n typed repository read error | Y (existing map)| re-raise via existing API mapping; | existing API error\n | | no fill |\n typed repository write error | Y (existing map)| re-raise; cache untouched (no commit) | existing API error\n absent record | n/a | absent DTO; sentinel 10 s | not-found result\n stale fill after write (R1) | Y after R1 | skip fill, return value, count branch | committed version\n```\nNo catch-alls proposed; the wrapper adds no new exception classes. **Outcome:**\n1 GAP (R1) mapped and resolved by D1 → A; otherwise OK. Decision gate: path 2\n(R1 exact answer cited); no new choice.\n\n### Section 3: Security & Threat Model\n| Threat | Likelihood | Impact | Mitigated |\n|---|---|---|---|\n| Cross-tenant cache read via key collision | Low | High | Yes: keys encode authenticated tenant + validated profile ID unambiguously (fixture); authz precedes repository access |\n| Cached result bypassing authorization | Low | High | Yes: fixture contract; cache sits below the authz boundary |\n| Key or secret leakage in logs/metrics | Low | Med | Yes: no raw IDs in metrics; the R1 suppressed-fill counter must carry no key labels (author requirement) |\n| Memory exhaustion via unbounded coordination state | Low | Med | Yes by R1 scope: state bounded by in-flight misses |\n| Cache poisoning via mutable DTO | Low | High | Yes: values are immutable DTOs (fixture) |\nNo new endpoint, input, dependency or background job. **Outcome:** No issues\nfound (0 High-severity open). Decision gate: no choice needed.\n\n### Section 4: Data Flow & Interaction Edge Cases\nData flow with shadow paths:\n```\n INPUT key -> VALIDATION (upstream authz/key validation) -> TRANSFORM (get / read) -> PERSIST (set / delete) -> OUTPUT DTO\n nil/empty key ........ rejected upstream before wrapper (fixture) tested: existing contract tests\n invalid DTO .......... impossible; immutable DTO from repository n/a\n exception/timeout .... read rejects -> no set -> typed error tested: rejected reads (S5)\n conflict/dup ......... overlapping writes -> last committed wins (S4) tested: S4\n stale/partial ........ stale snapshot fill -> R1 guard skips fill (S1) tested: S1-S3\n```\nAsync ordering, boundary = \"read begun after writeProfile fulfills observes the\ncommitted version\":\n```\n t | readProfile R (miss) | writeProfile W | cache[key] | gen[key]\n --|-----------------------------------|----------------------------------|-----------------|---------\n 1 | get -> miss; record gen=g0; await | | empty | g0\n 2 | (paused in repository.read, v0) | await write -> commit v1 | empty | g0\n 3 | | gen++ ; delete(key); fulfill | empty | g1\n 4 | read resolves v0; gen g1 != g0 | | empty (skipped) | g1\n 5 | return v0 to R's caller (allowed: R began before W completed) | |\n 6 | fresh read R2: miss -> read v1 -> gen unchanged -> set v1 | v1 | g1\n```\nOrder 2 (read resolves before commit): t2 read resolves v0, gen==g0, set v0;\nt3 W commits, gen++, delete(key) removes v0; R2 reads v1. Both orders satisfy the\nboundary with the R1 guard; without it, order 1 leaves v0 cached (violation).\nThe excluded schedule \"get observes an entry between set and delete\" is prevented\nby synchronous atomic cache ops in one event loop (fixture). Regression proof:\nS1 (order 1), S2 (order 2), S3 (absent sentinel), S4 (overlapping writes), S6\n(instance swap), with controlled pause/release points on the existing harness.\nInteraction edge cases: no user-visible interaction changes (backend only).\n**Outcome:** 5 edge cases mapped, 0 unhandled after R1. Decision gate: path 2\n(cites R1).\n\n### Section 5: Code Quality Review\nTwo small functions fit the existing read-through repository pattern. No\nduplicated behavior: invalidation and fill live only in the wrapper. Naming\n(readProfile/writeProfile) describes outcome. Error handling delegates to\nexisting typed mapping and adapter bypass (Section 2). The R1 guard adds one\nbranch; readProfile stays well under 5 branches. Not over-engineered: no\nframework, no version column, no adapter change. Under-engineering risk was the\nunconditional fill (R1, resolved). Require an ASCII comment above the wrapper\ndescribing the fill/write ordering (engineering preference: ASCII comments for\ncomplex state). **Outcome:** 1 issue (R1, resolved); comment requirement is a\ntask (T2). Decision gate: no new choice.\n\n### Section 6: Test Review\n```\n NEW THING | TEST TYPE | HAPPY | FAILURE | EDGE\n ---------------------------------|-------------|----------------------------|--------------------------------|-----------------------------\n readProfile hit/miss | unit | hit returns cached DTO | read rejects -> typed error | absent DTO -> sentinel decode\n readProfile fill guard (R1) | unit/harness| S2 fill then delete | S1 stale fill suppressed | S3 absent sentinel suppressed\n writeProfile invalidation | unit | commit -> delete -> fulfil | rejected write preserves cache | S4 overlapping writes\n adapter failure fallback | unit | bypass, results correct | bypass persists -> alert | failure on delete after commit\n eviction / byte cap / TTL | unit | LRU evicts oldest | over-cap value not stored | 1000th vs 1001st entry\n controller swap isolation | integration | new instance fresh cohort | old reader cannot fill new | S6 swap mid-miss\n suppressed-fill telemetry | unit | counter +1 on S1 | no key labels asserted | zero when no overlap\n```\nAssertion check: every row maps to a retained contract or to R1's approved scope;\nS1–S6 are the exact scenarios the author required (both orders, missing-record\ncreation, rejected ops, overlapping writes, instance isolation). Existing contract\ntests cover tenant isolation, key validation, absence, DB failures, authz,\nmulti-process rejection. 2am test: S1. Hostile QA: S4 plus swap mid-miss (S6).\nChaos: adapter failure injected mid-fill while a write commits (bypass must win).\nPyramid: mostly unit, few integration, no E2E (correct for backend). Flakiness:\nTTL tests must use the harness's controlled clock, not wall time. Load: existing\nrollout stages serve as the load test; no separate load test requested.\n**Outcome:** diagram produced, 0 gaps after R1 (TTL-clock note is a task, T3).\nDecision gate: path 2 (R1) and settled proof reuse; no new choice.\n\n### Section 7: Performance Review\nN+1: none (point reads). Memory: <= 1000 entries / 16 MiB adapter cap plus R1\nstate bounded by in-flight misses (hundreds of entries at most). Indexes: no new\nqueries. Caching: this is the caching. Jobs: none. Slow paths: cold miss = store\nread (today's p95 120 ms); hit = sub-millisecond; suppressed fill = store read on\nthe next read of that key. Floor: 900 hot keys / 30 s TTL >= 30 store reads/s\nplus suppressed fills on write-hot keys; within existing DB capacity (fixture:\ncold starts remain within capacity). Connection pools: unchanged. **Outcome:** No\nissues found; residual note: write-hot keys lower the hit rate, surfaced on the\ndashboard. Decision gate: no choice.\n\n### Section 8: Observability & Debuggability Review\nMetrics (existing telemetry): hit, miss, eviction, cache bytes, fallback/bypass\nerrors, DB CPU, read p95, plus the R1 suppressed-fill count on the current\ndashboard with no key labels (author requirement; not a new metric project).\nLogging: adapter failure already logs without keys/secrets; the wrapper adds no\nkey logging. Alerts: existing (SLO breach, p95 > 120 ms for 5 min, bypass > 1 min);\nno new threshold requested. Debuggability: a stale-read report three weeks later is\nreconstructable from suppressed-fill and bypass counters plus the flag stage\ntimeline. Runbook: existing; it must name \"suppressed fills climbing\" as expected\non write-hot keys, not an incident (task T4, documentation only). **Outcome:** 1\ngap (runbook note), resolved as a documentation task within existing runbook\nscope. Decision gate: no choice (no new observability project).\n\n### Section 9: Deployment & Rollout Review\nNo migration. Feature flag: existing runtime flag, 10% -> 50% -> 100% with one\nhealthy hour each and defined criteria (plan). Rollout order: deploy wrapper\ndisabled, then raise percentage. Rollback: disable flag -> controller stops\nadmitting, drains old writes, publishes disabled instance; seconds; runbook\nrecords incident. Deploy-time risk window: one process, so no mixed versions; the\ncontroller swap contract covers in-flight work. Environment parity: stage runs\nunder the same harness; first 5 minutes watch bypass and hit rate; first hour\nwatch the healthy-hour criteria. Smoke: hit/miss counters moving, zero bypass.\n**Outcome:** 0 risks flagged beyond the plan's existing controls. Decision gate:\nno choice.\n\n### Section 10: Long-Term Trajectory Review\nDebt: small (one guard with bounded state, one ASCII comment, runbook note).\nPath dependency: none; the repository interface preserves a replacement path.\nKnowledge: the ordering diagram in Section 4 belongs in the wrapper comment (T2).\nReversibility: 5/5 (flag off or delete two functions). Ecosystem fit: uses the\nexisting adapter and harness. 1-year question: obvious, provided the comment\nexplains why fills are suppressed. **Outcome:** reversibility 5/5, debt items 2\n(comment, runbook note). Decision gate: no choice.\n\n### Section 11: Design & UX Review\n`SKIPPED (no UI scope)` — internal backend change, no UI, API or user-visible state.\n\n## Closing sequence\n\n### Outside Voice\n`codex_reviews: disabled` (`.gstack-section-state-b2PTft/config.yaml`). Codex\nreview skipped (codex_reviews disabled). Re-enable: `gstack-config set\ncodex_reviews enabled`. No prompt, outside process or native fallback. Disabled\ncoverage record (`gstack-review-log` codex-plan-review, status skipped,\noutside_status disabled): **not persisted** (state writes out of this run's scope).\n\n### Resolve remaining TODO choices\nNone remain. HOLD SCOPE: no evidenced gap is deferred; every finding is in\naccepted scope (R1) or a same-branch task.\n\n### Approval readiness\nChecked rows: R1 — approved via D1 auto-decision (authority: run author policy;\nrecommended option A). Plan applies only that scope (wrapper guard, bounded state,\nS1–S6, suppressed-fill count on the existing dashboard). No declined, deferred or\nunanswered changes in accepted work. Option C declined and kept out.\n`Approval readiness: PASS` (R1 → D1/A).\n\n## Required Outputs\n\n### Review facts\nMode HOLD SCOPE; 1 critical gap found (R1) and resolved; 0 unresolved choices;\n0 critical gaps open; scope proposals 0 / accepted 0 / deferred 0; outside\ncoverage: codex disabled, no completed external review. Status: `clean`.\n\n### NOT in scope\nDeferred: none. Rejected: D1 option C \"keep the wrapper as proposed and accept\nTTL-bounded staleness\" — contradicts the retained consistency contract. Author's\nout-of-scope list retained: distributed caching, cross-process coherence,\nprewarming, consistency-semantics changes, new product surfaces.\n\n### What already exists\nSee 0B: LRU adapter (limits, TTL, sentinel, bypass), single-flight cohort in\nrepository.read, atomic repository.write, rollout controller with instance\nisolation, telemetry/dashboards/runbooks, contract test harness. All reused.\n\n### Dream state delta\nSee 0C. After this plan the service meets its DB CPU / p95 targets with the\nconsistency contract intact and a stable read-through boundary; the 12-month\nideal needs no further caching work unless the key set outgrows 1000 entries.\n\n### Error & Rescue Registry\nSee Section 2 tables (7 codepath rows, 1 GAP → resolved by R1; 0 CRITICAL GAPS).\n\n### Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n ----------------------------|------------------------------|--------------|------------|---------------------|---------------------\n readProfile#cache.get/set | adapter failure | Y (bypass) | Y | slower, correct | Y (no keys)\n readProfile#repository.read | store error | Y (typed) | Y | existing API error | Y (existing)\n readProfile#repository.read | record missing | n/a | Y | not-found result | n/a\n readProfile fill | stale snapshot after write | Y (R1 guard) | Y (S1,S3) | committed version | Y (suppressed count)\n writeProfile#repository.write| write rejected | Y (typed) | Y (S5) | existing API error | Y (existing)\n writeProfile#cache.delete | adapter failure after commit | Y (bypass) | Y | correct, slower | Y (no keys)\n controller swap | in-flight miss during swap | Y (isolation)| Y (S6) | committed version | Y (existing)\n LRU | hot set > 1000 / 16 MiB | Y (evict) | Y | lower hit rate | Y (eviction metric)\n```\n8 rows, 0 CRITICAL GAPS.\n\n### Diagrams\n1. System architecture: Section 1. 2. Data flow with shadow paths: Sections 1\nand 4. 3. State machine: cache entry per key —\n```\n [absent] --fill(gen unchanged)--> [value v, TTL 30s] --write commit+delete--> [absent]\n [absent] --fill(absent DTO)-----> [sentinel, TTL 10s] --create commit+delete--> [absent]\n [absent] --fill(gen changed)----> [absent] (suppressed; impossible: stale value installed after completed write)\n [value] --TTL expiry/evict-----> [absent]\n any --adapter failure------> [bypass] --empty reinit--> [absent]\n```\n4. Error flow: Section 2. 5. Deployment sequence:\n```\n deploy wrapper (flag off) -> 10% keys, 1 healthy hour -> 50%, 1 healthy hour -> 100%, acceptance targets\n```\n6. Rollback flowchart:\n```\n breach/alert -> disable flag -> controller stops admitting -> await old writes -> publish disabled instance -> runbook incident record -> resume criteria\n```\n\n### Stale Diagram Audit\nNo existing ASCII diagrams in files this plan touches (new module). 0 stale.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific\nfinding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1 day / CC: ~30min)** — wrapper — Implement the R1 fill guard (skip fills whose read overlapped a committed write; bounded in-flight state) and scenarios S1–S6 on the existing harness\n - Surfaced by: Section 1 / Section 4 — R1 stale fill after completed write (D1 → A)\n - Files: to be determined (new wrapper module and its test file)\n - Verify: S1–S6 pass deterministically with pause/release points; S1 asserts no fill and suppressed-fill count +1\n- [ ] **T2 (P2, human: ~1h / CC: ~5min)** — wrapper — Add the fill/write ordering ASCII comment (Section 4 schedule) above the wrapper\n - Surfaced by: Section 5 / Section 10 — knowledge concentration\n - Files: to be determined (wrapper module)\n - Verify: comment present and matches Section 4 schedule in review\n- [ ] **T3 (P2, human: ~1h / CC: ~5min)** — tests — Drive TTL and sentinel-expiry tests from the harness's controlled clock\n - Surfaced by: Section 6 — flakiness risk\n - Files: to be determined (wrapper test file)\n - Verify: TTL tests pass repeatedly with no wall-clock sleeps\n- [ ] **T4 (P2, human: ~30min / CC: ~5min)** — runbook — Add the note that rising suppressed fills on write-hot keys is expected, with the existing bypass/p95 alerts unchanged\n - Surfaced by: Section 8 — runbook coverage of the new branch\n - Files: to be determined (existing runbook)\n - Verify: runbook reviewed by service owner before the 10% stage\n_No new tasks from Sections 3, 7, 9, 11._\nTask JSONL for /autoplan: **not persisted** (state writes out of this run's scope).\nRatios assumed: bug fix with regression ~20x, docs ~10x.\n\n### Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run rules (fixture) |\n | Step 0 | HOLD SCOPE; R1 approved (D1 → A) |\n | Section 1 (Arch) | 1 issue found (R1, resolved) |\n | Section 2 (Errors) | 7 error paths mapped, 1 GAP (resolved) |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 5 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 1 issue found (R1; comment task T2) |\n | Section 6 (Tests) | Diagram produced, 0 gaps (T3 note) |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 1 gap found (runbook note, T4) |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 2 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (1 rejected, 0 deferred) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 7 rows, 0 CRITICAL GAPS |\n | Failure modes | 8 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (HOLD SCOPE) |\n | CEO plan | skipped by mode |\n | Outside voice | codex disabled (no completed review) |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, |\n | | rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 |\n +====================================================================+\n```\nReview Log / decision log / dashboard read: **not persisted / not run** (state\nwrites and gstack history are out of this run's scope; no write attempted).\nNo durable learnings this session.\n\n### Unresolved Decisions\nNone. D1 was resolved by authorized auto-decision (A).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps |\n| Outside Review | codex (`codex_reviews: disabled`) | Independent 2nd opinion | 0 | DISABLED | disabled by config; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | not run (no UI scope) |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |\n\n**OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by `codex_reviews: disabled`; no outside process or native fallback ran; no findings; disabled record not persisted.\n**VERDICT:** CEO CLEARED (HOLD SCOPE; R1 resolved by D1 → A) — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37182865432-t1-fail-derived-unrelated-decision.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37182865432-t1-fail-derived-unrelated-decision.json
new file mode 100644
index 000000000..6ab6c50bb
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37182865432-t1-fail-derived-unrelated-decision.json
@@ -0,0 +1,21 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "b56318cf9e1e2194",
+ "source": {
+ "run": "37182865432",
+ "sha": "44ef18cd0ee8bfb33b3812fbc7f012a2d52f6d9e",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "failed",
+ "census_error": "Error: expect(received).toBe(expected)",
+ "derived": "census 37182865432 report with its only currentDecision question replaced by an unrelated adapter-failure question; no census capture is a genuine miss",
+ "expected": "refuse",
+ "justification": "Negative control: the only recorded decision is no longer the stale-fill race and this report's prose never asserts the race in a form the finding detector accepts, so neither witness proves the review found the defect.",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nare the complete new read/write ordering rules; no additional version checks or\ncoordination between a cache fill and a write are proposed:\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n### Amendment A1 (accepted via D1/R1, CEO review 2026-10-04)\nThe ordering above is retained with one added guarantee. **Fill admission:** a\nread-miss may fill the cache only if no `repository.write` for that key committed\nbetween the start of that read's `repository.read` call and the fill. Required\nbehavior: `readProfile` captures the key's write generation before calling\n`repository.read` and performs `cache.set` only if the generation is unchanged\nwhen the read resolves; otherwise it skips the fill and still returns the value.\n`writeProfile` advances the key's write generation after `repository.write`\nresolves (committed) and before `cache.delete`; a rejected write does not advance\nit. The absent-result DTO is governed by the same rule. The skipped-fill branch is\ncounted on the existing dashboard with no key labels. Tradeoff: a read that\noverlaps a write may cost one extra DB miss. The generation store's data structure,\nbounds and function bodies are engineering-planning choices; they must keep the\noperation synchronous in the event loop and must not change the adapter, the\nsingle-flight wrapper, the controller, limits, TTLs or consistency semantics.\nDeterministic regression scenarios (existing harness, controlled pause/release):\nS1 read resolves and fills before the write commits → delete clears it → next read\nobserves v1; S2 read snapshot pre-commit, write completes, read resumes → fill\nskipped → next read observes v1; S3 same as S2 with a missing record created by\nthe write → no absent sentinel is cached; S4 rejected write overlapping a read →\nfill admitted, v0 correct; S5 rejected read → no fill, typed error unchanged;\nS6 overlapping writes W1/W2 with a read between → final read observes W2's\nversion; S7 controller instance change during an in-flight read → old read cannot\nfill the new instance (existing isolation test retained).\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n# CEO Review (plan-ceo-review, 2026-10-04)\n\n## Step 0 — Scope challenge and mode\n\nReview depth: implementation-ready (default; the plan names codepaths). Preamble\nskipped per run instructions (no skill-start, no system audit, no telemetry);\nAskUserQuestion unavailable; the run instructions authorize auto-decision under\nthe author policy. Base branch: `main` (git status snapshot; no git run).\nConfig `.gstack-section-state-aQnx04/config.yaml`: `codex_reviews: disabled`.\n\n**0A Premise.** Real problem: ~900 hot keys re-read from the DB (DB CPU 70%,\nread p95 120 ms). Do-nothing cost: DB saturation headroom gone before growth.\nA process-local LRU attacks the pain directly (one process, no external writers).\n**0B Leverage.** Reuses existing repository, LRU adapter (1000 entries/16 MiB/\n30 s TTL, absent sentinel), per-key single-flight, rollout controller,\ndashboards and runbooks. No rebuild proposed; nothing to refactor away.\n**0C Dream state.**\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n DB serves every read ---> process-local LRU, ---> repository interface\n CPU 70%, p95 120 ms >=60% hits, CPU<50%, ready for a shared\n single process p95<60 ms, same SLOs cache if ever needed\n```\nMoves toward the ideal without a general cache framework.\n**0E Mode.** Explicit user instruction: HOLD SCOPE (no mode question asked, no\nhandoff script available; provenance = user instruction). Changed files: ~3\n(wrapper, wrapper tests, flag wiring) — estimate; complexity check passes\n(1 new wrapper, 0 new services). Minimum change = the wrapper; nothing deferrable\nwithout blocking the goal. Invariants and acceptance criteria are kept; repairs\nneeded to meet them are in scope (0G HOLD rule 3).\n\n**0D required approach choice.** One contradiction found between the proposed\nordering and a retained contract; recorded as row R1 below. No other approach\nchoice is required.\n\n### Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| R1 — Section 4 (async ordering); owner: wrapper author | PLAN.md \"Existing contracts retained\", last bullet: every read begun after writeProfile fulfills must observe the committed version; TTL is no substitute. PLAN.md \"Proposed wrapper integration\": `readProfile` awaits `repository.read` then `cache.set` with no fill/write coordination (\"no additional version checks or coordination between a cache fill and a write are proposed\"); single-flight \"does not inspect cache fills\"; controller isolation \"does not coordinate an ordinary DB write with a cache fill\". Schedule S2 below demonstrates a stale fill. | Proposed ordering as written (unapproved) | A) add a fill-admission guard; B) refill on write; C) keep as proposed | approved | D1 → A, auto-decided 2026-10-04 under the run's author policy (\"authorize complete remedies and required verification that restore its retained contracts\"; recommended option among in-scope alternatives). Scope: the fill-admission guarantee, its write-side generation advance, the skipped-fill counter on the existing dashboard (no key labels), and the listed regression scenarios. Not approved: data structures, function bodies, test code, any change to limits, TTL, consistency semantics or alerts. |\n\n**Decision record D1 (R1).** Selected A. Authority: user run instructions\n(author policy), not a human answer and not a preamble SESSION_KIND echo. B was\nrejected because it leaves S2 reachable; C was rejected because the author policy\nforbids weaker consistency. Lake Score eligible: yes (10/10 option chosen).\n\n### Demonstrated schedule (S2, violating)\n\n```\n t | readProfile R1 (miss) | writeProfile W | cache[key] | store\n 1 | cache.get -> undefined | | - | v0\n 2 | await repository.read (v0 snapshot taken) | - | v0\n 3 | | repository.write commits | - | v1\n 4 | | cohort retired, resolves | - | v1\n 5 | | cache.delete (no-op) | - | v1\n 6 | | promise fulfills = WRITE COMPLETE | v1\n 7 | resumes: cache.set(key, v0) | | v0 STALE | v1\n 8 | readProfile R2 begins after step 6: cache.get -> v0 <-- VIOLATION | v1\n```\nNo retained mechanism excludes S2: single-flight admits R1 as an already-started\nreader that \"may finish with their earlier snapshot\"; the controller only isolates\ninstance changes; the adapter does not know about writes. The same schedule with an\nabsent-result DTO at step 2 caches \"absent\" for 10 s after a creating write.\n\n## currentDecision (R1)\nCommitment comparison:\n\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead-after-write visibility at wrapper boundary | PLAN.md retained contract (approved) | required | kept | kept | VIOLATED (S2)\nCache fill on read miss | PLAN.md proposed (pending) | unconditional set | set only if no write committed for key since the read's store access began | unconditional set | unconditional set\nWrite path after commit | PLAN.md proposed (pending) | cache.delete | bump per-key write generation, then cache.delete | cache.set(saved) after commit | cache.delete\nConsistency semantics / limits / TTL | PLAN.md retained (approved) | unchanged | unchanged | unchanged | unchanged\nSingle-flight, adapter, controller contracts | fixture facts (approved) | unchanged | unchanged | unchanged | unchanged\nAdded telemetry | PLAN.md acceptance reqs (approved) | none | existing counter for skipped fills, no key labels | none | none\nRegression tests | PLAN.md acceptance reqs (approved) | required | both orders, absent creation, rejected R/W, overlapping writes, instance isolation | same list but S2 still fails | S2 fails\nData structures / function bodies | engineering planning (pending) | not chosen | not chosen | not chosen | not chosen\n```\n\nQuestion: D1 — R1: Should cache adapter failures bypass the cache until reinitialized?\nProject/branch/task: profile-summary service, branch main, process-local LRU wrapper plan.\nELI10: A read can fetch an old copy from the database, pause, and only then put it in the cache. If a write finished during that pause, the cache now holds the old copy and every later reader gets stale data until the 30-second TTL. The plan promises that never happens once a write completes.\nStakes if we pick wrong: users see pre-write profile data for up to 30 s (10 s for \"absent\") after a confirmed save, breaking the correctness SLO the plan says is unchanged.\nRecommendation: A because it closes the only violating schedule with one in-process check and keeps every retained contract and limit unchanged.\nCompleteness: A=10/10, B=5/10, C=3/10\nPros / cons:\nA) Fill-admission guard (recommended)\n ✅ Any fill whose store access began before a commit is dropped, so S2 cannot publish v0 after W completes\n ✅ No change to adapter, single-flight, controller, TTL or consistency contracts; one counter on the existing dashboard\n ❌ Reads overlapping a write pay one extra DB miss; needs per-key generation state in the wrapper (engineering chooses the structure)\nB) Refill cache from the write result\n ✅ Readers after a write get an immediate hit on the saved DTO\n ✅ Simple to describe and no per-key bookkeeping\n ❌ Does not prevent S2: R1's late cache.set(v0) still lands after the write's set(v1); also changes the write path from delete to fill\nC) Keep as proposed\n ✅ Zero extra work and smallest diff\n ✅ Matches the current code sketch exactly\n ❌ Leaves the retained read-after-write guarantee violated; not permitted by the author policy (no weaker consistency)\nNet: A trades a rare extra miss for a provable guarantee; B and C both leave the demonstrated stale fill in place.\nHeader: R1 stale fill guard\nA) Fill-admission guard (recommended)\nGuard every cache fill: readProfile records the key's write generation before calling repository.read and sets the cache only if it is unchanged afterwards; writeProfile advances the generation after commit and before cache.delete, recording skipped fills on the existing dashboard without key labels. Effort S, risk low, reuses all existing adapters, verification: both completion orders, absent-record creation, rejected reads/writes, overlapping writes, instance isolation. ✅ closes S2 ✅ keeps all retained contracts ❌ extra miss for reads overlapping a write.\nB) Refill cache from the write result\nwriteProfile replaces cache.delete with cache.set(saved) after commit; reads stay as proposed. Effort S, risk medium, reuses adapters, verification: same scenario list. ✅ immediate hits after writes ✅ no bookkeeping ❌ S2 still overwrites v1 with v0 so the guarantee stays broken.\nC) Keep as proposed\nNo implementation work (effort S, zero changes), risk high, verification: existing list only. ✅ smallest diff ✅ matches sketch ❌ demonstrated contract violation remains and cannot be accepted under the author policy.\n\n## 0I Temporal interrogation\n```\n HOUR 1 wrapper sits between callers and repository; adapter/single-flight/controller are fixed facts\n HOUR 2-3 ambiguity: where the per-key write generation lives and when it is pruned (engineering choice, A1 constraints)\n HOUR 4-5 surprise: S2 only reproduces with a controlled pause inside repository.read; harness pause points needed\n HOUR 6+ wish: skipped-fill counter wired before the 10% stage so the branch is visible on day 1\n```\nEffort: human ~1.5 days / CC+gstack ~40 min. Feasibility blockers: none after D1.\nPending (engineering): generation-store structure and bounds; harness pause-point selection.\n\n## Current scope (Section 1 preface)\nMode HOLD SCOPE (user instruction). Governing rows: R1 approved (D1 → A, author\npolicy). Accepted: original plan + A1. Deferred: none. Rejected: options B and C\nof R1. Pending: engineering-planning details named in A1.\n\n## Section 1: Architecture Review\n```\n callers -> [flag predicate] -> readProfile/writeProfile (wrapper, +gen guard)\n | | |\n | excluded v v\n +--------------> repository.read/write (single-flight, txn store)\n ^\n cache adapter (LRU 1000/16MiB/30s, absent sentinel)\n```\nData flow paths: happy = miss→store→fill→hit; nil = absent DTO cached via\nsentinel 10 s; empty = `undefined` only ever means miss (adapter contract);\nerror = repository rejection propagates with typed mapping, no fill.\nState machine (per key, wrapper view): `empty → filled (gen g) → deleted (gen g+1)`;\ninvalid transition `filled(g) ← fill from read captured at g' < g` is now\nprevented by A1. Coupling: wrapper gains a per-key generation; justified by the\nretained contract. Scaling: 10x hot keys exceed 1000 entries → eviction, lower\nhit rate, not a correctness issue; 100x → DB load returns to baseline, same as\ntoday. SPOF: the single process (already the case). Security: no new surface.\nProduction failure: adapter failure → bypass until reinit (fixture). Rollback:\nflag disable → new bypassing instance, seconds.\n**Finding 1.1 (WARNING, resolved via R1):** stale-fill schedule S2. Remedy A1.\nOutcome: 1 issue, resolved. Decision gate: R1 answer applied; no new choice.\n\n## Section 2: Error & Rescue Map\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS\n readProfile | cache.get throws / adapter failure | CacheAdapterError (fixture: bypass)\n | repository.read rejects (DB/timeout) | RepositoryError (typed API mapping)\n | fill skipped by generation guard | not an error; counted\n writeProfile | repository.write rejects (rolled back) | RepositoryError; no gen advance, no delete\n | cache.delete throws | CacheAdapterError (fixture: bypass)\n controller | flag transition mid-flight | n/a; isolation (fixture)\n\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n CacheAdapterError | Y (adapter) | bypass until reinit; alert 1m | normal, slower\n RepositoryError | Y (API layer) | typed error, no fill/no delete | existing API error\n```\nNo catch-alls introduced; the wrapper adds no rescue of its own. Outcome: 5\nerror paths mapped, 0 GAPS. Decision gate: nothing new.\n\n## Section 3: Security & Threat Model\nAttack surface unchanged (no endpoints, params, files, jobs). Tenant-scoped keys\nand pre-repository authz are fixture facts; cached values cannot bypass authz.\nThreat: cross-tenant leak via key collision — likelihood Low, impact High,\nmitigated (unambiguous tenant+profile key). Threat: key/secret in logs —\nLow/Med, mitigated (no raw IDs; counter has no key labels). No new secrets or\ndependencies. Outcome: 0 issues, 0 High unmitigated.\n\n## Section 4: Data Flow & Interaction Edge Cases\n```\n INPUT key -> (validated upstream) -> cache.get -> miss -> repository.read -> gen check -> cache.set -> OUTPUT\n shadow: nil/absent -> absent DTO -> sentinel 10s | error -> reject, no fill | stale -> gen changed -> skip fill\n```\nAsync ordering: invariant = reads begun after writeProfile fulfills observe the\ncommitted version. Both orders: S1 (fill before commit → delete clears) safe by\nthe existing delete; S2 (fill after commit) violating as proposed, safe under A1\nbecause the generation advanced before the write could fulfil. Mechanism named:\nper-key write generation compared at fill time. Regression proof: S1–S7 in A1.\nNo user-visible interactions (backend only). Outcome: 7 edge cases mapped, 0\nunhandled after A1 (1 was unhandled before). Decision gate: R1 reused.\n\n## Section 5: Code Quality Review\nWrapper fits the existing read-through pattern; no duplication (single-flight\nand sentinel stay in their owners). Naming: `readProfile`/`writeProfile` fine.\nUnder-engineering fixed by A1; no over-engineering (no framework). Branching\nstays under 5 per function. Outcome: 0 new issues.\n\n## Section 6: Test Review\n```\n new thing | type | happy | failure | edge\n readProfile hit/miss | unit | hit returns DTO | adapter failure bypass | absent sentinel 10s\n readProfile fill guard | integration | S1 | S5 rejected read | S2, S3 (absent)\n writeProfile invalidate | integration | delete after commit| S4 rejected write | S6 overlapping writes\n controller isolation | integration | instance swap | transition mid-read | S7\n eviction/bytes/TTL | unit | 1000/16MiB/30s | over cap evicts | boundary sizes\n```\nAssertion check: each S1–S7 names the observed version and the wrong result it\nrejects (exact version, not \"eventually\"). 2am test: S2. Hostile QA: S3 absent\ncreation. Chaos: adapter failure mid-fill. Pyramid: mostly unit/integration, no\nE2E needed. Flakiness: all schedules use deterministic pause/release, not timers.\nLoad: existing capacity statement covers cold start. Outcome: diagram produced,\n0 gaps after A1 (the S2/S3 assertions were missing before).\n\n## Section 7: Performance Review\nNo N+1, no new queries or indexes. Memory: 16 MiB cap plus generation state\n(bounded per A1 engineering constraint). Slow paths: miss (= today), guarded\nmiss overlapping a write (= today + one skipped fill). Connection pools unchanged.\nOutcome: 0 issues.\n\n## Section 8: Observability & Debuggability Review\nExisting metrics: hit/miss, eviction, bytes, fallback errors, DB CPU, p95.\nAdded branch: skipped-fill counter on the current dashboard, no key labels\n(within accepted scope; no new alert). Debuggability: counter + existing error\nlogs reconstruct a stale-fill suspicion without raw IDs. Runbook: existing\nbreach → disable flag. Outcome: 0 gaps (1 counter required by A1, accepted).\n\n## Section 9: Deployment & Rollout Review\nNo migration. Flag staged 10% → 50% → 100% with one healthy hour each; breach\ndisables immediately; controller swaps instances safely (fixture). Old/new code\nwindow: wrapper is additive; disabled instances bypass both paths. Post-deploy:\nfirst 5 min watch fallback errors and skipped fills; first hour the healthy-hour\ncriteria. Smoke: hit/miss counters move; no bypass alert. Outcome: 0 new risks;\n1 note: verify alerts page before each stage (already in plan).\n\n## Section 10: Long-Term Trajectory Review\nDebt: one per-key generation structure (documented in A1). Path dependency: low;\nthe repository interface keeps a replacement path. Reversibility 5/5 (flag off).\nEcosystem fit: yes. 1-year question: obvious if A1's guarantee is commented at\nthe fill site. Outcome: debt items 1, reversibility 5/5.\n\n## Section 11: Design & UX Review\nSKIPPED (no UI scope).\n\n## Closing sequence\n**Outside Voice:** `codex_reviews: disabled` in the isolated config. Codex review\nskipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`.\nNo native fallback; coverage recorded as disabled (review-log helper not run in\nthis fixture; disabled record **not persisted**).\n**TODO choices:** none proposed (HOLD SCOPE; no evidenced deferred gap).\n**Approval readiness: PASS** — R1: D1 → A (author-policy auto-decision, scope as\nrecorded in the ledger). No other rows.\n\n## Required Outputs\n\n### Review facts\nMode HOLD SCOPE; findings 1 (1.1, resolved via R1/A1); unresolved choices 0;\ncritical gaps 0 after A1; scope proposals 0; outside coverage: codex disabled.\nStatus: clean.\n\n### NOT in scope\nDeferred: none. Rejected: R1 option B (refill on write — leaves S2 reachable);\nR1 option C (keep as proposed — violates retained read-after-write rule). Plan's\nown out-of-scope list retained (distributed cache, cross-process coherence,\nprewarming, semantic changes, new surfaces).\n\n### What already exists\nRepository with single-flight and typed errors (reused); LRU adapter with limits\nand absent sentinel (reused); rollout controller with instance isolation (reused);\ndashboards, alerts and runbooks (reused); deterministic contract harness (reused).\n\n### Dream state delta\nAfter this plan: hot reads served in-process with a provable read-after-write\nguarantee; the repository interface still allows a shared cache later. Gap to the\n12-month ideal: only the multi-process story, deliberately out of scope.\n\n### Error & Rescue Registry\nSee Section 2 map (cross-reference). Rows: 5; CRITICAL GAPS: 0.\n\n### Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n readProfile | adapter failure | Y | Y | slower, correct | Y (fallback)\n readProfile | repository.read rejects | Y | Y(S5) | typed API error | Y\n readProfile fill | stale fill after commit (S2) | Y (A1) | Y(S2) | correct version | Y (skip counter)\n readProfile fill | stale absent after create (S3)| Y (A1) | Y(S3) | correct record | Y (skip counter)\n writeProfile | repository.write rejects | Y | Y(S4) | typed API error | Y\n writeProfile | cache.delete throws | Y | Y | slower, correct | Y (fallback)\n controller | transition mid-read | Y | Y(S7) | correct | Y\n```\nTotal 7, CRITICAL GAPS 0.\n\n### Diagrams\n1. System architecture: Section 1. 2. Data flow with shadow paths: Section 4.\n3. State machine: Section 1 (per-key `empty → filled(g) → deleted(g+1)`).\n4. Error flow: Section 2.\n5. Deployment sequence:\n```\n deploy wrapper (flag off) -> enable 10% -> healthy hour -> 50% -> healthy hour -> 100% -> acceptance check\n```\n6. Rollback flowchart:\n```\n breach (SLO / p95>120ms 5m / bypass 1m)? --yes--> flag off -> controller drains writes, publishes bypass instance -> runbook incident\n --no---> continue stage\n```\n\n### Stale Diagram Audit\nNo existing ASCII diagrams in touched files were supplied; the plan's own code\nsketch is amended by A1 (not stale: A1 states the added guarantee alongside it).\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific\nfinding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~4h / CC: ~15min)** — wrapper — Implement the fill-admission guard (A1): generation capture before `repository.read`, conditional `cache.set`, generation advance after commit before `cache.delete`\n - Surfaced by: Section 4 — Finding 1.1 / schedule S2 (R1, D1 → A)\n - Files: to be determined (wrapper module)\n - Verify: S1–S6 pass in the deterministic harness with controlled pause/release\n- [ ] **T2 (P1, human: ~4h / CC: ~10min)** — tests — Add regression scenarios S1–S7 to the existing contract harness\n - Surfaced by: Section 6 — S2/S3 assertions missing before A1\n - Files: to be determined (wrapper test suite)\n - Verify: each scenario asserts the exact observed version and fails against the unguarded sketch\n- [ ] **T3 (P2, human: ~1h / CC: ~5min)** — telemetry — Count skipped fills on the existing dashboard without key labels\n - Surfaced by: Section 8 — added branch must be visible\n - Files: to be determined (existing telemetry wiring)\n - Verify: counter increments in S2/S3 runs; dashboard panel shows it\n_No new tasks from Sections 2, 3, 5, 7, 9, 10, 11._\nTask JSONL: **not persisted** (no gstack state root in this fixture).\n\n### Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run instructions |\n | Step 0 | HOLD SCOPE; R1 → A (fill-admission guard) |\n | Section 1 (Arch) | 1 issues found (resolved) |\n | Section 2 (Errors) | 5 error paths mapped, 0 GAPS |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 7 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 0 gaps found |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 1 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (2 items) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 5 rows, 0 CRITICAL GAPS |\n | Failure modes | 7 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (EXP + SEL) |\n | CEO plan | skipped by mode |\n | Outside voice | codex disabled |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 (listed below) |\n +====================================================================+\n```\n\n### Unresolved Decisions\nNone. D1 was resolved by authorized auto-decision (author policy).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps |\n| Outside Review | codex (`codex_reviews: disabled`) | Independent 2nd opinion | 0 | DISABLED | disabled by config; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |\n\n- **OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by `codex_reviews: disabled`; no outside process, no native fallback, no findings claimed.\n- **VERDICT:** CEO CLEARED — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37182865432-t1-fail.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37182865432-t1-fail.json
new file mode 100644
index 000000000..eb067adc0
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37182865432-t1-fail.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "b56318cf9e1e2194",
+ "source": {
+ "run": "37182865432",
+ "sha": "44ef18cd0ee8bfb33b3812fbc7f012a2d52f6d9e",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "failed",
+ "census_error": "Error: expect(received).toBe(expected)",
+ "expected": "pass",
+ "justification": "Fixed false red: the census assertion was phrase-only (hasStaleFillRaceFinding, test line 115) and missed the race; the report's approved R1 ledger row and its currentDecision question on a slow fill overwriting a newer write are read by hasApprovedStaleFillDecision since 27d4fd7.",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nare the complete new read/write ordering rules; no additional version checks or\ncoordination between a cache fill and a write are proposed:\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n### Amendment A1 (accepted via D1/R1, CEO review 2026-10-04)\nThe ordering above is retained with one added guarantee. **Fill admission:** a\nread-miss may fill the cache only if no `repository.write` for that key committed\nbetween the start of that read's `repository.read` call and the fill. Required\nbehavior: `readProfile` captures the key's write generation before calling\n`repository.read` and performs `cache.set` only if the generation is unchanged\nwhen the read resolves; otherwise it skips the fill and still returns the value.\n`writeProfile` advances the key's write generation after `repository.write`\nresolves (committed) and before `cache.delete`; a rejected write does not advance\nit. The absent-result DTO is governed by the same rule. The skipped-fill branch is\ncounted on the existing dashboard with no key labels. Tradeoff: a read that\noverlaps a write may cost one extra DB miss. The generation store's data structure,\nbounds and function bodies are engineering-planning choices; they must keep the\noperation synchronous in the event loop and must not change the adapter, the\nsingle-flight wrapper, the controller, limits, TTLs or consistency semantics.\nDeterministic regression scenarios (existing harness, controlled pause/release):\nS1 read resolves and fills before the write commits → delete clears it → next read\nobserves v1; S2 read snapshot pre-commit, write completes, read resumes → fill\nskipped → next read observes v1; S3 same as S2 with a missing record created by\nthe write → no absent sentinel is cached; S4 rejected write overlapping a read →\nfill admitted, v0 correct; S5 rejected read → no fill, typed error unchanged;\nS6 overlapping writes W1/W2 with a read between → final read observes W2's\nversion; S7 controller instance change during an in-flight read → old read cannot\nfill the new instance (existing isolation test retained).\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n# CEO Review (plan-ceo-review, 2026-10-04)\n\n## Step 0 — Scope challenge and mode\n\nReview depth: implementation-ready (default; the plan names codepaths). Preamble\nskipped per run instructions (no skill-start, no system audit, no telemetry);\nAskUserQuestion unavailable; the run instructions authorize auto-decision under\nthe author policy. Base branch: `main` (git status snapshot; no git run).\nConfig `.gstack-section-state-aQnx04/config.yaml`: `codex_reviews: disabled`.\n\n**0A Premise.** Real problem: ~900 hot keys re-read from the DB (DB CPU 70%,\nread p95 120 ms). Do-nothing cost: DB saturation headroom gone before growth.\nA process-local LRU attacks the pain directly (one process, no external writers).\n**0B Leverage.** Reuses existing repository, LRU adapter (1000 entries/16 MiB/\n30 s TTL, absent sentinel), per-key single-flight, rollout controller,\ndashboards and runbooks. No rebuild proposed; nothing to refactor away.\n**0C Dream state.**\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n DB serves every read ---> process-local LRU, ---> repository interface\n CPU 70%, p95 120 ms >=60% hits, CPU<50%, ready for a shared\n single process p95<60 ms, same SLOs cache if ever needed\n```\nMoves toward the ideal without a general cache framework.\n**0E Mode.** Explicit user instruction: HOLD SCOPE (no mode question asked, no\nhandoff script available; provenance = user instruction). Changed files: ~3\n(wrapper, wrapper tests, flag wiring) — estimate; complexity check passes\n(1 new wrapper, 0 new services). Minimum change = the wrapper; nothing deferrable\nwithout blocking the goal. Invariants and acceptance criteria are kept; repairs\nneeded to meet them are in scope (0G HOLD rule 3).\n\n**0D required approach choice.** One contradiction found between the proposed\nordering and a retained contract; recorded as row R1 below. No other approach\nchoice is required.\n\n### Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| R1 — Section 4 (async ordering); owner: wrapper author | PLAN.md \"Existing contracts retained\", last bullet: every read begun after writeProfile fulfills must observe the committed version; TTL is no substitute. PLAN.md \"Proposed wrapper integration\": `readProfile` awaits `repository.read` then `cache.set` with no fill/write coordination (\"no additional version checks or coordination between a cache fill and a write are proposed\"); single-flight \"does not inspect cache fills\"; controller isolation \"does not coordinate an ordinary DB write with a cache fill\". Schedule S2 below demonstrates a stale fill. | Proposed ordering as written (unapproved) | A) add a fill-admission guard; B) refill on write; C) keep as proposed | approved | D1 → A, auto-decided 2026-10-04 under the run's author policy (\"authorize complete remedies and required verification that restore its retained contracts\"; recommended option among in-scope alternatives). Scope: the fill-admission guarantee, its write-side generation advance, the skipped-fill counter on the existing dashboard (no key labels), and the listed regression scenarios. Not approved: data structures, function bodies, test code, any change to limits, TTL, consistency semantics or alerts. |\n\n**Decision record D1 (R1).** Selected A. Authority: user run instructions\n(author policy), not a human answer and not a preamble SESSION_KIND echo. B was\nrejected because it leaves S2 reachable; C was rejected because the author policy\nforbids weaker consistency. Lake Score eligible: yes (10/10 option chosen).\n\n### Demonstrated schedule (S2, violating)\n\n```\n t | readProfile R1 (miss) | writeProfile W | cache[key] | store\n 1 | cache.get -> undefined | | - | v0\n 2 | await repository.read (v0 snapshot taken) | - | v0\n 3 | | repository.write commits | - | v1\n 4 | | cohort retired, resolves | - | v1\n 5 | | cache.delete (no-op) | - | v1\n 6 | | promise fulfills = WRITE COMPLETE | v1\n 7 | resumes: cache.set(key, v0) | | v0 STALE | v1\n 8 | readProfile R2 begins after step 6: cache.get -> v0 <-- VIOLATION | v1\n```\nNo retained mechanism excludes S2: single-flight admits R1 as an already-started\nreader that \"may finish with their earlier snapshot\"; the controller only isolates\ninstance changes; the adapter does not know about writes. The same schedule with an\nabsent-result DTO at step 2 caches \"absent\" for 10 s after a creating write.\n\n## currentDecision (R1)\nCommitment comparison:\n\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead-after-write visibility at wrapper boundary | PLAN.md retained contract (approved) | required | kept | kept | VIOLATED (S2)\nCache fill on read miss | PLAN.md proposed (pending) | unconditional set | set only if no write committed for key since the read's store access began | unconditional set | unconditional set\nWrite path after commit | PLAN.md proposed (pending) | cache.delete | bump per-key write generation, then cache.delete | cache.set(saved) after commit | cache.delete\nConsistency semantics / limits / TTL | PLAN.md retained (approved) | unchanged | unchanged | unchanged | unchanged\nSingle-flight, adapter, controller contracts | fixture facts (approved) | unchanged | unchanged | unchanged | unchanged\nAdded telemetry | PLAN.md acceptance reqs (approved) | none | existing counter for skipped fills, no key labels | none | none\nRegression tests | PLAN.md acceptance reqs (approved) | required | both orders, absent creation, rejected R/W, overlapping writes, instance isolation | same list but S2 still fails | S2 fails\nData structures / function bodies | engineering planning (pending) | not chosen | not chosen | not chosen | not chosen\n```\n\nQuestion: D1 — R1: How does the wrapper keep a slow cache fill from overwriting a newer committed write?\nProject/branch/task: profile-summary service, branch main, process-local LRU wrapper plan.\nELI10: A read can fetch an old copy from the database, pause, and only then put it in the cache. If a write finished during that pause, the cache now holds the old copy and every later reader gets stale data until the 30-second TTL. The plan promises that never happens once a write completes.\nStakes if we pick wrong: users see pre-write profile data for up to 30 s (10 s for \"absent\") after a confirmed save, breaking the correctness SLO the plan says is unchanged.\nRecommendation: A because it closes the only violating schedule with one in-process check and keeps every retained contract and limit unchanged.\nCompleteness: A=10/10, B=5/10, C=3/10\nPros / cons:\nA) Fill-admission guard (recommended)\n ✅ Any fill whose store access began before a commit is dropped, so S2 cannot publish v0 after W completes\n ✅ No change to adapter, single-flight, controller, TTL or consistency contracts; one counter on the existing dashboard\n ❌ Reads overlapping a write pay one extra DB miss; needs per-key generation state in the wrapper (engineering chooses the structure)\nB) Refill cache from the write result\n ✅ Readers after a write get an immediate hit on the saved DTO\n ✅ Simple to describe and no per-key bookkeeping\n ❌ Does not prevent S2: R1's late cache.set(v0) still lands after the write's set(v1); also changes the write path from delete to fill\nC) Keep as proposed\n ✅ Zero extra work and smallest diff\n ✅ Matches the current code sketch exactly\n ❌ Leaves the retained read-after-write guarantee violated; not permitted by the author policy (no weaker consistency)\nNet: A trades a rare extra miss for a provable guarantee; B and C both leave the demonstrated stale fill in place.\nHeader: R1 stale fill guard\nA) Fill-admission guard (recommended)\nGuard every cache fill: readProfile records the key's write generation before calling repository.read and sets the cache only if it is unchanged afterwards; writeProfile advances the generation after commit and before cache.delete, recording skipped fills on the existing dashboard without key labels. Effort S, risk low, reuses all existing adapters, verification: both completion orders, absent-record creation, rejected reads/writes, overlapping writes, instance isolation. ✅ closes S2 ✅ keeps all retained contracts ❌ extra miss for reads overlapping a write.\nB) Refill cache from the write result\nwriteProfile replaces cache.delete with cache.set(saved) after commit; reads stay as proposed. Effort S, risk medium, reuses adapters, verification: same scenario list. ✅ immediate hits after writes ✅ no bookkeeping ❌ S2 still overwrites v1 with v0 so the guarantee stays broken.\nC) Keep as proposed\nNo implementation work (effort S, zero changes), risk high, verification: existing list only. ✅ smallest diff ✅ matches sketch ❌ demonstrated contract violation remains and cannot be accepted under the author policy.\n\n## 0I Temporal interrogation\n```\n HOUR 1 wrapper sits between callers and repository; adapter/single-flight/controller are fixed facts\n HOUR 2-3 ambiguity: where the per-key write generation lives and when it is pruned (engineering choice, A1 constraints)\n HOUR 4-5 surprise: S2 only reproduces with a controlled pause inside repository.read; harness pause points needed\n HOUR 6+ wish: skipped-fill counter wired before the 10% stage so the branch is visible on day 1\n```\nEffort: human ~1.5 days / CC+gstack ~40 min. Feasibility blockers: none after D1.\nPending (engineering): generation-store structure and bounds; harness pause-point selection.\n\n## Current scope (Section 1 preface)\nMode HOLD SCOPE (user instruction). Governing rows: R1 approved (D1 → A, author\npolicy). Accepted: original plan + A1. Deferred: none. Rejected: options B and C\nof R1. Pending: engineering-planning details named in A1.\n\n## Section 1: Architecture Review\n```\n callers -> [flag predicate] -> readProfile/writeProfile (wrapper, +gen guard)\n | | |\n | excluded v v\n +--------------> repository.read/write (single-flight, txn store)\n ^\n cache adapter (LRU 1000/16MiB/30s, absent sentinel)\n```\nData flow paths: happy = miss→store→fill→hit; nil = absent DTO cached via\nsentinel 10 s; empty = `undefined` only ever means miss (adapter contract);\nerror = repository rejection propagates with typed mapping, no fill.\nState machine (per key, wrapper view): `empty → filled (gen g) → deleted (gen g+1)`;\ninvalid transition `filled(g) ← fill from read captured at g' < g` is now\nprevented by A1. Coupling: wrapper gains a per-key generation; justified by the\nretained contract. Scaling: 10x hot keys exceed 1000 entries → eviction, lower\nhit rate, not a correctness issue; 100x → DB load returns to baseline, same as\ntoday. SPOF: the single process (already the case). Security: no new surface.\nProduction failure: adapter failure → bypass until reinit (fixture). Rollback:\nflag disable → new bypassing instance, seconds.\n**Finding 1.1 (WARNING, resolved via R1):** stale-fill schedule S2. Remedy A1.\nOutcome: 1 issue, resolved. Decision gate: R1 answer applied; no new choice.\n\n## Section 2: Error & Rescue Map\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS\n readProfile | cache.get throws / adapter failure | CacheAdapterError (fixture: bypass)\n | repository.read rejects (DB/timeout) | RepositoryError (typed API mapping)\n | fill skipped by generation guard | not an error; counted\n writeProfile | repository.write rejects (rolled back) | RepositoryError; no gen advance, no delete\n | cache.delete throws | CacheAdapterError (fixture: bypass)\n controller | flag transition mid-flight | n/a; isolation (fixture)\n\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n CacheAdapterError | Y (adapter) | bypass until reinit; alert 1m | normal, slower\n RepositoryError | Y (API layer) | typed error, no fill/no delete | existing API error\n```\nNo catch-alls introduced; the wrapper adds no rescue of its own. Outcome: 5\nerror paths mapped, 0 GAPS. Decision gate: nothing new.\n\n## Section 3: Security & Threat Model\nAttack surface unchanged (no endpoints, params, files, jobs). Tenant-scoped keys\nand pre-repository authz are fixture facts; cached values cannot bypass authz.\nThreat: cross-tenant leak via key collision — likelihood Low, impact High,\nmitigated (unambiguous tenant+profile key). Threat: key/secret in logs —\nLow/Med, mitigated (no raw IDs; counter has no key labels). No new secrets or\ndependencies. Outcome: 0 issues, 0 High unmitigated.\n\n## Section 4: Data Flow & Interaction Edge Cases\n```\n INPUT key -> (validated upstream) -> cache.get -> miss -> repository.read -> gen check -> cache.set -> OUTPUT\n shadow: nil/absent -> absent DTO -> sentinel 10s | error -> reject, no fill | stale -> gen changed -> skip fill\n```\nAsync ordering: invariant = reads begun after writeProfile fulfills observe the\ncommitted version. Both orders: S1 (fill before commit → delete clears) safe by\nthe existing delete; S2 (fill after commit) violating as proposed, safe under A1\nbecause the generation advanced before the write could fulfil. Mechanism named:\nper-key write generation compared at fill time. Regression proof: S1–S7 in A1.\nNo user-visible interactions (backend only). Outcome: 7 edge cases mapped, 0\nunhandled after A1 (1 was unhandled before). Decision gate: R1 reused.\n\n## Section 5: Code Quality Review\nWrapper fits the existing read-through pattern; no duplication (single-flight\nand sentinel stay in their owners). Naming: `readProfile`/`writeProfile` fine.\nUnder-engineering fixed by A1; no over-engineering (no framework). Branching\nstays under 5 per function. Outcome: 0 new issues.\n\n## Section 6: Test Review\n```\n new thing | type | happy | failure | edge\n readProfile hit/miss | unit | hit returns DTO | adapter failure bypass | absent sentinel 10s\n readProfile fill guard | integration | S1 | S5 rejected read | S2, S3 (absent)\n writeProfile invalidate | integration | delete after commit| S4 rejected write | S6 overlapping writes\n controller isolation | integration | instance swap | transition mid-read | S7\n eviction/bytes/TTL | unit | 1000/16MiB/30s | over cap evicts | boundary sizes\n```\nAssertion check: each S1–S7 names the observed version and the wrong result it\nrejects (exact version, not \"eventually\"). 2am test: S2. Hostile QA: S3 absent\ncreation. Chaos: adapter failure mid-fill. Pyramid: mostly unit/integration, no\nE2E needed. Flakiness: all schedules use deterministic pause/release, not timers.\nLoad: existing capacity statement covers cold start. Outcome: diagram produced,\n0 gaps after A1 (the S2/S3 assertions were missing before).\n\n## Section 7: Performance Review\nNo N+1, no new queries or indexes. Memory: 16 MiB cap plus generation state\n(bounded per A1 engineering constraint). Slow paths: miss (= today), guarded\nmiss overlapping a write (= today + one skipped fill). Connection pools unchanged.\nOutcome: 0 issues.\n\n## Section 8: Observability & Debuggability Review\nExisting metrics: hit/miss, eviction, bytes, fallback errors, DB CPU, p95.\nAdded branch: skipped-fill counter on the current dashboard, no key labels\n(within accepted scope; no new alert). Debuggability: counter + existing error\nlogs reconstruct a stale-fill suspicion without raw IDs. Runbook: existing\nbreach → disable flag. Outcome: 0 gaps (1 counter required by A1, accepted).\n\n## Section 9: Deployment & Rollout Review\nNo migration. Flag staged 10% → 50% → 100% with one healthy hour each; breach\ndisables immediately; controller swaps instances safely (fixture). Old/new code\nwindow: wrapper is additive; disabled instances bypass both paths. Post-deploy:\nfirst 5 min watch fallback errors and skipped fills; first hour the healthy-hour\ncriteria. Smoke: hit/miss counters move; no bypass alert. Outcome: 0 new risks;\n1 note: verify alerts page before each stage (already in plan).\n\n## Section 10: Long-Term Trajectory Review\nDebt: one per-key generation structure (documented in A1). Path dependency: low;\nthe repository interface keeps a replacement path. Reversibility 5/5 (flag off).\nEcosystem fit: yes. 1-year question: obvious if A1's guarantee is commented at\nthe fill site. Outcome: debt items 1, reversibility 5/5.\n\n## Section 11: Design & UX Review\nSKIPPED (no UI scope).\n\n## Closing sequence\n**Outside Voice:** `codex_reviews: disabled` in the isolated config. Codex review\nskipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`.\nNo native fallback; coverage recorded as disabled (review-log helper not run in\nthis fixture; disabled record **not persisted**).\n**TODO choices:** none proposed (HOLD SCOPE; no evidenced deferred gap).\n**Approval readiness: PASS** — R1: D1 → A (author-policy auto-decision, scope as\nrecorded in the ledger). No other rows.\n\n## Required Outputs\n\n### Review facts\nMode HOLD SCOPE; findings 1 (1.1, resolved via R1/A1); unresolved choices 0;\ncritical gaps 0 after A1; scope proposals 0; outside coverage: codex disabled.\nStatus: clean.\n\n### NOT in scope\nDeferred: none. Rejected: R1 option B (refill on write — leaves S2 reachable);\nR1 option C (keep as proposed — violates retained read-after-write rule). Plan's\nown out-of-scope list retained (distributed cache, cross-process coherence,\nprewarming, semantic changes, new surfaces).\n\n### What already exists\nRepository with single-flight and typed errors (reused); LRU adapter with limits\nand absent sentinel (reused); rollout controller with instance isolation (reused);\ndashboards, alerts and runbooks (reused); deterministic contract harness (reused).\n\n### Dream state delta\nAfter this plan: hot reads served in-process with a provable read-after-write\nguarantee; the repository interface still allows a shared cache later. Gap to the\n12-month ideal: only the multi-process story, deliberately out of scope.\n\n### Error & Rescue Registry\nSee Section 2 map (cross-reference). Rows: 5; CRITICAL GAPS: 0.\n\n### Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n readProfile | adapter failure | Y | Y | slower, correct | Y (fallback)\n readProfile | repository.read rejects | Y | Y(S5) | typed API error | Y\n readProfile fill | stale fill after commit (S2) | Y (A1) | Y(S2) | correct version | Y (skip counter)\n readProfile fill | stale absent after create (S3)| Y (A1) | Y(S3) | correct record | Y (skip counter)\n writeProfile | repository.write rejects | Y | Y(S4) | typed API error | Y\n writeProfile | cache.delete throws | Y | Y | slower, correct | Y (fallback)\n controller | transition mid-read | Y | Y(S7) | correct | Y\n```\nTotal 7, CRITICAL GAPS 0.\n\n### Diagrams\n1. System architecture: Section 1. 2. Data flow with shadow paths: Section 4.\n3. State machine: Section 1 (per-key `empty → filled(g) → deleted(g+1)`).\n4. Error flow: Section 2.\n5. Deployment sequence:\n```\n deploy wrapper (flag off) -> enable 10% -> healthy hour -> 50% -> healthy hour -> 100% -> acceptance check\n```\n6. Rollback flowchart:\n```\n breach (SLO / p95>120ms 5m / bypass 1m)? --yes--> flag off -> controller drains writes, publishes bypass instance -> runbook incident\n --no---> continue stage\n```\n\n### Stale Diagram Audit\nNo existing ASCII diagrams in touched files were supplied; the plan's own code\nsketch is amended by A1 (not stale: A1 states the added guarantee alongside it).\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific\nfinding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~4h / CC: ~15min)** — wrapper — Implement the fill-admission guard (A1): generation capture before `repository.read`, conditional `cache.set`, generation advance after commit before `cache.delete`\n - Surfaced by: Section 4 — Finding 1.1 / schedule S2 (R1, D1 → A)\n - Files: to be determined (wrapper module)\n - Verify: S1–S6 pass in the deterministic harness with controlled pause/release\n- [ ] **T2 (P1, human: ~4h / CC: ~10min)** — tests — Add regression scenarios S1–S7 to the existing contract harness\n - Surfaced by: Section 6 — S2/S3 assertions missing before A1\n - Files: to be determined (wrapper test suite)\n - Verify: each scenario asserts the exact observed version and fails against the unguarded sketch\n- [ ] **T3 (P2, human: ~1h / CC: ~5min)** — telemetry — Count skipped fills on the existing dashboard without key labels\n - Surfaced by: Section 8 — added branch must be visible\n - Files: to be determined (existing telemetry wiring)\n - Verify: counter increments in S2/S3 runs; dashboard panel shows it\n_No new tasks from Sections 2, 3, 5, 7, 9, 10, 11._\nTask JSONL: **not persisted** (no gstack state root in this fixture).\n\n### Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run instructions |\n | Step 0 | HOLD SCOPE; R1 → A (fill-admission guard) |\n | Section 1 (Arch) | 1 issues found (resolved) |\n | Section 2 (Errors) | 5 error paths mapped, 0 GAPS |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 7 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 0 gaps found |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 1 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (2 items) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 5 rows, 0 CRITICAL GAPS |\n | Failure modes | 7 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (EXP + SEL) |\n | CEO plan | skipped by mode |\n | Outside voice | codex disabled |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 (listed below) |\n +====================================================================+\n```\n\n### Unresolved Decisions\nNone. D1 was resolved by authorized auto-decision (author policy).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps |\n| Outside Review | codex (`codex_reviews: disabled`) | Independent 2nd opinion | 0 | DISABLED | disabled by config; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |\n\n- **OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by `codex_reviews: disabled`; no outside process, no native fallback, no findings claimed.\n- **VERDICT:** CEO CLEARED — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37186854666-t1-pass.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37186854666-t1-pass.json
new file mode 100644
index 000000000..2315ad5b6
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37186854666-t1-pass.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "2986275878c588ef",
+ "source": {
+ "run": "37186854666",
+ "sha": "b24b6d8fcf57db7e9c8ded236d31b8a5d0b4504a",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Genuine pass: R1-FILL-RACE is approved and its question names the write as 'writeProfile'; the ledger reader accepts camelCase write identifiers since the lane D B2 change (before it, only hasStaleFillRaceFinding held).",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nare the complete new read/write ordering rules; no additional version checks or\ncoordination between a cache fill and a write are proposed:\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n### Required guarantee: write-aware fill guard (accepted amendment R1-FILL-RACE, D1 → A)\nThe ordering above is incomplete. A readProfile miss that awaits repository.read\ncan store a snapshot taken before a writeProfile for the same key completed,\nafter that write's cache.delete. Later reads begun after the write completed\nwould then observe the stale value (up to 30 s; up to 10 s as an absent result\nfor a just-created record), contradicting the retained consistency rule.\nRequired behavior, added to the wrapper contract:\n- A cache fill from readProfile is admitted only if no writeProfile for that key\n completed (reached cache.delete) after that read's repository.read began.\n Otherwise the fill is dropped and the read still returns its value.\n- The guard state lives in the active wrapper/cache instance, is updated\n synchronously in the event loop next to cache.delete, has bounded memory, and\n never logs keys. A new controller instance starts with empty guard state.\n- A rejected write does not update the guard (no commit, cache preserved).\n A rejected read performs no fill. Adapter bypass makes the guard moot.\n- Rejected fills are counted on the existing dashboard with no key labels.\nTradeoff: one marker per written key in the active instance and one extra\nbranch; some fills by reads that started after the commit but before the\ndelete are dropped unnecessarily (safe; the next read refills).\nExact data structure and function bodies belong to engineering planning.\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\nAccepted amendment R1-FILL-RACE adds deterministic regression scenarios in the\nexisting repository-contract harness, using controlled pause/release points:\n(a) read miss pauses inside repository.read; write commits, deletes, fulfills;\nread resumes: fill dropped, next read observes the committed version;\n(b) same read but the write fulfills only after the read's fill: fill admitted,\nthen delete removes it; next read observes the committed version;\n(c) missing-record creation: read of an absent key pauses; write creates the\nrecord; read resumes with the absent DTO: fill dropped, next read observes the\nrecord (no 10 s absent window);\n(d) overlapping writes W1, W2 with a paused read: fill dropped, final version\nobserved; (e) rejected write during a paused read: guard untouched, fill\nadmitted; rejected read: no fill, typed error unchanged; (f) controller\ninstance change during a paused old read: old read cannot fill the new\ninstance, new instance starts with empty guard state.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n## CEO Review Working Ledger (plan-ceo-review, 2026-10-04)\n\nPreamble: `gstack-skill-start` not run (fixture: system audit, environment setup and telemetry skipped per run rules). Degraded mode: SESSION_KIND treated as interactive; no Conductor. Config `.gstack-section-state-FStB3C/config.yaml`: `codex_reviews: disabled`.\nPlatform/base branch: git commands not run per run rules; branch `main` from session context. Design doc check skipped (no `/office-hours` offer in automated run). Landscape/web research skipped (HOLD SCOPE, internal backend change). Prior learnings/brain context: unavailable, treated cold.\nReview depth: implementation-ready at the wrapper boundary (readProfile/writeProfile contracts, rescue, tests). Data structures, function bodies and executable tests stay with engineering per PLAN.md \"Author's review and acceptance requirements\".\nStorage policy: working plan = PLAN.md (requested output); scoped appends/Edits; all content retained. Task JSONL, review log, decision log, spec metrics: not persisted (gstack bins unavailable; mutating commands forbidden).\n\nLimits recorded (unchanged): LRU 1000 entries; 16 MiB bytes; value TTL 30 s; absent sentinel TTL 10 s; acceptance ≥60% admitted hits, DB CPU <50%, read p95 <60 ms; stage gates 70%/120 ms baselines; alerts p95 >120 ms for 5 min, bypass >1 min. Deliverables counted: 1 wrapper module (2 functions), 1 wrapper test file, 0 adapter/controller/schema changes. Estimated changed files: 2 (estimate).\n\n### 0A Premise\nReal problem: 900 hot keys re-read from the DB; DB CPU 70%, read p95 120 ms. Target: hits ≥60%, DB CPU <50%, p95 <60 ms with correctness/error SLOs unchanged. Do-nothing cost: DB saturation headroom gone; p95 stays 2x target. The plan attacks the pain directly (repeat reads of hot immutable DTOs), not a proxy. Premise holds.\n\n### 0B Existing leverage\nRepository (read-through interface, single-flight cohort, atomic write, typed error mapping), LRU adapter (limits, absent sentinel, failure bypass), rollout controller (flag, instance swap, predicate parity), telemetry/dashboards/runbooks: all reused unchanged. New code: only the two wrapper functions plus tests. No rebuild proposed; none warranted.\n\n### 0C Dream state\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n DB serves every hot read; ---> process-local LRU in front of ---> repository interface keeps a\n CPU 70%, p95 120 ms the repository; hits >=60%, replacement path open (e.g. a\n no cache layer CPU <50%, p95 <60 ms, same shared cache) without a general\n consistency rule at wrapper framework; consistency rule\n boundary unchanged and tested\n```\nThe plan moves toward the ideal and keeps the replacement path (plan \"Out of scope\").\n\n### 0E Mode\nExplicit user instruction: HOLD SCOPE (\"Hold the current scope (HOLD SCOPE mode)\"). Steps 2–3 skipped. `gstack-ceo-mode-handoff` unavailable in this fixture; handoff line: Mode: HOLD SCOPE; approved decisions: none yet (R1 pending below). Application: preserve scope; trace failures, edge cases, error paths, tests and observability; repairs needed to meet retained invariants are in scope. Provenance: explicit user choice, no question asked, no log.\nNo new approach decision was needed in 0D before mode selection (the plan's approach, a process-local LRU wrapper, is the requested plan). Route: 0G → 0I.\n\n### 0G HOLD SCOPE checks\n1. Complexity: 2 estimated changed files, 0 new classes/services. Under thresholds; no challenge.\n2. Minimum change: the two wrapper functions are the minimum. No deferrable work found; no defer/keep question raised.\n3. Invariants: the retained consistency rule (\"every read begun after that write completes must observe the committed version; TTL expiry is not a substitute\") is violated by the proposed ordering. See R1 below. The repair is in scope under 0G.3 and the author's acceptance requirements.\n\n### Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| R1-FILL-RACE (owner: plan author; Section 4 async ordering, surfaced in 0G.3) | PLAN.md \"Existing contracts retained\" consistency clause; \"Proposed wrapper integration\" readProfile sets cache after `await repository.read`, writeProfile deletes after commit; plan states no coordination between a fill and a write; cohort retirement \"does not inspect cache fills\" | Proposed wrapper as written (no fill/write coordination) | A) add a write-aware fill-guard requirement; B) keep wrapper, rely on TTL; C) delete before and after write | approved | D1 → A, auto-decided under the user's run rules (author representative: \"Authorize complete remedies and required verification that restore its retained contracts\"; recommended option). Scope: the fill-guard requirement, its tradeoffs and regression scenarios (a)–(f) in PLAN.md; data structure and code left to engineering. No limit, TTL, consistency or cohort change. |\n\n### currentDecision (R1-FILL-RACE)\nCommitment comparison:\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead begun after writeProfile completes observes committed version | PLAN.md retained consistency clause (author-approved) | required, violated by proposal | met: stale fill dropped | violated (TTL bounds staleness only) | violated (window narrowed, not closed)\nTTL is not a substitute for the rule | same clause | required | kept | broken | kept\nAdapter limits 1000 / 16 MiB / 30 s / 10 s sentinel | PLAN.md retained | unchanged | unchanged | unchanged | unchanged\nSingle-flight cohort and write-retire semantics | PLAN.md retained | unchanged | unchanged | unchanged | unchanged\nController instance isolation | PLAN.md rollout | unchanged | guard state is per instance | unchanged | unchanged\nNew wrapper state | PLAN.md proposes none | none | per-instance, per-key write-completion marker; structure left to engineering | none | none\nTelemetry for added branch | author policy: existing telemetry, no key labels, no new alert/metric project | n/a | rejected-fill counter on current dashboard | n/a | n/a\nRegression scenarios | author acceptance requirements | required (not yet implemented) | both completion orders, missing-record creation, overlapping writes, rejected read/write, instance isolation | none added (rule unmet) | both orders (would fail) \n```\n\nQuestion: D1 — R1-FILL-RACE: How should the plan close the stale-cache-fill race between an in-flight readProfile miss and a completed writeProfile?\nProject/branch/task: main; CEO review of PLAN.md \"cache profile summaries in one process\".\nELI10: A read that misses the cache goes to the database, waits, then stores what it got. If a write finishes and clears the cache during that wait, the old read still stores its outdated copy afterwards. Every later read, for up to 30 seconds (10 seconds for a \"missing\" record that was just created), gets stale data. The plan promises that reads begun after a write completes always see the new version, so this breaks a retained contract.\nStakes if we pick wrong: Users see stale profile summaries or a just-created profile reported missing; the correctness SLO breaks under a plain read/write overlap, not a rare failure.\nRecommendation: A because it restores the retained rule with in-process state only, reusing the adapter, cohort and instance lifecycle unchanged; immediate cost is small (one marker per key in the active instance, one branch) and future maintenance is low because the rule is tested deterministically.\nCompleteness: A=10/10, B=3/10, C=5/10\nPros / cons:\nA) Add write-aware fill-guard requirement (recommended)\n ✅ Restores the retained consistency rule without changing limits, TTLs or cohort semantics\n ✅ Needs only per-instance in-process state; synchronous in the single event loop, so no new races\n ❌ Adds per-key bookkeeping and a rejected-fill branch that engineering must size and test\nB) Keep the proposed wrapper and rely on the 30 s TTL to bound staleness\n ✅ Zero implementation work beyond the proposed wrapper\n ✅ No new wrapper state to maintain or size\n ❌ Contradicts the retained clause \"TTL expiry is not a substitute\"; not authorizable\nC) Delete the cache key before and after repository.write\n ✅ Two-line change to writeProfile with no new state\n ✅ Narrows the stale window for reads that started before the write began\n ❌ A read that started before the first delete and fills after the second still stores stale data; rule still unmet\nNet: Only A meets the retained rule; B and C trade a correctness contract for less code.\nHeader: R1 stale fill race\nA) Fill-guard requirement (recommended)\nAmend the plan with a required guarantee: a cache fill from readProfile is admitted only if no writeProfile for that key completed (reached cache.delete) after that read's repository.read began; otherwise the fill is dropped and the read still returns its value. State is per wrapper/cache instance, synchronous in the event loop, bounded memory, no key logging; rejected fills are counted on the existing dashboard. Effort S; risk low; reuse: adapter, cohort, controller lifecycle unchanged; verification: deterministic both-order, missing-record creation, overlapping-write, rejected read/write and instance-isolation scenarios in the existing contract harness. ✅ Restores the retained rule with no limit or TTL change. ✅ In-process only; no new adapter or transport. ❌ Adds per-key bookkeeping and a new branch to maintain.\nB) Keep wrapper, rely on TTL\nKeep the proposed ordering and accept up to 30 s (10 s absent) staleness after an overlapping write. Effort S with zero implementation work; risk high; reuse: full; verification: none added for the race. ✅ No new code or state. ✅ Simplest wrapper. ❌ Violates the retained clause that TTL is not a substitute; weakens consistency, which this review cannot authorize.\nC) Double delete around the write\nCall cache.delete before and after repository.write. Effort S; risk high; reuse: full; verification: both-order tests would still fail for a read spanning both deletes. ✅ Two-line change. ✅ Shrinks the race window. ❌ Does not close the window, so the retained rule remains violated.\n\n### D1 answer record\nD1 — R1-FILL-RACE → A) Fill-guard requirement. Authority: user run rules for this capture (plan-author representative, recommended option within retained-contract scope). No human asked; preamble spawned/auto-decide transport not available, so this is recorded as an authorized auto-decision, not an AskUserQuestion answer. Applied: \"Required guarantee\" subsection and scenarios (a)–(f) in the plan body; ledger row R1 approved. Not applied: any implementation choice (marker structure, counter name).\n\n### 0I Temporal interrogation\n```\n HOUR 1 (foundations): wrapper boundary semantics; where \"write completes\" is (after cache.delete); guard scoped per instance\n HOUR 2-3 (core logic): ambiguity: guard update point (at cache.delete, synchronously) and read capture point (before awaiting repository.read); absent DTO fills pass through the same guard\n HOUR 4-5 (integration): surprise: controller instance swap must not carry guard state; adapter bypass path must skip the fill and the counter cleanly\n HOUR 6+ (polish/tests): pause/release harness hooks inside repository.read; counter on the existing dashboard with no key labels\n```\nFeasibility blockers: none remaining after R1. Pending choices (engineering): guard data structure and bounding strategy; rejected-fill counter name. Effort: human ~1 day / CC+gstack ~30 min for wrapper + tests.\n\n## Review Sections (HOLD SCOPE, implementation-ready at wrapper boundary)\n\nCurrent scope: Mode HOLD SCOPE (explicit user instruction). Governing rows: R1-FILL-RACE approved (D1 → A, authorized auto-decision; scope above). Accepted work: proposed wrapper + fill-guard requirement + scenarios (a)–(f). Deferred: none. Rejected: none. Pending (engineering): guard structure, counter name.\n\n### Section 1: Architecture Review — OK, 0 issues\n```\n caller --auth/authz--> readProfile/writeProfile (wrapper, per controller instance)\n | cache.get/set/delete (sync, atomic) | guard: per-key write marker\n v v\n LRU adapter (1000 / 16 MiB / 30 s, absent sentinel 10 s, failure -> bypass)\n |\n repository.read (single-flight cohort) / repository.write (atomic, rollback on reject)\n |\n in-process transactional store (no network)\n rollout controller: flag predicate (shared read/write) -> drain writes -> publish new instance\n```\nBoundaries unchanged; new coupling: wrapper ↔ guard state (in-instance, justified by R1). Scaling: at 10x keys the LRU evicts (bounded 16 MiB); at 100x the hit rate falls and DB load returns to baseline, never above it. SPOF: the one process (pre-existing). Security: no new surface. Production failure: adapter failure → bypass → DB load returns to 70% baseline; alert on bypass >1 min. Rollback: flag disable, immediate, instance swap drains writes. Decision gate: no new choice; R1 cited.\n\n### Section 2: Error & Rescue Map — 5 paths, 0 GAPS\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS (existing)\n ---------------------------|--------------------------------------|---------------------------\n readProfile cache.get | adapter failure | CacheAdapterError -> bypass\n readProfile repository.read| store error / timeout | typed API error (existing mapping)\n readProfile cache.set | adapter failure / byte cap | CacheAdapterError -> bypass; cap = evict\n writeProfile repository.write | rejected (rolled back) | typed API error (existing mapping)\n writeProfile cache.delete | adapter failure | CacheAdapterError -> bypass until reinit\n\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n ----------------------|----------|---------------------------------------------|---------------------\n CacheAdapterError | Y | bypass cache until empty reinit; counted | normal latency, correct data\n typed API error (read)| Y | cohort released; no fill; error mapped | existing error response\n typed API error (write)| Y | no commit; guard untouched; cache preserved | existing error response\n```\nNo catch-alls introduced; wrapper adds no new exception classes. Guard update is synchronous in-process and has no failure path beyond a defect (covered by tests (a)–(f)). Decision gate: no new choice.\n\n### Section 3: Security & Threat Model — 0 issues, 0 High\nAttack surface: none added (no endpoint, param, job or file path). Authorization: runs before the wrapper; keys encode tenant + validated profile ID, so a cached value cannot cross tenants (threat: cross-tenant read via key collision — likelihood Low, impact High, mitigated by retained key contract). Secrets/keys never logged; rejected-fill counter has no labels. Dependencies: none new. Data class: profile summaries (existing handling). Decision gate: no new choice.\n\n### Section 4: Data Flow & Interaction Edge Cases — 6 edge cases mapped, 0 unhandled (after R1)\n```\n INPUT key -> (auth upstream) -> cache.get -> miss -> repository.read -> guard check -> cache.set -> OUTPUT\n shadow: nil key: rejected upstream (existing validation) | absent record: absent DTO, sentinel 10 s\n adapter error: bypass | store error: typed error, no fill | stale snapshot: dropped by guard\n```\nAsync ordering (invariant: a read begun after writeProfile fulfills observes the committed version):\n```\n time | readProfile R (miss) | writeProfile W | cache[key] | guard[key]\n t1 | get -> miss; capture guard=g0 | | - | g0\n t2 | await repository.read (v1 snap) | | - | g0\n t3 | | await write -> commit v2 | - | g0\n t4 | | cache.delete; guard=g1 | - | g1\n t5 | | promise fulfills (complete) | - | g1\n t6 | resume; guard g1 != g0 -> DROP | | - | g1\n t7 | fresh read R2 (begun after t5) | | miss -> v2 fill | g1\n Other order: R fills at t2' before t4 -> cache=v1; t4 delete removes it; R2 at t7 sees v2. OK.\n Without the guard (proposed wrapper): t6 would set cache=v1; R2 returns v1 until TTL -> VIOLATION.\n```\n**CRITICAL GAP (resolved): R1-FILL-RACE** — evidence: proposed readProfile/writeProfile ordering plus \"no additional version checks or coordination\" and \"does not inspect cache fills\". Remedy: D1 → A fill-guard requirement (approved). Residual risk: unnecessary drops for reads starting between commit and delete (safe). Verification: scenarios (a)–(f). Interaction edge cases: no user-visible interaction changes (backend only). Decision gate: exact prior answer D1 covers it; applied.\n\n### Section 5: Code Quality Review — 0 issues\nTwo wrapper functions plus guard check fit the existing read-through pattern; no duplication (single-flight stays in repository.read, invalidation stays in the wrapper). Branches per function ≤ 4. Naming to be settled by engineering; no abstraction added. Decision gate: no new choice.\n\n### Section 6: Test Review — diagram produced, 0 gaps\n```\n readProfile: hit | miss+fill | miss+guard-drop (a,c,d) | miss+adapter-bypass | read rejected (e)\n writeProfile: commit+delete (b) | rejected (e) | overlapping W1/W2 (d) | delete adapter failure\n controller: instance swap mid-read (f) | predicate parity | old-writer/new-reader\n limits: eviction, 16 MiB cap, 30 s TTL, 10 s absent sentinel\n```\nUnit: wrapper hit/miss/guard; Integration: contract harness with pause/release for (a)–(f); System: rollout stage gates. Assertions map 1:1 to retained clauses (exact: \"next read observes the committed version\", \"no 10 s absent window\"). 2am test: (a); hostile QA: (c) create-after-absent; chaos: adapter failure mid-fill. Pyramid upright; flakiness: none (deterministic pause points, no wall clock; TTL tests use the harness clock). Load: cold-start within existing DB capacity (plan). Decision gate: coverage carried from D1 and the author's requirements; no new choice.\n\n### Section 7: Performance Review — 0 issues\nMemory: ≤1000 entries / 16 MiB plus guard markers bounded by written keys in the instance (engineering must bound, per R1). No new queries or indexes; no N+1; no pool pressure (fewer DB reads). Slow path: miss + store read, unchanged. WARNING (informational): guard drops lower the hit rate on write-heavy keys; visible via the rejected-fill counter; acceptance targets unchanged. Decision gate: no new choice.\n\n### Section 8: Observability & Debuggability — 0 gaps\nExisting metrics: hit/miss, eviction, bytes, fallback errors, DB CPU, p95; added branch: rejected-fill counter on the current dashboard, no key labels (author policy). Alerts and runbooks exist (SLO breach, p95 >120 ms 5 min, bypass >1 min). Debuggability: counters plus typed errors reconstruct behavior without keys. No new alert or metric project. Decision gate: no new choice.\n\n### Section 9: Deployment & Rollout — 0 new risks\nNo migration. Flag staged 10% → 50% → 100% with one healthy hour each; breach disables immediately. Rollback: disable flag → controller drains writes → bypass instance; seconds. Deploy window: old/new instances isolated by the controller. Post-deploy: first 5 min watch bypass and error rate; first hour the stage gate. Smoke: hit/miss counters moving, zero fallback errors. Decision gate: no new choice.\n\n### Section 10: Long-Term Trajectory — Reversibility 5/5, debt items: 1\nDebt: guard bookkeeping must stay next to cache.delete (document with an ASCII comment). Path dependency: none; repository interface keeps the replacement path. Knowledge: this plan plus scenarios (a)–(f) suffice. Ecosystem fit: in-process, same patterns. Decision gate: no new choice.\n\n### Section 11: Design & UX Review — SKIPPED (no UI scope)\n\n## Closing sequence\nOutside Voice: `codex_reviews: disabled` → Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`. No prompt, outside process or native fallback. Disabled-coverage record: not persisted (`gstack-review-log` unavailable; mutating commands forbidden in this run).\nTODO choices: none proposed (HOLD SCOPE; no evidenced deferred gap).\nApproval readiness: PASS — rows checked: R1-FILL-RACE (D1 → A, authorized auto-decision; scope limited to the fill-guard requirement and scenarios (a)–(f)). No declined/deferred items in accepted work.\n\n## Required Outputs\n\n### Review facts\nMode HOLD SCOPE; findings: 1 (R1-FILL-RACE, critical, resolved by approved remedy); unresolved choices: 0; critical gaps remaining: 0; scope proposals: 0; outside coverage: codex disabled. Status: clean.\n\n### NOT in scope\nDeferred: none. Rejected: none. (Declined options B and C of D1 were remedy alternatives, not scope items.) Pre-existing exclusions unchanged: distributed caching, cross-process coherence, prewarming, consistency changes, new product surfaces.\n\n### What already exists\nRepository read-through interface, single-flight cohort and write-retire, atomic write with rollback, typed error mapping, LRU adapter with limits/sentinel/bypass, rollout controller with instance isolation and predicate parity, telemetry dashboards, alerts and runbooks. The plan reuses all of them unchanged; only the wrapper and its tests are new.\n\n### Dream state delta\nAfter this plan: hot reads served in-process at the acceptance targets, consistency rule intact and tested, replacement path preserved. Remaining distance to the 12-month ideal: any shared cache stays out of scope by design.\n\n### Error & Rescue Registry\nSee Section 2 map (5 codepaths, 3 exception classes, all rescued by existing mechanisms; 0 CRITICAL GAPS).\n\n### Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n ----------------------------|-------------------------------|----------|--------------|-------------------|--------\n readProfile cache.get/set | adapter failure | Y bypass | Y (existing) | correct, slower | Y fallback metric\n readProfile repository.read | store error | Y typed | Y (existing) | existing error | Y existing\n readProfile fill | stale fill after write (R1) | Y guard | Y (a,c,d) | committed version | Y rejected-fill count\n writeProfile repository.write| rejected / rollback | Y typed | Y (e) | existing error | Y existing\n writeProfile cache.delete | adapter failure | Y bypass | Y (existing) | correct, slower | Y fallback metric\n controller swap | old read fills new instance | Y isolation | Y (f) | n/a | Y existing\n 6 rows, 0 CRITICAL GAPS\n```\n\n### Diagrams\n1. System architecture: Section 1. 2. Data flow with shadow paths: Section 4. 3. State machine (cache entry):\n```\n [absent] --fill(guard ok)--> [value|sentinel] --TTL 30s/10s or evict--> [absent]\n [value|sentinel] --cache.delete (write complete)--> [absent]\n [absent] --fill(guard changed)--> [absent] (drop; counted)\n invalid: fill after delete with stale snapshot -> prevented by guard; fill into other instance -> prevented by controller\n```\n4. Error flow: Section 2. 5. Deployment sequence:\n```\n deploy wrapper (flag off) -> 10% keys, 1 healthy hour -> 50%, 1 healthy hour -> 100%, absolute targets -> record results\n```\n6. Rollback flowchart:\n```\n breach? -> yes -> disable flag -> controller drains writes -> bypass instance -> runbook records incident -> resume criteria\n -> no -> continue stage\n```\n\n### Stale Diagram Audit\nFiles touched are to be determined; no existing ASCII diagrams known in them. Plan diagrams above are current with the accepted amendment.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~4h / CC: ~15min)** — profile-summary wrapper — Implement readProfile/writeProfile with the write-aware fill guard per \"Required guarantee\"\n - Surfaced by: Section 4 — R1-FILL-RACE (D1 → A)\n - Files: to be determined (wrapper module)\n - Verify: contract harness scenarios (a)–(f) pass; existing repository contract tests unchanged and green\n- [ ] **T2 (P1, human: ~4h / CC: ~10min)** — wrapper tests — Add deterministic pause/release scenarios (a)–(f) plus hit/miss, eviction, byte cap, TTL, bypass, invalidation, preservation, coalescing\n - Surfaced by: Section 6 — Test Review (coverage carried from D1 and author requirements)\n - Files: to be determined (wrapper test file in the existing harness)\n - Verify: each scenario fails against the unguarded wrapper and passes with the guard\n- [ ] **T3 (P2, human: ~1h / CC: ~5min)** — telemetry — Record the rejected-fill branch on the current dashboard with no key labels\n - Surfaced by: Section 8 — Observability (author policy: existing telemetry only)\n - Files: to be determined\n - Verify: counter increments in scenario (a); no key labels present\n_No new tasks from Sections 1, 2, 3, 5, 7, 9, 10, 11._\nTask JSONL: not persisted (gstack state root unavailable; no mutating commands in this run). Ratios assumed: bug fix with regression ~20x, tests ~50x.\n\n### Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run rules; fixture contracts |\n | Step 0 | HOLD SCOPE; R1-FILL-RACE approved (D1 → A) |\n | Section 1 (Arch) | 0 issues found |\n | Section 2 (Errors) | 5 error paths mapped, 0 GAPS |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 6 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 0 gaps found |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 1 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (0 items) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 5 rows, 0 CRITICAL GAPS |\n | Failure modes | 6 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (EXP + SEL) |\n | CEO plan | skipped by mode |\n | Outside voice | codex + disabled |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 (listed below) |\n +====================================================================+\n```\n\n### Unresolved Decisions\nNone. D1 was resolved by an authorized auto-decision (run rules); no question went unanswered.\n\nReview Log / decision log / dashboard: not persisted (gstack bins unavailable; mutating commands forbidden). This run is unlogged. Learnings: No durable learnings this session.\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps (1 found, resolved by R1-FILL-RACE) |\n| Outside Review | codex (disabled) via `/plan-ceo-review` | Independent 2nd opinion | 0 | disabled | codex_reviews disabled; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |\n\n- **OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by config (codex_reviews: disabled); no outside process, no native fallback; no completed external review. Disabled record not persisted.\n- **VERDICT:** CEO CLEARED — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37193478719-t1-fail.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37193478719-t1-fail.json
new file mode 100644
index 000000000..af2d4e572
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37193478719-t1-fail.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "2986275878c588ef",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "failed",
+ "census_error": "Error: expect(received).toBe(expected)",
+ "expected": "pass",
+ "justification": "Fixed false red: at 9f357d9 the ledger reader accepted only R row IDs and this report recorded the approved decision as CEO-F1 (phrase fallback also false); 4128700 accepts any ledger row ID, so the approved CEO-F1 decision now reads.",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nare the new read/write ordering rules as originally proposed. **Amended by CEO review\nD1 (CEO-F1):** the original text proposed no coordination between a cache fill and a\nwrite; that contradicts the retained consistency rule (see CEO-F1 below). Required\nguarantee added: `readProfile` must skip its `cache.set` when a `writeProfile` for the\nsame key completed `cache.delete` after that read began (a per-instance write-completion\ntoken checked synchronously around the fill; representation chosen by engineering). The\nsketch below is the pre-amendment baseline, retained for review traceability:\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\n**Added by CEO review D1 (CEO-F1), deterministic schedules on the existing harness:**\n(a) read pauses after its store snapshot, write commits + deletes + fulfills, read resumes:\nfill must be skipped and a fresh read must observe the committed version; (b) read fills\nbefore the write completes: delete must remove it and a fresh read observes the committed\nversion; (c) same as (a) for a missing record created by the write: absent sentinel must\nnot be installed after creation; (d) rejected write leaves the cache and token unchanged;\n(e) two overlapping writes: a fresh read after both complete observes the last commit.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n# CEO Review (plan-ceo-review, 2026-10-04, HOLD SCOPE)\n\nRun notes: automated fixture run, no human present. Preamble/system audit, environment\nsetup, telemetry, design-doc offer and learnings-search skipped per run rules. Outside\nreview: `codex_reviews: disabled` in `.gstack-section-state-Zns2Y9/config.yaml`, so the\noutside voice step and its native fallback are skipped and recorded as disabled coverage.\nBase branch: main (git read-only snapshot; no git commands run).\n\n## Step 0\n\n**Review depth:** implementation-ready for wrapper ordering, rescue and test behavior.\nAuthor policy limits: data structures, function bodies and executable test code stay\nwith engineering planning; this review records required guarantees and feasibility.\n**Storage policy:** working plan = this file (requested output); scoped Edits at checkpoints.\n**Stated limits (kept):** LRU 1000 entries, 16 MiB, 30 s TTL, 10 s absent sentinel;\ntargets hits >= 60%, DB CPU < 50%, read p95 < 60 ms; rollout 10% -> 50% -> 100%,\none healthy hour per stage. Deliverables: 1 wrapper module edit + 1 wrapper test file\n(estimate: 2 changed files, excluding unchanged reused adapter/controller/repository).\n\n### Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| CEO-F1 (Section 1 / Section 4; owner: wrapper engineering) | Consistency rule, PLAN.md \"Existing contracts retained\" last bullet: every read begun after writeProfile completes must observe the committed version; TTL is not a substitute. Single-flight bullet: already-started readers may finish with their earlier snapshot; admission rule does not inspect cache fills. Proposed wrapper (PLAN.md readProfile/writeProfile): fill after `await repository.read` with no guard; plan states no fill/write coordination. | Proposed wrapper as written: unguarded `cache.set` after read; `cache.delete` after commit. | A) fill-after-write guard required in wrapper; B) keep wrapper, accept TTL-bounded staleness; C) move fill coordination into repository single-flight cohort. | approved | D1 answered A (auto-decision authorized by the run's author policy: \"Authorize complete remedies and required verification that restore its retained contracts... use the recommended option\"). Scope: wrapper must skip a cache fill when a writeProfile for that key completed cache.delete after the read began; deterministic regression schedules for both completion orders and missing-record creation; one added branch recorded on the existing dashboard with no key labels. Token mechanism left to engineering. B declined (weakens retained contract). C declined (changes accepted fixture contract). |\n\n### 0A. Premise Challenge\nReal problem: ~900 hot keys re-read from the DB; DB CPU 70%, read p95 120 ms (one-week\ntrace). Target: hits >= 60%, DB CPU < 50%, p95 < 60 ms, SLOs unchanged. Do-nothing cost:\nDB saturation headroom shrinks; latency stays 2x the target. The plan attacks the pain\ndirectly (repeat reads of a small hot set in one process), not a proxy. OK.\n\n### 0B. Existing Code Leverage\nRepository (single-flight, typed errors, absent DTO), LRU adapter (limits, sentinel,\nbypass-on-failure), rollout controller (instance swap, predicate parity), telemetry and\nrunbooks are all reused unchanged. Only the thin wrapper and its tests are new. No rebuild.\n\n### 0C. Dream State Mapping\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n Every hot read hits the ---> process-local LRU in ---> repository interface\n DB; CPU 70%, p95 120 ms front of repository; still the seam; swap\n one process, no cache hits >= 60%, p95 < 60 ms adapter if multi-process\n ever becomes supported\n```\nMoves toward the ideal without committing to a distributed cache. OK.\n\n### 0D\nNo approach choice was needed in Step 0: the requested plan (A, current) is the only\napproach consistent with the accepted contracts. Section findings use 0D below.\n\n### 0E. Mode Selection\nExplicit user instruction: HOLD SCOPE. Mode: HOLD SCOPE; approved decisions: none.\nApplication: preserve scope, maximum rigor on failures, ordering, tests, observability.\nProvenance: explicit user choice; no question asked, no question log.\nNo new approach decision was needed.\n\n### 0G. HOLD SCOPE checks\n1. Complexity: ~2 changed files, 0 new classes/services. No challenge needed.\n2. Minimum change: wrapper + tests is already minimal. Nothing deferrable without\n blocking the acceptance targets. No deferral questions.\n3. Invariants and acceptance criteria retained; repairs needed to meet them (CEO-F1) are in scope.\n\n### 0I. Temporal Interrogation\n```\n HOUR 1 (foundations): Adapter/sentinel semantics; undefined = miss; absent DTO round-trip.\n HOUR 2-3 (core logic): Fill-after-write race (CEO-F1); where the guard token lives.\n HOUR 4-5 (integration): Controller instance swap; flag predicate parity; bypass paths.\n HOUR 6+ (polish/tests): Deterministic pause/release schedules; fake clock for TTL.\n```\nFeasibility blocker: CEO-F1 (resolved below). Pending choices left to engineering:\nguard token representation, TTL clock injection. Effort: human ~2 days / CC ~30 min.\n\n## Section 1: Architecture Review\n\n**Current scope:** Mode HOLD SCOPE (explicit user instruction). Governing rows: CEO-F1\npending. Accepted: wrapper + tests + existing rollout. Deferred/rejected: none.\n\nDependency graph (new component marked *):\n```\n caller -> auth/authz -> [readProfile/writeProfile]* -> LRU adapter (existing)\n | \n +-> repository (single-flight, typed errors) -> in-process store\n rollout controller (existing) --publishes/retires--> wrapper+cache instance*\n```\nData paths: happy (miss -> store -> fill -> return), nil (absent DTO -> sentinel 10 s ->\ndecoded DTO), empty (n/a: DTOs immutable, no empty payloads), error (read rejects -> no\nfill -> typed error; write rejects -> no delete -> cache retains committed value, correct\nbecause rollback is guaranteed). Scaling: 10x load is served from cache; 100x saturates\nthe single process before the DB. SPOF: the one process (pre-existing). Security: no new\nsurface. Rollback: flag off -> controller swaps to bypass instance; seconds.\n\n**CRITICAL GAP (CEO-F1) — stale fill after a completed write.** A read that starts before\na write can resolve its store snapshot after `writeProfile` has already run `cache.delete`\nand fulfilled. Its `cache.set` then installs the pre-write value (or the absent sentinel\nfor a just-created record). Every later read hits that stale entry for up to 30 s (10 s\nfor the sentinel), violating the retained rule that every read begun after the write\ncompletes observes the committed version. The retained single-flight rule explicitly lets\nalready-started readers finish with their earlier snapshot and does not inspect fills; the\ncontroller isolation covers instance swaps only. Nothing in the plan prevents this schedule.\nSchedule proof: Section 4.\n\n## currentDecision (CEO-F1)\nCommitment comparison:\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead-after-write visibility at wrapper boundary | retained contract (PLAN.md consistency bullet) | violated by stale fill | kept | weakened to TTL bound | kept\nWrapper read/write ordering | proposed, unapproved | unguarded fill | fill guarded by write completion | unchanged | unchanged in wrapper\nrepository single-flight contract | accepted fixture fact | does not inspect fills | unchanged | unchanged | changed (must inspect fills)\nAdapter limits / TTL / sentinel | accepted fixture fact | 1000 / 16 MiB / 30 s / 10 s | unchanged | unchanged | unchanged\nRegression tests: both completion orders, missing-record creation | author acceptance requirement | required, not implemented | added as deterministic schedules | would need to expect stale reads (not allowed) | added at repository level |\nAdded branch on dashboard, no key labels | author permits existing telemetry | none | one \"fill suppressed\" branch | none | none |\nCompleteness: A=10/10, B=3/10, C=7/10\n```\n\nQuestion: D1 — CEO-F1: How does the wrapper prevent a read begun before a write from filling the cache after that write completes?\nProject: profile-summary service, branch main, process-local LRU wrapper plan.\nELI10: A slow read can fetch the old value, then a write finishes and clears the cache, then the slow read puts the old value back. Everyone sees stale data for 30 seconds. The plan promises that never happens after a write completes.\nStakes if we pick wrong: correctness SLO breach and stale profiles after every write that overlaps a read; the rollout would be rolled back on alerts.\nRecommendation: A because it keeps the retained consistency contract with the smallest clear diff (explicit over clever) and leaves the token mechanism to engineering.\nCompleteness: A=10/10, B=3/10, C=7/10\nPros / cons:\nA) Require a fill-after-write guard in the wrapper (recommended)\n ✅ Preserves the retained read-after-write rule; stale fill becomes impossible by construction\n ✅ Local to the wrapper; adapter, repository and controller contracts stay untouched\n ❌ Adds one small piece of per-instance state and one extra branch to test and chart\nB) Keep the wrapper as proposed; accept staleness bounded by TTL\n ✅ Zero implementation work beyond the proposed wrapper (effort S, nothing new to build)\n ✅ Simplest code; no extra state in the wrapper\n ❌ Weakens a retained contract; TTL is explicitly not a substitute, so this cannot be authorized\nC) Move fill coordination into repository single-flight cohort retirement\n ✅ Centralizes ordering in one place for all future callers of the repository\n ✅ Guard would cover any other caller that fills caches from repository reads\n ❌ Changes an accepted fixture contract (admission rule does not inspect fills); wider, riskier diff\nNet: A fixes the demonstrated gap inside the new code; B breaks the promise; C fixes it by rewriting an accepted contract.\nHeader: Stale fill guard\nA) Guard wrapper fill (recommended)\nRequire: a read's cache.set is skipped when a writeProfile for that key completed cache.delete after the read began. Effort S, risk low, reuses adapter and harness; verification: both completion orders and missing-record creation as deterministic schedules. ✅ keeps retained contract ✅ wrapper-local change ❌ one more branch and token state.\nB) Keep wrapper, TTL bound\nKeep the proposed ordering and rely on 30 s / 10 s TTL to age out stale fills. Effort S, zero implementation work, risk high; verification would have to assert stale reads. ✅ nothing to build ✅ simplest code ❌ violates retained consistency rule, not authorizable.\nC) Coordinate in repository\nMake repository single-flight retirement reject fills from retired cohorts. Effort M, risk medium, reuses single-flight; verification at repository contract level. ✅ one central guard ✅ covers other callers ❌ rewrites an accepted fixture contract and widens the diff.\n\n**Answer D1: A (authorized auto-decision, see ledger row CEO-F1).** Plan amended in\n\"Proposed wrapper integration\" and \"Verification and rollout\". Section 1 outcome: 1 issue (CEO-F1, remedied).\n\n## Section 2: Error & Rescue Map\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS\n -------------------------|--------------------------------------|------------------\n readProfile#cache.get | adapter failure | adapter-internal (bypass)\n readProfile#repo.read | store failure / timeout | typed repository error\n | invalid key / unauthorized | typed validation/auth error\n readProfile#cache.set | adapter failure, byte cap, stale fill| adapter-internal / CEO-F1 guard\n writeProfile#repo.write | rejected (rolled back) | typed repository error\n writeProfile#cache.delete| adapter failure after commit | adapter-internal (bypass)\n\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n -------------------------|----------|---------------------------------------|---------------\n adapter-internal | Y | adapter bypasses cache until reinit; | transparent;\n | | fallback error metric + 1-min alert | slower reads\n typed repository error | Y (exist)| existing API error mapping; no fill; | existing typed\n | | single-flight released | API error\n stale fill (CEO-F1) | Y (D1) | fill skipped; \"fill suppressed\" branch| fresh value\n```\nNo catch-alls. **WARNING (W1, verification only):** a `cache.delete` adapter failure after\na committed write must not be reported as a write failure; the retained adapter contract\nsays failures bypass internally, so this is a determined check within the accepted\n\"adapter-failure fallback\" coverage (write path), not a new choice. Outcome: 6 paths mapped, 0 open GAPS.\n\n## Section 3: Security & Threat Model\nNo new endpoints, params, jobs or dependencies. Keys carry authenticated tenant + validated\nprofile ID (retained), so cross-tenant IDOR via cache is prevented (likelihood Low, impact\nHigh, mitigated). Values are immutable DTOs; secrets and keys never logged (retained).\nStale-fill (CEO-F1) is correctness, not confidentiality. Flag predicate shared by reads\nand writes prevents write-path/read-path asymmetry. Outcome: No issues found, 0 High.\n\n## Section 4: Data Flow & Interaction Edge Cases\n```\n INPUT(key) -> VALIDATION(auth/key, existing) -> cache.get -> [hit] OUTPUT\n \\-> [miss] repo.read -> guard -> cache.set -> OUTPUT\n shadow: absent -> absent DTO -> sentinel(10 s) -> decoded DTO tested (accepted)\n shadow: read rejects -> no fill -> typed error tested (accepted)\n shadow: adapter failure -> bypass -> store read tested (accepted)\n shadow: byte cap / eviction -> entry dropped -> next read misses tested (accepted)\n```\nAsync ordering (invariant: a read begun after writeProfile fulfills observes the committed\nversion; boundary = readProfile call vs writeProfile fulfillment):\n```\n t | readProfile R1 (begun before W) | writeProfile W | cache[key] | store\n 1 | get -> undefined (miss) | | - | v0\n 2 | await repo.read (snapshot v0) | | - | v0\n 3 | | await repo.write | - | v1 committed\n 4 | | cache.delete; fulfill | - | v1 <- W complete\n 5 | resume: cache.set(key, v0) | | v0 STALE | v1\n 6 | R2 begins: get -> v0 | | v0 | v1 <- VIOLATION\n```\nReverse order (R1 fills at t3 before W deletes at t4) is safe: delete removes v0 and R2\nmisses to v1. Only the schedule above violates; single-thread execution does not exclude\nit because the violation spans the `await`. Mechanism after D1: at t5 the guard sees a\nwrite completion for the key after R1 began and skips the fill; R2 misses to v1. Same\nproof covers the absent sentinel for missing-record creation. Regression proof: schedules\n(a)-(e) in \"Verification and rollout\". Overlapping writes W1/W2: both delete; store holds\nlast commit; fresh read observes it. Interaction edge cases: no UI; double-submit handled\nby store atomicity. Outcome: 6 edge cases mapped, 0 unhandled after D1.\n\n## Section 5: Code Quality Review\nWrapper fits the existing read-through pattern; no duplication (adapter owns sentinel/TTL,\nrepository owns single-flight). Naming fine. The D1 guard adds one branch (<= 3 branches\nper function). No over-engineering; keep the token per wrapper instance so the controller\nswap discards it. Outcome: No issues found.\n\n## Section 6: Test Review\n```\n NEW THING | TYPE | HAPPY | FAILURE | EDGE\n readProfile hit/miss/fill | unit (harness)| miss->fill->hit | repo.read rejects | absent sentinel\n writeProfile commit+delete | unit | commit->delete | write rejects, no delete| overlapping writes\n fill-after-write guard (D1) | unit sched. | schedule (b) | schedule (a),(c) | (d),(e)\n adapter bypass | unit | n/a | get/set/delete failures | bypass persists alert\n controller instance swap | integration (existing)| swap | old writer/new reader | predicate parity\n```\nAssertion check: each accepted requirement maps to an observable assertion (committed\nversion visible to a read begun after fulfillment; no sentinel after creation; cache\nunchanged after rejected write). 2 am test: schedule (a). Hostile QA: schedule (c) plus\neviction mid-schedule. Chaos: adapter failure injected at t5. Pyramid: unit-heavy, correct.\nFlakiness: TTL tests must use the harness's controlled clock (engineering detail, owner:\nwrapper engineering; not a new decision). Outcome: diagram produced, 0 gaps after D1.\n\n## Section 7: Performance Review\n900 hot keys fit 1000 entries / 16 MiB (retained). Guard is O(1) synchronous per op; its\nstate must stay bounded (per-instance; engineering chooses per-key map with cleanup or a\nprocess-wide generation; either satisfies D1). Slowest new path: miss + store read, same as\ntoday. No new connections. Outcome: No issues found.\n\n## Section 8: Observability & Debuggability Review\nExisting metrics cover hit/miss/eviction/bytes/fallback/DB CPU/p95 without raw IDs. D1 adds\none \"fill suppressed\" counter on the current dashboard, no key labels (author-permitted).\nAlerts and runbooks are existing contracts. Outcome: 0 gaps.\n\n## Section 9: Deployment & Rollout Review\nNo migration. Flag-staged 10/50/100 with instance swap; rollback = disable flag (seconds).\nRisk window: old-instance reads cannot fill the new instance (retained). D1 token lives in\nthe instance, so swaps reset it safely. Smoke: hit rate, fallback errors, suppressed-fill\ncount visible within first 5 minutes. Outcome: 0 new risks beyond retained contracts.\n\n## Section 10: Long-Term Trajectory Review\nDebt: 1 small item (guard token state). Path dependency: none; repository seam preserved.\nReversibility 5/5. Ecosystem fit: yes. 1-year clarity: add an ASCII comment of the\nschedule above next to the guard. Outcome: Reversibility 5/5, debt items 1.\n\n## Section 11: Design & UX Review\nSKIPPED (no UI scope).\n\n## Closing sequence\n**Outside Voice:** Codex review skipped (codex_reviews disabled). Re-enable:\n`gstack-config set codex_reviews enabled`. No native fallback (disabled means no extra\nstep). Disabled-coverage review-log write not persisted: gstack binaries are not installed\nin this fixture and mutating commands are disallowed.\n**TODO choices:** none proposed (HOLD SCOPE; no evidenced deferred gap). TODOS.md unchanged.\n**Approval readiness: PASS** — CEO-F1: D1 answered A (authorized auto-decision, scope as\nrecorded in the ledger). No other rows. Declined B/C kept out of accepted work.\n\n## NOT in scope\nDeferred: none. Rejected: D1-B (TTL-bounded staleness; violates retained contract),\nD1-C (repository-level fill coordination; changes an accepted fixture contract).\nOriginal out-of-scope list retained above.\n\n## What already exists\nRepository single-flight + typed errors + absent DTO (reused); LRU adapter with limits,\nsentinel and bypass (reused); rollout controller instance swap (reused); dashboards,\nalerts, runbooks (reused). Nothing rebuilt.\n\n## Dream state delta\nAfter this plan: hot reads served in-process at target latency; repository seam intact\nfor a future adapter swap. Remaining distance to the 12-month ideal: multi-process\ncoherence stays unsupported by design.\n\n## Error & Rescue Registry\nSee Section 2 table: 6 method/codepath rows, 3 exception classes, all rescued, 0 CRITICAL GAPS.\n\n## Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n -------------------------|-------------------------|----------|-------|-----------------|--------\n readProfile#cache.get | adapter failure | Y | Y | slower read | Y (fallback metric)\n readProfile#repo.read | store failure | Y | Y | typed error | Y (existing)\n readProfile#cache.set | stale fill after write | Y (D1) | Y (a,c)| fresh value | Y (suppressed counter)\n readProfile#cache.set | byte cap / eviction | Y | Y | miss next time | Y (eviction metric)\n writeProfile#repo.write | rejected / rolled back | Y | Y (d) | typed error | Y (existing)\n writeProfile#cache.delete| adapter failure | Y | Y (W1)| slower reads | Y (fallback metric)\n```\n6 total, 0 CRITICAL GAPS.\n\n## Diagrams\n1. System architecture: Section 1. 2. Data flow with shadow paths: Section 4.\n3. State machine (cache entry):\n```\n [absent] --fill(value)--> [cached v] --delete/TTL/evict--> [absent]\n [absent] --fill(absent DTO)--> [sentinel 10 s] --delete/TTL--> [absent]\n [any] --adapter failure--> [bypassed] --reinit--> [absent]\n invalid: fill after write completion -> prevented by D1 guard\n```\n4. Error flow: Section 2. 5. Deployment sequence: flag 10% -> healthy hour -> 50% ->\nhealthy hour -> 100% -> acceptance check (Section 9). 6. Rollback flowchart:\n```\n alert/breach --> disable flag --> controller drains writes --> bypass instance --> incident + runbook\n```\nStale diagram audit: no ASCII diagrams exist in touched files; 0 stale.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific\nfinding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1 day / CC: ~20min)** — profile-summary wrapper — Add the fill-after-write guard required by D1 (CEO-F1) with the \"fill suppressed\" dashboard branch\n - Surfaced by: Section 1 / Section 4 — CEO-F1 stale fill after a completed write\n - Files: to be determined (wrapper module)\n - Verify: deterministic schedules (a)-(e) pass on the existing repository-contract harness\n- [ ] **T2 (P1, human: ~1 day / CC: ~15min)** — wrapper tests — Implement schedules (a)-(e) plus W1 write-path adapter-failure check and TTL tests on a controlled clock\n - Surfaced by: Section 2 (W1), Section 4, Section 6\n - Files: to be determined (wrapper test file)\n - Verify: harness run green; each schedule fails when the guard is removed\n_No new tasks from Sections 3, 5, 7, 8, 9, 10, 11._\nTask JSONL: not persisted (gstack state root unavailable in this fixture).\n\n## Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run rules; fixture facts used |\n | Step 0 | HOLD SCOPE (explicit); D1 CEO-F1 -> A |\n | Section 1 (Arch) | 1 issue found (CEO-F1, remedied) |\n | Section 2 (Errors) | 6 error paths mapped, 0 GAPS |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 6 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 0 gaps found |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 1 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (2 rejected, 0 deferred) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 6 rows, 0 CRITICAL GAPS |\n | Failure modes | 6 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (HOLD SCOPE) |\n | CEO plan | skipped by mode |\n | Outside voice | codex: disabled (no fallback) |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, |\n | | rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 |\n +====================================================================+\n```\nReview Log / dashboard: not persisted (gstack-review-log and gstack-review-read are not\ninstalled in this fixture; mutating commands disallowed). Prior review history: unknown.\nNo durable learnings this session.\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps open (1 found, remedied by D1) |\n| Outside Review | codex (disabled) | Independent 2nd opinion | 0 | DISABLED | codex_reviews disabled; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | not run (no UI scope) |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |\n\n- **OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by config; no outside process started, no native fallback; 0 completed external reviews, no findings implied.\n- **VERDICT:** CEO CLEARED — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37195203538-t1-pass.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37195203538-t1-pass.json
new file mode 100644
index 000000000..cdc70b711
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37195203538-t1-pass.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "68761d79db01290b",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Genuine pass: the approved decision uses the slash-joined ROW-ID 'D1 / STALE-FILL', which the ledger reader parses since the lane D B2 change (before it, only hasStaleFillRaceFinding held).",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. These\nare the complete new read/write ordering rules; no additional version checks or\ncoordination between a cache fill and a write are proposed:\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n### Accepted amendment A1 (D1 / STALE-FILL, approved 2026-10-04 by author policy auto-decision, option A)\nRequired guarantee (retained contract, now enforced by the wrapper): a cache fill must not publish a snapshot that predates a writeProfile for the same key that committed after the read's cache miss. Remedy: readProfile captures a per-key in-process write marker at the miss; writeProfile advances that marker after repository.write resolves and before cache.delete; readProfile publishes `cache.set` only if the marker is unchanged, checking and setting in the same synchronous turn. Absent-result DTOs follow the same rule, so a creation that completes during an in-flight absent read is never cached as absent. A rejected write does not advance the marker (preserving failed-write preservation). Marker state belongs to the wrapper instance; the controller's new-instance publication starts it fresh, and old reads cannot fill the new instance (existing controller contract). Tradeoffs: one small bounded piece of wrapper state and one suppressed-fill branch; write latency, limits, TTL and the adapter are unchanged. The suppressed-fill branch is counted on the current dashboard with no key labels (existing telemetry; no new alert or metric project). Deterministic regression scenarios (existing repository-contract harness, controlled pause/release): (S1) read miss, write commits and completes, read resolves late, fresh read must observe committed version; (S2) read resolves before write completes, fill then delete, fresh read observes committed version; (S3) absent read in flight, record created and write completes, fresh read observes the record; (S4) rejected write during in-flight read, fill proceeds, fresh read observes the (unchanged) earlier snapshot; (S5) rejected read releases single-flight, no fill, error mapping preserved; (S6) two overlapping writes, each completing after the other's commit, fresh read observes the last committed version; (S7) instance change mid-read, old read cannot fill the new instance. Exact data structure, bounding strategy and test code are left to engineering planning.\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n---\n\n# CEO Review (plan-ceo-review, HOLD SCOPE) — working record\n\nRun: 2026-10-04. Author policy supplied by the run. Outside coverage: codex_reviews disabled in `.gstack-section-state-5DvWvu/config.yaml`; the extra outside-review step and its native fallback are skipped per that control. Preamble helpers, system audit, design-doc lookup, learnings, brain context, Aside/WebSearch research and telemetry are unavailable or skipped under the run rules; no dashboard or log result is claimed. Base branch: `main` (git status snapshot; no git commands run).\n\nReview depth: implementation-ready (default; the author requests a full scope and feasibility review with required guarantees and regression scenarios, while exact data structures and code stay with engineering). Storage: this file is the working plan and final output; checkpoints use scoped Edits.\n\n## Stated limits (recorded, unchanged)\n| Measure | Value | Prerequisite |\n|---|---|---|\n| Hot keys | ~900 (one-week trace) | fits adapter limits |\n| Adapter capacity | 1000 entries, 16 MiB, 30 s TTL; absent sentinel 10 s TTL | existing adapter |\n| Acceptance at 100% | hits >= 60%, DB CPU < 50%, read p95 < 60 ms | correctness/error SLOs unchanged, no alerts |\n| Stage health (10%, 50%) | hits >= 60% admitted, DB CPU/p95 no worse than 70%/120 ms | one healthy hour each |\n| Alerts | SLO breach; p95 > 120 ms for 5 min; bypass persisting 1 min | existing dashboards/runbooks |\n| Planned changed files | ~2 estimated (wrapper module, wrapper tests) | estimate; no file list in plan |\n\n## Decision ledger\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| MODE (author) | Run instruction: \"Hold the current scope (HOLD SCOPE mode)\" | HOLD SCOPE | none | approved | Explicit user choice in the run instruction; no question asked, no log. |\n| D1 / STALE-FILL (author; implementer: service team) | PLAN.md \"Existing contracts retained\" bullets 5–7 (cohort retirement does not inspect cache fills; already-started readers may finish with earlier snapshot; every read begun after a completed write must observe the committed version; TTL is not a substitute) vs \"Proposed wrapper integration\" (unconditional `cache.set` after `await repository.read`; \"no additional version checks or coordination between a cache fill and a write are proposed\") | Proposed ordering: read miss -> await store -> unconditional fill; write -> commit -> delete | A) write-aware fill guard; B) keep ordering, rely on TTL; C) hold write completion until in-flight reads finish | unresolved | pending |\n\n## 0A. Premise Challenge\nReal problem: ~900 hot profile-summary keys are re-read from the DB; DB CPU 70%, read p95 120 ms. Target: hits >= 60%, DB CPU < 50%, p95 < 60 ms with SLOs unchanged. Do-nothing cost: DB saturation headroom shrinks and p95 stays 2x the target. The plan solves the pain directly (serves repeated hot reads from process memory) rather than a proxy. Single process, no external writers, so a process-local cache is the right unit. Premise holds.\n\n## 0B. Existing Code Leverage\n| Sub-problem | Reused | Rebuild? |\n|---|---|---|\n| Bounded LRU, byte cap, TTL, absent sentinel, failure bypass | existing cache adapter | no |\n| Miss coalescing, cohort retirement on commit | existing per-key single-flight inside repository.read | no |\n| Atomic commit/rollback, typed error mapping | existing repository + contract tests | no |\n| Flag staging, instance swap, drain, predicate parity | existing rollout controller | no |\n| Metrics, alerts, runbooks | existing telemetry/dashboards | no |\n| New: read-through/write-invalidate ordering | new ~2-function wrapper | only new code |\nNo rebuild proposed; refactoring the existing read path is unnecessary because the wrapper sits above it.\n\n## 0C. Dream State Mapping\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n every hot read hits DB; ---> process-local LRU wrapper; ---> same repository interface,\n DB CPU 70%, p95 120 ms hits >= 60%, CPU < 50%, cache tier swappable (local now,\n single process, no cache p95 < 60 ms, same SLOs distributed later if multi-process\n is ever supported); no framework\n```\nThe plan moves toward the ideal: it keeps the repository interface as the seam and adds no general cache framework (plan: \"Out of scope\").\n\n## 0D. Approach choice at Step 0\nNo new approach decision was needed: the author approved the process-local LRU approach, limits, rollout and targets. Fixture contracts are accepted facts; an unavailable implementation is not evidence against them.\n\n## 0E. Mode\nMode: HOLD SCOPE; approved decisions: MODE (explicit run instruction). Application: preserve scope, trace failures, edge cases, error paths, tests and observability; repairs needed to meet the retained invariants are in scope (0G rule 3). Provenance: explicit user choice; mode handoff helper not run (unavailable under run rules). Route: 0G -> 0I.\n\n## 0G. HOLD SCOPE checks\n1. Complexity: ~2 changed files, 0 new classes/services. Under thresholds; no challenge.\n2. Minimum change: the two wrapper functions plus tests. Nothing deferrable without blocking the goal; no deferral question raised.\n3. Invariants: the read-after-completed-write rule is a retained requirement. The proposed ordering does not meet it (see D1); its repair is in scope.\n\n## 0I. Temporal Interrogation\n```\n HOUR 1 (foundations): adapter semantics (undefined = miss, absent DTO round-trip, bypass), cohort rule, controller swap\n HOUR 2-3 (core logic): AMBIGUITY -> stale fill after a completed write (D1); absent-sentinel fill after record creation (same race)\n HOUR 4-5 (integration): feature-flag instance swap, metric for any added branch with no key labels, failed-write preservation\n HOUR 6+ (polish/tests): both completion orders of overlapping read/write in the deterministic harness; eviction/byte/TTL tests\n```\nEffort: human ~2–3 days / CC+gstack ~1–2 h for wrapper + tests. Feasibility blocker: D1 (below). Other design choices stay pending with engineering.\n\n### D1 feasibility trace (evidence)\n```\n t0 readProfile(k): cache.get -> undefined (miss)\n t1 repository.read(k) starts, snapshot = v1 (cohort C1)\n t2 writeProfile(k): repository.write commits v2, retires C1, resolves\n t3 cache.delete(k) (nothing cached) ; writeProfile fulfills == WRITE COMPLETE\n t4 readProfile(k') begins (after write complete) -> must observe v2\n t5 t1's read resolves with v1 (already-started reader keeps its snapshot, allowed)\n t6 cache.set(k, v1) <-- stale fill, no guard\n t7 t4's cache.get -> v1 == CONTRACT VIOLATION (until 30 s TTL; TTL is not a substitute)\n```\nVariant: k absent at t1, created at t2; t6 stores the absent sentinel (10 s), so reads after creation report absent. Both orders of t3/t5 matter: if t5 precedes t3, the delete removes the fill and the contract holds; the harness must cover both.\n\n## currentDecision (D1 / STALE-FILL)\nCommitment comparison:\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead begun after completed write sees committed version | PLAN.md retained contract (approved) | violated by proposed ordering | restored | not restored (TTL-bounded staleness) | restored\nWrite completes after cache.delete, promptly | PLAN.md retained contract (approved) | yes | unchanged | unchanged | changed: completion waits on in-flight read cohorts\nOverlapping reads may return earlier snapshot | approved | yes | unchanged | unchanged | unchanged\nNo coordination between fill and write | proposed (unapproved wrapper text) | stated | replaced by write-aware fill guard | kept | replaced by write-side wait\nAdapter limits 1000 / 16 MiB / 30 s / 10 s sentinel | approved | yes | unchanged | unchanged | unchanged\nAdded observability | author: existing telemetry, current dashboard, no key labels | none | one suppressed-fill counter on current dashboard | none | one delayed-write counter\nRegression scenarios (both completion orders, creation, rejected ops, overlapping writes, instance isolation) | author acceptance requirement | required | required, deterministic | cannot pass read-after-write case | required; adds write-latency cases\n```\n\nQuestion: D1 — STALE-FILL: How should the wrapper stop a late cache fill from hiding a write that already completed?\nProject/branch/task: main; CEO review of the process-local profile-summary cache wrapper in PLAN.md.\nELI10: A read can start, fetch the old value from the database, and only then get around to putting it in the cache. If a write finished in between, the cache now holds the old value and every reader for up to 30 seconds sees stale data. The plan promises that never happens after a write completes, but the proposed code has no check to keep that promise.\nStakes if we pick wrong: users see a profile they just saved revert for up to 30 s (or a newly created profile report \"missing\" for 10 s), breaking the correctness SLO the rollout gates on.\nRecommendation: A because it restores the retained guarantee with an in-process, synchronous check and leaves write latency and limits untouched.\nCompleteness: A=10/10, B=3/10, C=7/10\nNet: A trades a small per-key in-flight marker for the guarantee; B silently weakens consistency; C keeps the guarantee by slowing writes.\nHeader: Stale fill fix\nA) Write-aware fill guard (recommended)\nAmend the plan: readProfile records a per-key marker at the miss, and writeProfile bumps that marker after commit and before cache.delete; the fill is published only if the marker is unchanged, in the same synchronous turn as cache.set. Effort S (human ~1 day / CC ~30 min), risk low, reuses the adapter and cohort rule, verification: deterministic harness covers both completion orders, creation, rejected ops, overlapping writes and instance isolation. ✅ Restores \"every read begun after a completed write observes the committed version\" without touching limits, TTL or write latency. ✅ The marker is in-process and synchronous, so no new coordination or network is involved and the controller's instance swap already isolates it. ❌ Adds one small piece of wrapper state and a suppressed-fill branch that engineering must bound and test.\nB) Keep the proposed ordering and rely on TTL\nLeave readProfile/writeProfile as written; staleness after a lost race is bounded by the 30 s TTL (10 s for absent). Effort S with zero implementation work, risk high, reuses everything, verification: the read-after-completed-write scenario cannot pass. ✅ No new wrapper state or branch to test. ✅ Smallest possible diff and simplest code. ❌ Contradicts the retained consistency rule and the author's statement that TTL is not a substitute; not authorizable under this review.\nC) Hold write completion until in-flight read cohorts finish\nwriteProfile waits, after commit, for any in-flight repository.read on that key to settle before cache.delete and fulfilment, so no stale fill can land after the write completes. Effort M (human ~2 days / CC ~1 h), risk medium, reuses the cohort rule only if the repository exposes cohort completion (unknown), verification: harness covers the same scenarios plus write-latency behavior. ✅ Restores the guarantee without a per-key marker in the wrapper. ✅ Keeps cache.set unconditional, so the read path is unchanged. ❌ Changes write-completion latency and couples writes to read storms, and depends on an unverified repository capability.\n\n## D1 decision record\nAuthority: run instruction (\"Authorize complete remedies and required verification that restore its retained contracts and acceptance targets; use the recommended option only among alternatives within that scope\") and author acceptance requirements in PLAN.md (\"For a demonstrated gap, amend the plan with the required guarantee, a feasible remedy, its tradeoffs and deterministic regression scenarios\"). AskUserQuestion unavailable (automated run); auto-decision authorized by that policy, not by a spawned-session echo. Answer: **A) Write-aware fill guard**. Exact scope: amendment A1 inserted before \"## Verification and rollout\" (guarantee, remedy, tradeoffs, scenarios S1–S7, suppressed-fill count on the current dashboard). Not authorized: B (weaker consistency), C (changed write-completion semantics), data-structure or test-code selection. Ledger row D1 status -> approved.\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| D1 / STALE-FILL (author; implementer: service team) | see ledger row above; trace in 0I | Proposed ordering + amendment A1 (fill guard) | B, C declined | approved | Author-policy auto-decision 2026-10-04 -> A; scope = A1 text only |\n\n## Review Sections (HOLD SCOPE, implementation-ready)\nCurrent scope: mode HOLD SCOPE (explicit run instruction). Governing rows: MODE approved (explicit); D1 approved (author-policy auto-decision -> A, scope A1). Accepted: original plan + A1. Deferred: none. Rejected: D1 options B and C. Pending: none.\n\n### Section 1: Architecture Review\n```\n caller --> rollout controller (flag predicate, instance swap)\n |-- disabled instance: readProfile/writeProfile bypass cache --> repository --> in-process store\n `-- enabled instance: readProfile ---> cache adapter (LRU/bytes/TTL/sentinel/bypass)\n | miss ^ guarded fill (A1)\n v |\n repository.read (single-flight cohort) --> store\n writeProfile --> repository.write (atomic) --> marker++ --> cache.delete\n```\nPaths: happy = miss, store read, guarded fill, later hits. Nil = absent DTO round-trips through sentinel; undefined only means miss. Empty = empty DTO is a value, cached as is. Error = repository error propagates with typed mapping, no fill, single-flight releases. State machine (wrapper instance): `fresh -> admitting -> draining -> retired`; invalid transition \"old read fills new instance\" is prevented by controller instance capture; \"fill after completed write\" was unprevented (CRITICAL GAP -> fixed by A1). Coupling: wrapper depends on adapter and repository only; no new coupling to the controller. Scaling: 10x hot keys (9000) exceeds the 1000-entry cap, hit rate degrades gracefully to eviction churn; 100x hits the byte cap first; both visible on eviction/bytes metrics. SPOF: single process already; cache failure degrades to bypass (DB load returns to baseline, alert at 1 min). Security: no new endpoint or mutation; auth precedes repository access. Production failure: adapter bypass during a stage -> DB CPU returns to 70%, alert pages, flag disabled. Rollback: flag off, controller swaps to a bypassing instance; seconds. **Outcome: 1 issue (D1, resolved by A1).** Decision gate: path 2, cites D1.\n\n### Section 2: Error & Rescue Map\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS\n -----------------------|--------------------------------------------|----------------------------\n readProfile | cache.get adapter failure | adapter bypass (no throw, contract)\n | repository.read rejects (DB failure) | typed repository API error\n | late fill after completed write | none (silent) -> A1 guard\n writeProfile | repository.write rejects (rolled back) | typed repository API error\n | cache.delete adapter failure | adapter bypass (no throw, contract)\n controller swap | admitted old write still pending | awaited by controller (contract)\n\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n ---------------------------|----------|------------------------------------|-------------------\n adapter failure | Y | bypass until reinit; alert at 1 min| transparent, slower\n typed repository error | Y (exist)| existing mapping; no fill; release | existing API error\n stale fill (pre-A1) | N <- GAP | A1: suppress fill, count branch | was: stale data\n rejected write | Y | marker unchanged, cache untouched | existing API error\n```\nNo catch-alls proposed. **Outcome: 6 paths mapped, 1 GAP (D1, resolved).** Decision gate: path 2.\n\n### Section 3: Security & Threat Model\nNo new endpoint, input, dependency, secret or background job. Keys encode tenant and profile IDs (contract); cross-tenant hit requires a key collision, prevented by unambiguous encoding: likelihood Low, impact High, mitigated. Cached DTOs are immutable, so a caller cannot mutate a shared cached object: mitigated. Keys and values are never logged; suppressed-fill count carries no key labels: mitigated. Memory exhaustion via many distinct keys is bounded by the 1000-entry/16 MiB caps: Low/Med, mitigated. **Outcome: No issues found.** Decision gate: nothing to resolve.\n\n### Section 4: Data Flow & Interaction Edge Cases\n```\n INPUT key -> (validated upstream, contract) -> cache.get -> miss -> repository.read -> A1 guard -> cache.set -> OUTPUT\n shadow: nil/absent -> absent DTO -> sentinel 10 s -> same DTO; invalid key -> rejected upstream; DB timeout -> typed error, no fill;\n conflict -> single-flight coalesces; stale -> A1 guard suppresses; oversize value -> byte cap evicts/declines (adapter)\n```\nAsync ordering: invariant = every readProfile begun after writeProfile fulfils observes the committed version. Schedule (shared state: cache entry, marker):\n```\n readProfile R1 | writeProfile W | fresh readProfile R2 | cache[k] | marker\n get -> miss, m0 | | | empty | m0\n await read (v1) | await write -> v2 | | empty | m0\n | marker++ ; delete | | empty | m1\n | fulfil (WRITE DONE) | begins | empty | m1\n resolves v1; m0!=m1 | | get -> miss -> read | empty | m1\n SUPPRESS fill | | -> v2, fill | v2 | m1\n Other order: R1 resolves before W fulfils -> fill v1 -> W deletes -> R2 misses -> v2. Mechanism: delete after commit.\n```\nPre-A1 the first order violated the invariant (0I trace). Regression proof: S1–S7 in A1 with controlled pause/release. Interaction edge cases: no user-visible interaction changes; double writes = S6; retries = idempotent reads. **Outcome: 7 edge cases mapped, 0 unhandled after A1.** Decision gate: path 2.\n\n### Section 5: Code Quality Review\nTwo small functions plus a per-key marker fit the existing repository-wrapper pattern. No duplicated behavior: invalidation and marker logic exist nowhere else. Naming: engineering to name the marker for what it guarantees (write-aware fill guard), not its mechanism. Branching stays under 5 per function. Under-engineering was the stale fill (fixed). No over-engineering: no general cache framework. **Outcome: No issues found.** Decision gate: nothing to resolve.\n\n### Section 6: Test Review\n```\n NEW THING | TEST TYPE | HAPPY | FAILURE | EDGE\n readProfile hit/miss | unit | miss then hit | adapter bypass -> store read | absent sentinel round-trip\n guarded fill (A1) | integration | S2 fill then delete | S1 suppressed fill, counted | S3 absent + creation, S7 instance\n writeProfile invalidate | integration | S2 | S4 rejected write, cache kept | S6 overlapping writes\n rejected read | unit | n/a | S5 typed error, no fill | single-flight released\n eviction/bytes/TTL | unit | 1000 entries, 16 MiB, 30 s | oversize value | 10 s sentinel expiry\n controller transitions | existing | enable/disable/percent | old-writer/new-reader order | predicate parity\n```\nAssertion check: S1 asserts R2 returns the committed version (rejects v1); S3 asserts the record (rejects absent); S4 asserts the earlier snapshot (rejects a spurious invalidation effect); hit-rate math asserts hits/(hits+misses) over admitted requests only. All map to retained contracts or A1; no new verification choice remains open. 2am test: S1 under the harness. Hostile QA: S3 and S6 together. Chaos: adapter failure mid-stage forces bypass and the 1-minute alert. Pyramid: mostly unit/integration, no E2E needed. Flakiness: all scenarios use controlled pause/release, no timers; TTL tests use the adapter's clock injection if present (engineering to confirm). Load: existing stage gates cover p95/CPU. **Outcome: diagram produced, 0 gaps after A1.** Decision gate: path 2.\n\n### Section 7: Performance Review\nNo N+1 or new queries. Memory: cache <= 16 MiB; marker map must be bounded to in-flight keys (requirement recorded in A1; structure left to engineering). Slow paths: miss path = store read + synchronous set; hit path = O(1) get; write path adds one synchronous marker update. Connection pools unchanged. Cold start: within existing DB capacity (plan). **Outcome: No issues found.**\n\n### Section 8: Observability & Debuggability Review\nExisting metrics: hit/miss, eviction, bytes, fallback errors, DB CPU, p95; existing alerts and runbooks. A1 adds one suppressed-fill count on the current dashboard, no key labels, no new alert (author constraint). A 3-weeks-later stale-read report is reconstructable from suppressed-fill, bypass and flag-stage history. Runbook: bypass persisting 1 min -> page, disable flag. **Outcome: 1 gap (suppressed-fill visibility) resolved within A1.**\n\n### Section 9: Deployment & Rollout Review\nNo migration. Flag staged 10% -> 50% -> 100% with one healthy hour each; controller drains writes and swaps instances. Rollback: disable flag -> bypassing instance; runbook records incident. Deploy-time risk window: controller contract isolates instances. Post-deploy checks: first 5 min hit rate, bypass errors, suppressed-fill count nonzero but small; first hour stage criteria. Smoke: existing contract tests plus S1–S7 in CI. **Outcome: 0 new risks flagged.**\n\n### Section 10: Long-Term Trajectory Review\nDebt: one marker to maintain; documented in A1. Path dependency: none, repository seam preserved. Knowledge: A1 plus the 0I trace explain the why. Reversibility 5/5 (flag off, delete wrapper). Ecosystem fit: wraps existing adapter. 1-year: obvious. **Outcome: debt items 1, reversibility 5/5.**\n\n### Section 11: Design & UX Review\nSKIPPED (no UI scope).\n\n## Closing sequence\n1. Outside Voice: codex_reviews disabled (config read above); skipped with no native fallback, as directed. Review-log helper unavailable under run rules; disabled coverage **not persisted**. Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`.\n2. TODO choices: none remain (no deferred gap; HOLD SCOPE).\n3. Approval readiness: PASS. Rows checked: MODE (explicit run instruction), D1 (author-policy auto-decision -> A, scope A1). No pending, declined or deferred remedies in accepted work.\n\n## NOT in scope\nDeferred: none. Rejected: D1-B keep ordering/rely on TTL (contradicts retained consistency rule); D1-C hold write completion (changes write semantics, unverified repository capability). Original out-of-scope list unchanged.\n\n## What already exists\nCache adapter, single-flight cohort rule, atomic repository, rollout controller, telemetry/alerts/runbooks, repository contract tests: all reused (0B).\n\n## Dream state delta\nAfter this plan: hot reads served in-process with the read-after-write rule intact; repository seam ready for a later cache-tier swap; no framework added. See 0C.\n\n## Error & Rescue Registry\nSee Section 2 tables: 6 rows, 1 CRITICAL GAP (stale fill) resolved by A1; 0 open.\n\n## Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n -----------------------|------------------------------|----------|-----------|-------------------|--------\n readProfile | adapter failure | Y | Y | slower, correct | Y (bypass)\n readProfile | repository.read rejects | Y | Y (S5) | API error | Y (existing)\n readProfile fill | stale fill after write | Y (A1) | Y (S1,S3) | correct | Y (count)\n writeProfile | repository.write rejects | Y | Y (S4) | API error | Y (existing)\n writeProfile | cache.delete adapter failure | Y | Y | slower, correct | Y (bypass)\n controller swap | pending old write | Y | Y (exist) | none | Y (existing)\n```\n6 total, 0 CRITICAL GAPS open.\n\n## Diagrams\n1. System architecture: Section 1. 2. Data flow with shadow paths: Section 4. 3. State machine: Section 1 (wrapper instance). 4. Error flow: Section 2. 5. Deployment sequence: `deploy wrapper (flag off) -> enable 10% -> healthy hour -> 50% -> healthy hour -> 100% -> acceptance check`. 6. Rollback: `breach or alert -> disable flag -> controller drains writes, swaps to bypass instance -> runbook records incident -> resume criteria`.\nStale Diagram Audit: no ASCII diagrams exist in touched files (plan only); none stale.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1 day / CC: ~30 min)** — profile-summary wrapper — Implement readProfile/writeProfile with the write-aware fill guard (A1)\n - Surfaced by: Section 4 / 0I — stale fill after completed write (D1)\n - Files: to be determined (wrapper module)\n - Verify: harness scenarios S1–S7 pass; existing repository contract tests pass\n- [ ] **T2 (P1, human: ~1 day / CC: ~30 min)** — wrapper tests — Add deterministic tests for hit/miss, eviction, bytes, TTL, bypass, invalidation, failed-write preservation, coalescing and S1–S7\n - Surfaced by: Section 6 — test diagram\n - Files: to be determined (wrapper test module)\n - Verify: tests run under the existing deterministic harness with controlled pause/release\n- [ ] **T3 (P2, human: ~2 h / CC: ~10 min)** — telemetry — Emit the suppressed-fill count on the current dashboard with no key labels\n - Surfaced by: Section 8 — added branch visibility\n - Files: to be determined\n - Verify: panel shows the count during S1 in staging; no key labels present\n_No new tasks from Sections 3, 5, 7, 9, 10, 11._\nTask JSONL: **not persisted** (gstack helpers unavailable under run rules).\n\n## Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped by run rules; plan-only review |\n | Step 0 | HOLD SCOPE; D1 -> A (fill guard, A1) |\n | Section 1 (Arch) | 1 issues found (D1, resolved) |\n | Section 2 (Errors) | 6 error paths mapped, 1 GAPS (resolved) |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 7 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 0 issues found |\n | Section 8 (Observ) | 1 gaps found (resolved in A1) |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 1 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (2 items rejected, 0 deferred) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 6 rows, 0 CRITICAL GAPS open |\n | Failure modes | 6 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (HOLD SCOPE) |\n | CEO plan | skipped by mode |\n | Outside voice | codex: disabled |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 |\n +====================================================================+\n```\nUnresolved Decisions: none. Learnings: no durable learnings this session. Review log, decision log and dashboard: **not persisted** (helpers unavailable; no dashboard result claimed).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps |\n| Outside Review | codex (`codex_reviews: disabled`) | Independent 2nd opinion | 0 | DISABLED | disabled by config; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | not run (no UI scope) |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |\n\n**OUTSIDE COVERAGE:** provider codex, phase plan-review, disabled by `codex_reviews: disabled`; no outside process or native fallback ran; no completed external review; disabled record not persisted.\n\n**VERDICT:** CEO CLEARED — plan approved with amendment A1 (D1 -> A); eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/37198445662-t1-pass.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/37198445662-t1-pass.json
new file mode 100644
index 000000000..787a475fa
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/37198445662-t1-pass.json
@@ -0,0 +1,20 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-ceo-section-loading",
+ "detector": "test/helpers/ceo-stale-fill-decision.ts#hasApprovedStaleFillDecision || test/helpers/ceo-section-loading-fixture.ts#hasStaleFillRaceFinding",
+ "series_identity": "68761d79db01290b",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "periodic",
+ "artifact": "paid-slice-6-a1",
+ "path": "shards/skill-e2e-plan-ceo-review-section-loading/_partial-e2e.json#/tests/0/output"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Genuine pass at the latest census SHA: approved stale-fill ledger decision and prose finding both hold.",
+ "input": {
+ "report": "# Plan: cache profile summaries in one process\n\n## Measured problem and accepted scope\nThe existing profile-summary service has one active process. A one-week trace\nshows repeated reads of about 900 hot keys: DB CPU is 70%, with read p95 120 ms.\nAdd a process-local LRU wrapper to the existing repository. Acceptance targets\nare at least 60% cache hits, DB CPU below 50%, and read p95 below 60 ms, with the\nexisting error-rate and correctness SLOs unchanged. This is an internal backend\nchange with no UI, API, schema, pricing, or developer onboarding change.\n\n## Existing contracts retained\n- All reads and writes use this repository in the same process; there are no\n external DB writers. Multi-process operation remains unsupported and startup\n rejects that configuration while caching is enabled.\n- These surrounding contracts are accepted fixture facts, supplied by the\n existing repository, cache adapter and rollout controller. Preserve them;\n review the new wrapper ordering below against them.\n- Authentication and authorization run before repository access. Keys encode\n the authenticated tenant ID and validated profile ID without ambiguity.\n Values are immutable profile-summary DTOs; secrets and cache keys are never\n logged. Cached results cannot bypass authorization.\n- The existing LRU adapter supports 1000 entries, a 16 MiB byte cap, and a\n 30-second TTL. Recorded hot data fits those limits. repository.read returns\n an immutable absent-result DTO for a missing record, never undefined. The\n adapter recognizes that DTO in cache.set, stores an internal sentinel with a\n 10-second TTL, and cache.get decodes it back to the same absent-result DTO.\n The internal sentinel cannot escape the adapter; undefined means a cache miss.\n- Cache operations are synchronous and atomic in the single JS event loop.\n On any cache failure the existing adapter bypasses the cache until an empty\n cache is reinitialized; repository errors keep the current typed API error\n mapping. The existing per-key single-flight wrapper sits inside\n repository.read, coalesces simultaneous store reads and releases on failure.\n A committed repository.write retires that key's old read cohort before its\n promise resolves. A later repository.read starts a fresh cohort; a rejected\n write leaves the cohort unchanged. Already-started readers may finish with\n their earlier snapshot. This admission rule does not inspect cache fills.\n- The repository uses an in-process transactional store, with no network\n transport between this wrapper and the store. repository.write is atomic:\n a resolved promise means committed, and every\n rejected promise guarantees no commit; its transaction rolled back before\n rejection. Existing contract tests exercise that guarantee.\n- Consistency is measured at the public wrapper boundary. A write completes\n when writeProfile's promise fulfills after cache.delete, not when\n repository.write commits or resolves. A read begins when readProfile is\n invoked. Reads that overlap an unfinished writeProfile may return an earlier\n snapshot, including reads begun after the store commit but before the wrapper\n promise fulfills. Every read begun after that write completes must\n observe the committed version. TTL expiry is not a substitute for this rule.\n\n## Proposed wrapper integration\nKeep the current read-through repository interface and shared adapters. The\nsketch below shows the baseline ordering as originally proposed. Per CEO review\ndecision D1 (ledger R1), it is amended by the **fill-admission guarantee**: a read's\ncache fill is admitted only if no repository.write for that key has committed since\nthat read began; writeProfile advances a per-key in-process committed-write marker\nafter repository.write resolves and before cache.delete. A suppressed fill still\nreturns the value to the caller and increments a suppressed-fill counter on the\nexisting dashboard with no key labels. The marker's exact data structure and the\nfunction bodies are engineering-planning work, not selected here.\n\n```javascript\nasync function readProfile(key) {\n const cached = cache.get(key);\n if (cached !== undefined) return cached;\n const value = await repository.read(key);\n cache.set(key, value);\n return value;\n}\n\nasync function writeProfile(key, update) {\n const saved = await repository.write(key, update);\n cache.delete(key);\n return saved;\n}\n```\n\n## Verification and rollout\nExisting repository contract tests cover tenant isolation, key validation,\nabsence, DB failures, authorization, and startup rejection of multi-process\noperation while caching is enabled. New wrapper tests cover hit/miss,\neviction and byte limits, TTL, adapter-failure fallback, successful-write\ninvalidation, failed-write preservation, and concurrent-miss coalescing.\nPer D1 (R1) they also cover, with controlled pause/release points in the existing\ndeterministic harness: (a) read starts, write commits and completes, read resolves\n→ fill suppressed, next read observes committed version; (b) read resolves before\nwrite completes → fill admitted, then deleted by the write; (c) missing-record read\nin flight while the record is created → absent sentinel not cached after creation;\n(d) two overlapping writes with an in-flight read → no stale fill; (e) rejected read\n→ no fill, marker unchanged; rejected write → cache preserved, marker unchanged;\n(f) controller instance change during an in-flight read → old read cannot fill the\nnew instance.\nThe rollout uses the existing runtime feature flag: enable for 10% of keys,\nthen 50%, then all keys after one healthy hour at each stage. Monitor hit/miss,\neviction, cache bytes, fallback errors, DB CPU, and read p95 without raw IDs.\nThe existing controller uses one shared key-selection predicate for reads and\nwrites. On any enable, disable or percentage change, it stops admitting work,\nawaits every admitted old-instance write, then publishes a new wrapper/cache\ninstance with a fresh single-flight cohort before admitting new work. Old reads\nretain their old instance and cannot fill the new one. Disabled instances\nbypass the cache on both paths. Tests cover the old-writer/new-reader ordering,\nall those transitions and predicate parity. This lifecycle isolation does not coordinate\nan ordinary DB write with a cache fill in the same active instance.\nExisting dashboards and runbooks cover these metrics. Before each stage, verify\nthat alerts page the service owner on any correctness/error-SLO breach, read\np95 above 120 ms for five minutes, or cache bypass persisting for one minute.\nHit rate is hits / (hits + misses) among requests admitted to the cache path;\nflag-excluded or adapter-bypassed requests are tracked separately, not as misses.\nDB CPU and read p95 are service-wide metrics, including bypassed requests.\nAt the 10% and 50% stages, a healthy hour requires at least 60% admitted-request\nhits, unchanged correctness/error SLOs, no alerts, and aggregate DB CPU/read p95\nno worse than their 70%/120 ms pre-rollout baselines. At 100%, the original\nabsolute acceptance targets (DB CPU below 50%, read p95 below 60 ms, hits at\nleast 60%) must all hold with unchanged correctness/error SLOs and no alerts.\nAny breach disables the flag immediately; the runbook records the incident,\nrollback and criteria for resuming. These are existing\nrollout-controller and telemetry contracts, not proposed wrapper additions.\nCold starts remain within the existing DB capacity. The service owner monitors\nthe rollout and records the results against the acceptance targets.\n\n## Out of scope\nDistributed caching, cross-process coherence, prewarming, changing consistency\nsemantics, or adding new product surfaces. The repository interface preserves a\nfuture replacement path without introducing a general cache framework now.\n\n## Author's review and acceptance requirements\nThis is a full CEO scope and feasibility review. The author has approved the\nretained contracts, limits, rollout and acceptance targets above. Evaluate the\nproposed wrapper against them; the wrapper itself remains unapproved. An actual\ncontradiction or missing proof must be reported and resolved, not assumed away.\nFor a demonstrated gap, amend the plan with the required guarantee, a feasible\nremedy, its tradeoffs and deterministic regression scenarios. Those repairs\nand their required verification are within the requested scope. The exact data\nstructures, full function bodies and executable test code belong to subsequent\nengineering planning; do not select or implement them during this review when\nthe required behavior and feasibility can already be established.\n\nUse the existing deterministic repository-contract test harness. Required wrapper\nacceptance includes both completion orders of overlapping reads and writes,\nmissing-record creation, rejected reads/writes, overlapping writes, and isolation\nacross controller instance changes. Use existing telemetry to record any added\nbranch on the current dashboard with no key labels; no new alert threshold or metric\nproject is requested. These are future acceptance requirements, not tests already\nimplemented or passing. Preserve all 11 review outcomes, required registries,\ndiagrams, tasks, completion summary and the full GSTACK REVIEW REPORT.\n\n## CEO Review — Step 0 (HOLD SCOPE)\n\nReview depth: implementation-ready for behavior, interfaces and verification; exact data\nstructures, function bodies and test code stay with engineering per the author's policy.\nStorage: this file is the single working plan and final output. Preamble/system audit,\ntelemetry, Aside/WebSearch, brain context, prior learnings: skipped per run rules (fixture).\nOutside coverage (codex_reviews): disabled by `.gstack-section-state-IfADEH/config.yaml`.\nSession transport: automated run, author policy authorizes auto-selection of the recommended\noption within retained scope; no AskUserQuestion available.\n\n### 0A. Premise Challenge\nReal problem: ~900 hot keys re-read from the DB; DB CPU 70%, read p95 120 ms. Target: hits\n>= 60%, DB CPU < 50%, p95 < 60 ms, SLOs unchanged. Do-nothing cost: DB saturation headroom\nkeeps shrinking and p95 stays above the target. A process-local read-through cache attacks\nthe pain directly (repeated identical reads), not a proxy. Premise holds under HOLD SCOPE.\n\n### 0B. Existing Code Leverage\n| Sub-problem | Reused | Notes |\n|---|---|---|\n| Bounded storage, TTL, absent sentinel | existing LRU adapter | fixture fact, retained |\n| Concurrent-miss coalescing | single-flight inside repository.read | fixture fact, retained |\n| Rollout, instance swap, predicate parity | existing rollout controller | fixture fact, retained |\n| Dashboards, alerts, runbooks | existing telemetry | retained; new branch recorded on current dashboard |\n| New code | readProfile / writeProfile ordering | the only unapproved piece |\nNo rebuild proposed; nothing to justify.\n\n### 0C. Dream State Mapping\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n single process, no cache, process-local LRU wrapper same repository interface,\n DB CPU 70%, p95 120 ms ---> behind existing repo, ---> replaceable cache backend if\n 900 hot keys re-read 60% hits, CPU<50%, p95<60 multi-process ever arrives\n```\nMoves toward the ideal: keeps the repository interface as the replacement seam (plan \"Out\nof scope\" paragraph) without a cache framework now.\n\n### Decision ledger\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| R1 (author / wrapper) | \"Every read begun after that write completes must observe the committed version. TTL expiry is not a substitute\" (PLAN.md, Existing contracts retained, last bullet). Proposed ordering: readProfile fills cache after `await repository.read` with no fill/write coordination (PLAN.md, Proposed wrapper integration). Single-flight: \"Already-started readers may finish with their earlier snapshot. This admission rule does not inspect cache fills.\" | Consistency rule retained; wrapper ordering unapproved; no fill/write coordination proposed | A) amend wrapper with a fill-admission guarantee; B) keep ordering, weaken rule to TTL-bounded staleness; C) drop the write path from the cache (bypass + delete only, still fills on read) | approved | D1 → A. Authority: run rules (author policy: auto-select the recommended option among in-scope alternatives; weaker consistency and changed limits not authorizable, so B excluded; C does not meet the retained rule). Scope: amend the wrapper with the fill-admission guarantee, writeProfile per-key committed-write marker advanced before cache.delete, suppressed-fill count on the existing dashboard with no key labels, and the deterministic regression scenarios listed in the comparison. No data structure or test code selected. |\n\n### Demonstrated gap behind R1 (evidence trace)\n```\n t0 R1: readProfile(k) -> cache.get miss -> repository.read(k) starts, snapshot v1\n t1 W: writeProfile(k) -> repository.write commits v2, retires R1's cohort, resolves\n t2 W: cache.delete(k) (nothing cached) -> writeProfile fulfills == WRITE COMPLETE\n t3 R2: readProfile(k) begins (after write complete) -> cache.get miss -> read v2 ok\n t4 R1: repository.read resolves v1 (allowed by single-flight) -> cache.set(k, v1)\n t5 R3: readProfile(k) begins -> cache.get HIT v1 (stale) for up to 30 s TTL\n```\nR3 began after the write completed and observes v1: a direct violation of the retained\nrule. The same trace with a missing record caches the absent sentinel for 10 s after the\nrecord was created. Retained contracts do not prevent this: the single-flight rule says\nit \"does not inspect cache fills\", and the controller note says lifecycle isolation \"does\nnot coordinate an ordinary DB write with a cache fill in the same active instance\". This\nis a contradiction inside the proposal, not an unavailable implementation.\n\n## currentDecision (R1)\nCommitment comparison:\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nRead-after-write-complete observes committed version | PLAN.md retained contract, approved | required | kept | weakened to TTL-bounded | kept but unmet (stale fill still possible)\nTTL is not a consistency substitute | PLAN.md retained contract, approved | required | kept | violated | kept\nAdapter limits 1000 / 16 MiB / 30 s / 10 s absent | PLAN.md retained, approved | fixed | unchanged | unchanged | unchanged\nSingle-flight cohort rule (no fill inspection) | PLAN.md retained, approved | fixed | unchanged; wrapper adds its own fill check | unchanged | unchanged\nreadProfile fill after repository.read | PLAN.md proposed, pending | proposed | fill admitted only if no committed write for key since read began; otherwise skip fill, return value | unchanged | unchanged\nwriteProfile: await write, cache.delete, return | PLAN.md proposed, pending | proposed | unchanged, plus per-key committed-write marker advanced before cache.delete | unchanged | write path also bypasses cache reads (no gain)\nTelemetry for added branch | author requirement, approved | n/a | stale-fill-suppressed count on current dashboard, no key labels | none | none\nDeterministic regression scenarios | author requirement, approved | required | both completion orders, missing-record creation, overlapping writes, rejected read/write, instance change | not satisfiable | not satisfiable\nAcceptance targets 60% / <50% CPU / <60 ms | PLAN.md, approved | fixed | unchanged | unchanged | unchanged\n```\n\nQuestion: D1 — R1: Fix the stale cache fill that breaks read-after-write consistency?\nProject/branch/task: main, PLAN.md profile-summary cache wrapper, HOLD SCOPE review.\nELI10: A read can start, a write can finish and clear the cache, then the slow old read\nlands and stores the old value. Everyone then sees the old profile for up to 30 seconds,\nwhich the plan explicitly promises cannot happen. Stakes if we pick wrong: correctness SLO\nbreach that the rollout alert may not catch, or a quietly weakened promise.\nRecommendation: A because it is the only option that keeps the retained consistency rule\nand is feasible in one event loop with synchronous cache operations (small diff, low\nmaintenance: one per-key marker checked at fill time).\nCompleteness: A=10/10, B=3/10, C=3/10\nNet: trade a small fill-time check and one counter for keeping the promise the plan already makes.\nHeader: Stale fill fix\nA) Add fill-admission guarantee (recommended)\nAmend the wrapper: a fill is admitted only if no repository.write for that key committed since the read began; writeProfile advances that per-key marker before cache.delete. Effort S, risk low, reuses adapter and single-flight unchanged, verification covers both completion orders, missing-record creation, overlapping writes, rejected read/write and instance change. ✅ Keeps \"every read after write completes sees the committed version\" with no TTL reliance. ✅ Deterministic in the single JS event loop; sync cache ops make the check atomic. ❌ One more per-key in-process state and a new suppressed-fill telemetry branch to test.\nB) Keep ordering, allow TTL-bounded staleness\nLeave readProfile/writeProfile as proposed and rewrite the consistency contract to allow up to 30 s (10 s absent) staleness after a completed write. Effort S, risk high, no reuse change, verification cannot prove read-after-write. ✅ Zero wrapper changes beyond the proposed code. ✅ Simplest code path to reason about for hit-rate measurement. ❌ Changes a retained consistency requirement, which the author has not authorized.\nC) Keep ordering, bypass cache on the write path only\nwriteProfile skips the cache entirely except for the delete; reads unchanged. Effort S, risk high, no reuse change, verification still fails the stale-fill scenario. ✅ No change to the read path the rollout measures. ✅ Preserves the stated adapter limits and TTLs. ❌ Does not address the race; the stale fill comes from the read path, so the retained rule stays violated.\n\n### 0E. Mode\nMode: HOLD SCOPE; explicit user instruction (\"Hold the current scope (HOLD SCOPE mode)\").\nNo mode question asked or logged; mode handoff script unavailable in this fixture (skipped\nper run rules). Approved decisions: R1 (D1 → A, scope above). No other approach decision\nwas needed: the current plan is the only candidate approach and HOLD SCOPE forbids expansion.\n\n### 0G. HOLD SCOPE checks\n1. Complexity: estimated 2–3 changed files (wrapper module, wrapper tests, dashboard\n panel config), 0 new classes/services → under the 8-file / 2-class threshold. OK.\n2. Minimum change: the two wrapper functions plus the D1 guard. Nothing deferrable\n without blocking the acceptance targets; no deferral proposed, no defer/keep question.\n3. Invariants kept: all retained contracts, limits (1000 / 16 MiB / 30 s / 10 s), targets\n (60% / <50% / <60 ms) unchanged. D1 is the one repair needed to meet them.\n\n### 0I. Temporal Interrogation\n```\nHOUR 1 foundations: adapter API (get/set/delete, absent sentinel), single-flight cohort\n rule, controller instance lifecycle, telemetry counter API. All fixture facts.\nHOUR 2-3 core logic: fill-admission guard (D1). Ambiguity: where the per-key marker lives\n and how it is bounded (engineering; see Section 7 requirement). Marker advances\n after repository.write resolves and before cache.delete, synchronously.\nHOUR 4-5 integration: controller swap mid-read (old read must not fill new instance,\n already a controller contract); adapter failure mid-write (bypass until reinit).\nHOUR 6+ tests: six D1 scenarios with pause/release points; TTL tests on the harness's\n controlled clock, never wall-clock sleeps.\n```\nEffort: human ~2 days / CC+gstack ~40 min. Feasibility blockers: none after D1.\nPending (engineering, non-blocking): marker data structure and bounding strategy.\n\n## Review Sections (HOLD SCOPE, implementation-ready)\n\nCurrent scope: mode HOLD SCOPE (explicit instruction). Governing rows: R1 approved\n(D1 → A, authority and scope in the ledger). Accepted: plan as written plus the D1\namendment. Deferred: none. Rejected: D1 options B and C. Pending: none blocking.\n\n### Section 1: Architecture Review\n```\n caller ──auth/authz──▶ readProfile/writeProfile (wrapper, NEW)\n │ │\n cache.get/set/delete await repository.read/write\n ▼ ▼\n existing LRU adapter existing repository (+ single-flight)\n (1000 / 16 MiB / 30 s) │\n in-process transactional store\n rollout controller ──publishes──▶ wrapper+cache instance (fresh cohort per change)\n```\n- Boundaries: wrapper is the only new component; it depends on adapter and repository\n only. New coupling: wrapper↔adapter sentinel semantics (already adapter-owned). Justified.\n- Data flow paths: happy (miss→read→fill→return), nil (absent DTO → sentinel 10 s),\n empty (no empty-value shape exists; DTO is always a full object), error (read rejects →\n typed error, no fill). Diagrammed in Section 4.\n- State machine (per key, per instance): `empty → filled(v) | absent-sentinel` via\n admitted fill; `any → empty` via cache.delete, TTL expiry, eviction, adapter bypass\n reinit. Invalid transition prevented by D1: `empty → filled(stale)` after a completed\n write; the guard blocks it. Section 4 shows the schedule.\n- Scaling: 10x load → hit rate rises, DB load falls; 100x → event-loop CPU on cache ops\n and the 16 MiB cap drive evictions; hit rate falls toward baseline but correctness holds.\n- SPOF: the single process (pre-existing). Security: no new surface (Section 3).\n- Production failure: adapter throws mid-set → bypass until reinit, DB absorbs load\n (cold-start capacity stated). Rollback: disable flag, instant, controller swaps instance.\n**Finding F1 — CRITICAL GAP (resolved):** stale fill after completed write; evidence\ntrace and remedy under ledger R1 / D1 → A. Residual: marker bounding (Section 7).\nDecision gate: F1 resolved by D1 → A; applied to plan. No other findings.\n\n### Section 2: Error & Rescue Map\n```\n METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION CLASS\n -------------------------|------------------------------------------|---------------------\n readProfile cache.get | adapter failure | AdapterError (internal)\n readProfile repo.read | store error / timeout / not found | existing typed API errors; absent DTO (not an error)\n readProfile cache.set | adapter failure; byte cap exceeded | AdapterError (internal)\n readProfile fill guard | marker says write committed since start | none (branch, not error)\n writeProfile repo.write | validation / conflict / store error | existing typed API errors (rolled back)\n writeProfile cache.delete| adapter failure | AdapterError (internal)\n startup | multi-process config with caching on | StartupConfigError (existing)\n -------------------------|------------------------------------------|---------------------\n EXCEPTION CLASS | RESCUED? | RESCUE ACTION | USER SEES\n -------------------------|----------|----------------------------------------|----------------\n AdapterError (get/set) | Y adapter| bypass cache until empty reinit; count | normal response, slower\n AdapterError (delete) | Y adapter| bypass until reinit (cache emptied) | write succeeds\n typed read error | Y repo | existing mapping; no fill; cohort freed| existing error\n typed write error | Y repo | rollback; no delete; marker unchanged | existing error\n fill-guard branch | n/a | skip fill, return value, count suppressed | fresh value\n StartupConfigError | Y | refuse to start | deploy fails loudly\n```\nNo catch-alls introduced; wrapper adds no try/catch of its own. No GAPs. Decision gate: no issues found beyond F1 (already resolved).\n\n### Section 3: Security & Threat Model\nNo new endpoint, input, job or dependency. Keys carry authenticated tenant + validated\nprofile ID (fixture); cross-tenant hit impossible without key collision, which the key\ncontract excludes. Values immutable DTOs → no shared-mutation leak. Secrets/keys never\nlogged; the D1 counter carries no key labels. Threats: cache poisoning via stale fill\n(Low likelihood, Med impact) mitigated by D1; memory exhaustion via many keys (Low/Low)\nmitigated by 1000 / 16 MiB caps. Decision gate: no issues found.\n\n### Section 4: Data Flow & Interaction Edge Cases\n```\nINPUT(key) -> VALIDATION(upstream auth/authz, key encoding) -> cache.get\n -> hit: OUTPUT(dto) -> miss: repository.read -> fill guard -> cache.set -> OUTPUT\nshadow: nil record -> absent DTO -> sentinel 10 s wrong type -> rejected upstream (existing)\n read rejects -> typed error, no fill adapter throws -> bypass, OUTPUT from repo\n oversize value -> adapter refuses set, OUTPUT ok stale (write raced) -> guard suppresses fill\n```\nAsync ordering, invariant: \"every read begun after writeProfile fulfills observes the\ncommitted version\" (boundary: readProfile invocation vs. writeProfile fulfilment).\n```\n readProfile R1 | writeProfile W | cache(k) | marker(k)\n get miss; read starts | | empty | g0\n | write commits v2 | empty | g0\n | marker -> g1; delete | empty | g1 == W fulfils\n (R2 begins: miss, reads v2, fills v2 if marker still g1)\n read resolves v1 | | |\n guard: g0 != g1 -> skip fill, return v1 to R1's caller (allowed: R1 overlapped W)\n```\nOrder 2 (R1 resolves before W commits): fill v1 admitted at g0; W then deletes it;\nlater reads miss and read v2. Both orders satisfy the invariant with D1; without D1\norder 1 violates it (Step 0 trace). Missing-record creation: same schedule with absent\nDTO; guard suppresses the sentinel fill. Overlapping writes W1, W2: marker advances twice;\nany read started before either commit is suppressed. Instance change: controller\ncontract isolates old reads from the new instance. Regression proof: the six D1 scenarios.\nInteraction edge cases: no user-visible interaction (backend only). Decision gate: F1\ncovered by D1 → A; no new issues.\n\n### Section 5: Code Quality Review\nWrapper matches the existing read-through repository pattern; no duplicated behavior\n(sentinel handling stays in the adapter, coalescing in the repository). Naming: the\nmarker should be named for what it means (committed-write generation), engineering's\ncall. readProfile branches ≤ 4 after D1; writeProfile ≤ 2. No over-engineering; the\noriginal proposal was under-engineered on the race (F1). Decision gate: no new issues.\n\n### Section 6: Test Review\n```\n NEW THING | TYPE | HAPPY | FAILURE | EDGE\n readProfile hit/miss/fill | unit | miss→fill→hit | read rejects → no fill | absent DTO → sentinel\n fill guard (D1) | unit | order 2 admits fill | order 1 suppresses | overlapping writes\n writeProfile delete + marker | unit | commit→marker→delete | reject → cache kept | delete adapter failure\n eviction / byte cap / TTL | unit | within caps | oversize refused | 1000th/1001st entry\n adapter-failure fallback | integration | bypass → repo read | reinit → empty cache | failure mid-write\n concurrent-miss coalescing | integration | one store read | failure releases cohort | fresh cohort after write\n controller instance change | integration | old writer/new reader | mid-read swap | predicate parity\n suppressed-fill counter | unit | increments once | no key label present | zero when no race\n```\nAssertion check: each row maps to a retained contract or the D1 remedy; the D1 rows assert\nthe exact committed version observed by the post-write read, not a lower bound. 2am test:\nD1 scenario (a). Hostile QA: overlapping writes + absent record + instance swap in one\nschedule. Chaos: adapter throwing on every call under load. Pyramid: unit-heavy, correct.\nFlakiness: TTL and ordering tests must use the harness's controlled clock and pause/release\npoints, never timers. Load test: existing rollout stages serve as the load check.\nDecision gate: coverage already approved by author requirements and D1; no new question.\n\n### Section 7: Performance Review\nNo N+1, no new queries or indexes, no new connections. Memory: cache ≤ 16 MiB (adapter).\n**WARNING W1:** the D1 per-key marker is new in-process state; it must stay bounded (for\nexample retained only while a read for that key is in flight, or capped alongside the\ncache). Requirement recorded; data structure left to engineering; verification: a test\nthat marker state is released after reads complete. Slow paths: miss path = existing\nread + two sync cache ops; hit path sub-millisecond. Decision gate: W1 is a required\nproperty of the approved remedy, not a new choice; recorded for engineering.\n\n### Section 8: Observability & Debuggability Review\nExisting metrics (hit/miss, eviction, bytes, fallback, DB CPU, p95) plus the D1\nsuppressed-fill counter on the current dashboard, no key labels. Alerts: existing set\n(SLO breach, p95 > 120 ms 5 min, bypass > 1 min); none added per author. Debuggability:\na stale-read report 3 weeks later is reconstructible from suppressed-fill and bypass\ncounts plus the flag-stage timeline in the runbook. Decision gate: no issues found.\n\n### Section 9: Deployment & Rollout Review\nNo migration. Flag staged 10% → 50% → 100% with one healthy hour each; criteria as\nstated. Rollback: disable flag → controller drains writes, publishes bypass instance;\nseconds. Risk window: single process, no old/new code overlap. Post-deploy: first 5 min\nwatch bypass and error rate; first hour watch hit rate and p95. Smoke: one read-after-\nwrite probe on a synthetic tenant. Decision gate: no issues found.\n\n### Section 10: Long-Term Trajectory Review\nDebt: marker state needs a short ASCII comment (schedule diagram above) in the wrapper.\nPath dependency: none; the repository interface stays the replacement seam. Reversibility\n5/5. Ecosystem fit: reuses adapter, single-flight, controller. 1-year question: obvious\nif the comment and the six scenarios ship. Decision gate: no issues found.\n\n### Section 11: Design & UX Review\nSKIPPED (no UI scope).\n\n## Closing sequence\n- **Outside Voice:** codex_reviews disabled (`.gstack-section-state-IfADEH/config.yaml`).\n Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`.\n No native fallback. Disabled-coverage review-log write: not persisted (no gstack\n binaries; mutating commands forbidden in this run).\n- **TODO choices:** none proposed. Nothing deferred; HOLD SCOPE.\n- **Approval readiness: PASS** — rows checked: R1 (D1 → A, authority: run-rules author\n policy, scope recorded in the ledger). Plan applies only that scope. W1 is a property of\n R1's remedy, no separate approval. Declined: D1 B, C.\n\n## NOT in scope\nDeferred: none. Rejected: D1-B (weaken consistency to TTL-bounded staleness; not\nauthorizable, violates retained rule), D1-C (write-path bypass; does not fix the race).\nUnchanged non-goals: distributed caching, cross-process coherence, prewarming, consistency\nchanges, new product surfaces, new alerts/metric projects.\n\n## What already exists\nLRU adapter (limits, TTL, absent sentinel) — reused. Single-flight in repository.read —\nreused. Rollout controller (flag, instance swap, predicate parity) — reused. Dashboards,\nalerts, runbooks — reused with one added counter. Deterministic contract harness — reused.\n\n## Dream state delta\nAfter this plan: single-process cache behind the unchanged repository interface, targets\nmet, read-after-write guarantee intact. Remaining to the 12-month ideal: nothing required;\nthe seam for a different backend is preserved.\n\n## Error & Rescue Registry\nSee Section 2 map: 7 codepaths, 6 exception/branch classes, all rescued or non-error; 0 CRITICAL GAPS.\n\n## Failure Modes Registry\n```\n CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?\n -------------------------|------------------------------|----------|-------|-------------------|--------\n readProfile cache.get | adapter failure | Y | Y | slower response | Y (fallback)\n readProfile repo.read | store error | Y | Y | existing error | Y\n readProfile cache.set | adapter failure / oversize | Y | Y | normal | Y\n readProfile fill | stale fill after write (F1) | Y (D1) | Y | fresh value | Y (counter)\n writeProfile repo.write | rejection / rollback | Y | Y | existing error | Y\n writeProfile cache.delete| adapter failure | Y | Y | write succeeds | Y (fallback)\n startup | multi-process config | Y | Y | deploy fails | Y\n```\n7 rows, 0 CRITICAL GAPS.\n\n## Diagrams\n1. System architecture — Section 1. 2. Data flow with shadow paths — Section 4.\n3. State machine — Section 1 (per-key cache state). 4. Error flow — Section 2 map.\n5. Deployment sequence:\n```\n deploy wrapper (flag off) -> enable 10% -> 1 healthy hour -> 50% -> 1 healthy hour -> 100%\n | | breach: disable flag, runbook incident, resume criteria\n```\n6. Rollback flowchart:\n```\n alert/breach? --yes--> disable flag -> controller drains writes -> bypass instance live\n |no -> record incident -> fix -> resume at 10%\n v\n continue stage\n```\nStale diagram audit: no ASCII diagrams exist in files this plan touches (none to audit).\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific\nfinding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~25min)** — wrapper — Implement readProfile/writeProfile with the D1 fill-admission guard and per-key committed-write marker (bounded per W1), plus the suppressed-fill counter with no key labels\n - Surfaced by: Section 1 / Section 4 — F1 stale fill after completed write (ledger R1, D1 → A)\n - Files: to be determined (wrapper module in the profile-summary service)\n - Verify: D1 scenarios (a)–(f) pass on the deterministic harness; marker-release test passes\n- [ ] **T2 (P1, human: ~1d / CC: ~20min)** — tests — Add wrapper tests listed in Section 6, using controlled clock and pause/release points\n - Surfaced by: Section 6 — test diagram rows; author acceptance requirements\n - Files: to be determined (wrapper test file on the existing harness)\n - Verify: all rows green; no timer-based waits\n- [ ] **T3 (P2, human: ~1h / CC: ~5min)** — telemetry — Add suppressed-fill panel to the existing dashboard; add the ASCII schedule comment to the wrapper\n - Surfaced by: Section 8 / Section 10 — observability of the new branch, debt comment\n - Files: to be determined (dashboard config)\n - Verify: panel renders counter with no key labels; comment present\n_No new tasks from Sections 3, 5, 7 (W1 folded into T1), 9, 11._\nTask JSONL: not persisted (no gstack state root in this fixture).\n\n## Completion Summary\n```\n +====================================================================+\n | MEGA PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | Mode selected | HOLD SCOPE |\n | System Audit | skipped per run rules (fixture) |\n | Step 0 | HOLD SCOPE; R1 D1 → A fill-admission guard |\n | Section 1 (Arch) | 1 issue found (F1, resolved) |\n | Section 2 (Errors) | 7 error paths mapped, 0 GAPS |\n | Section 3 (Security)| 0 issues found, 0 High severity |\n | Section 4 (Data/UX) | 9 edge cases mapped, 0 unhandled |\n | Section 5 (Quality) | 0 issues found |\n | Section 6 (Tests) | Diagram produced, 0 gaps |\n | Section 7 (Perf) | 1 issue found (W1, requirement recorded) |\n | Section 8 (Observ) | 0 gaps found |\n | Section 9 (Deploy) | 0 risks flagged |\n | Section 10 (Future) | Reversibility: 5/5, debt items: 1 |\n | Section 11 (Design) | SKIPPED (no UI scope) |\n +--------------------------------------------------------------------+\n | NOT in scope | written (2 rejected, 0 deferred) |\n | What already exists | written |\n | Dream state delta | written |\n | Error/rescue registry| 7 rows, 0 CRITICAL GAPS |\n | Failure modes | 7 total, 0 CRITICAL GAPS |\n | TODOS.md updates | 0 items proposed |\n | Scope proposals | 0 proposed, 0 accepted (HOLD SCOPE) |\n | CEO plan | skipped by mode |\n | Outside voice | codex — disabled (config), no fallback |\n | Lake Score | 1/1 recommendations chose complete option |\n | Diagrams produced | 6 (arch, data flow, state, error, deploy, rollback) |\n | Stale diagrams found | 0 |\n | Unresolved decisions | 0 |\n +====================================================================+\n```\nUnresolved Decisions: none. Review log / decision log / dashboard read: not persisted\n(no gstack binaries; mutating commands forbidden). No durable learnings this session.\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 (not persisted) | CLEAR | mode: HOLD SCOPE, 0 critical gaps; 1 resolved finding (F1, D1 → A), 1 warning (W1) |\n| Outside Review | codex `/plan-ceo-review` outside voice | Independent 2nd opinion | 0 | DISABLED | disabled by config; no completed external review |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run |\n| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | not run (no UI scope) |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |\n\n**OUTSIDE COVERAGE:** codex, plan-review phase, disabled (codex_reviews: disabled), no native fallback, no findings claimed.\n**VERDICT:** CEO CLEARED — eng review required\n\nNO UNRESOLVED DECISIONS\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-ceo-section-loading/inventory.json b/test/fixtures/detector-corpora/plan-ceo-section-loading/inventory.json
new file mode 100644
index 000000000..b2e28ea14
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-ceo-section-loading/inventory.json
@@ -0,0 +1,85 @@
+[
+ {
+ "run": "37174266054",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "a7f8ef2d01d4716d",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37174266054-t1-pass.json"
+ },
+ {
+ "run": "37176837432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "a7f8ef2d01d4716d",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": null,
+ "reason": "Same series (a7f8ef2d01d4716d) as the 37174266054 entry and same detector path (approved ledger decision and prose finding both hold) as the 37179171083 and 37198445662 entries; omitted to bound corpus size."
+ },
+ {
+ "run": "37179171083",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "c040a0214e4f3172",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37179171083-t1-pass.json"
+ },
+ {
+ "run": "37182865432",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "b56318cf9e1e2194",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37182865432-t1-fail.json"
+ },
+ {
+ "run": "37186854666",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "2986275878c588ef",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37186854666-t1-pass.json"
+ },
+ {
+ "run": "37193478719",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "2986275878c588ef",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37193478719-t1-fail.json"
+ },
+ {
+ "run": "37195203538",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "68761d79db01290b",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37195203538-t1-pass.json"
+ },
+ {
+ "run": "37198445662",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "68761d79db01290b",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37198445662-t1-pass.json"
+ },
+ {
+ "run": "37182865432",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "b56318cf9e1e2194",
+ "artifact": "paid-slice-6-a1",
+ "available": true,
+ "entry": "37182865432-t1-fail-derived-unrelated-decision.json",
+ "reason": "derived negative control (no genuine failing capture exists for this case)"
+ }
+]
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37174266054-t1-pass.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37174266054-t1-pass.json
new file mode 100644
index 000000000..e3b48fa71
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37174266054-t1-pass.json
@@ -0,0 +1,22 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "c0a69951bb8e69c8",
+ "source": {
+ "run": "37174266054",
+ "sha": "a8ace39030dd92745b45ac16e5d733e63c936c0f",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37174266054-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-0332/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Pre-repair pass on the oldest series: the summary itself names information architecture and interaction state, and the saved plan adds the design states.",
+ "input": {
+ "exit_reason": "success",
+ "output": "Read-back confirms the saved plan ends with the GSTACK REVIEW REPORT and its final line is the unresolved-decisions block.\n\nDesign review of plan.md is complete and saved. Overall design score moved from 2/10 to 8/10.\n\n- Classified the dashboard as an OPERATE surface and cut the gradient hero, card grid and decorative icons (hard rejections #1 and #7 cleared).\n- Added nine design decisions D1 to D9: ASCII information architecture, a full interaction state table, a journey storyboard, a no-slop UI spec, a token set as CSS variables, three responsive layouts, an accessibility spec, live-update behavior and edge-case formatting.\n- Each pass is rated before and after with a one-line path to 10. Lowest after-score is 8 on Journey and Design System.\n- Three decisions stay unresolved for a product owner: the engagement rate formula, light or dark from the use scene, and whether settings holds destructive actions.\n- Six implementation tasks, a NOT in scope list, and a TODO to run `/design-consultation` are in the plan.\n- Skipped in this run: preamble scripts, mockups, outside voices, review log and dashboard. Verdict is ISSUES OPEN, eng review still required.",
+ "plan_after": "# Plan: User Dashboard\n\n## Context\nBuild a user dashboard that shows account stats, recent activity, and settings.\n\nSurface mode: OPERATE (app UI). The visitor's win is finishing a task: check numbers, scan what happened, change a setting. Scanability and native expectations beat expression.\n\n## Implementation\n1. Create a dashboard page at /dashboard\n2. Show user stats (posts, followers, engagement rate) per D1 and D4\n3. Add a recent activity feed per D1, D2 and D8\n4. Add a settings panel per D1 and D2\n5. Apply the token set in D5 (replaces \"clean, modern UI with cards and icons\")\n6. Page header per D4 (replaces \"hero section with gradient background\")\n7. Implement every state in D2, the responsive layouts in D6 and the a11y spec in D7\n8. Handle the edge cases in D9\n\n## Technical Details\n- React components with Tailwind CSS; tokens from D5 exposed as CSS variables in the Tailwind theme\n- API endpoint: GET /api/dashboard\n- WebSocket for real-time activity updates, behavior per D8\n\n## Design Decisions\n\n### D1. Information architecture (Pass 1)\nHierarchy on first paint: 1) the three stats, 2) the activity feed, 3) settings. Constraint worship: only these three stats appear; no fourth metric without removing one.\n\n```\n+------------------------------------------------------------------+\n| App logo Dashboard Settings [avatar menu] | <- global nav, 56px\n+------------------------------------------------------------------+\n| h1 \"Dashboard\" \"Updated 2 min ago\" (live dot per D8) | <- page header, one line\n+------------------------------------------------------------------+\n| Posts Followers Engagement | <- stat row, 3 columns\n| 1,284 12.4K 4.8% | tabular numerals, 32px\n| +12 this wk +310 this wk -0.2 pts | delta line, 14px\n+---------------------------------------------+--------------------+\n| h2 \"Recent activity\" | h2 \"Settings\" |\n| 09:41 Ana liked \"Spring launch\" | Display name [___] |\n| 09:40 New follower: @marco | Email [___] |\n| 09:12 Comment on \"Q3 notes\" | Notifications (o ) |\n| ... [Load 20] | [Save changes] |\n+---------------------------------------------+--------------------+\nGrid: 12 columns, feed spans 8, settings spans 4.\nNavigation: /dashboard is the overview; \"Settings\" in the nav scrolls to or focuses the panel (same route).\n```\n\n### D2. Interaction state table (Pass 2)\nWhat the user sees; backend behavior is out of scope here.\n\n| FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL |\n|---|---|---|---|---|---|\n| Stat row | Three number-shaped skeletons (32px tall, 50% width), labels visible, no spinner | New account: \"0\" with delta line \"Publish your first post to start tracking\" linking to the composer | Inline per stat: \"Couldn't load\" in text-muted plus a \"Retry\" text button; other stats stay | Numbers in tabular numerals, delta colored by sign per D5 | One stat failed: that cell shows the error treatment, others render |\n| Activity feed | Five row skeletons matching row height (44px) | Headline \"Nothing here yet\", one sentence \"Activity from your posts and followers shows up here as it happens\", primary button \"Create a post\" | Headline \"Activity didn't load\", body with a plain-language reason when known, \"Try again\" button; feed keeps any rows already shown | Rows grouped by day with sticky day label | After 20 rows a \"Load 20 more\" button; while loading more, the button shows a spinner and stays in place |\n| Live updates (D8) | n/a | n/a | Status text \"Reconnecting...\" replaces \"Updated N min ago\"; after 30s \"Live updates paused. Refresh\" link | New rows slide in from the top over 160ms, then settle | Offline: rows received before disconnect stay, banner shows last sync time |\n| Settings panel | Field skeletons, Save disabled | n/a (fields always exist) | Field-level message under the field in text-danger with icon; panel-level banner only for save failures, with \"Try again\" | Toast \"Settings saved\" bottom-left, 4s, dismissible; Save returns to disabled until the next change | Some fields saved, some failed: failed fields keep their edited value and show the field error |\n\n### D3. User journey storyboard (Pass 3)\n\n| STEP | USER DOES | USER FEELS | PLAN SPECIFIES? |\n|---|---|---|---|\n| 1 | Lands on /dashboard (5-sec visceral) | Oriented: \"my numbers are right there\" | D1 stat row first, D4 header stays one line |\n| 2 | Scans the deltas | Reassured or alerted, not confused | D5 sign colors plus explicit \"+/-\" so color is not the only signal |\n| 3 | Reads the feed for 30 seconds (5-min behavioral) | Caught up without hunting | D1 day grouping, D8 live rows arrive quietly |\n| 4 | Changes a setting | Confident it took effect | D2 toast plus disabled Save state |\n| 5 | Returns weekly for a year (5-year reflective) | Trusts the dashboard as the record of their account | D9 number formatting consistency; U1 metric definition still open |\n| 6 | Opens it on a phone at a bus stop | Gets the three numbers in one glance | D6 mobile layout |\n\n### D4. Specific UI, no slop (Pass 4)\n- No hero. The page header is one 56px line: h1 \"Dashboard\" at 24px and the live status text. No gradient, no banner image, no welcome copy.\n- No cards. The stat row is a single bordered region divided by 1px rules; feed and settings are plain layout regions separated by whitespace and the grid, not boxes.\n- No icons as decoration. Icons appear only where they carry information: live status dot, field error icon, external-link marker.\n- Copy is utility language: section headings state the area (\"Recent activity\", \"Settings\"), never mood or brand lines.\n- Motion: one authored moment, new feed rows entering per D2, ease-out from a visible default. No hover scale, no pulsing dot (status dot is static; color conveys state).\n\n### D5. Token set (Pass 5)\nNo DESIGN.md exists; these tokens are the plan's contract until `/design-consultation` creates one. All exposed as CSS variables.\n- Type: display and body IBM Plex Sans (explicitly assigned as the Operate body/UI face), IBM Plex Mono for numbers in the stat row and timestamps. Scale: 14 / 16 / 20 / 24 / 32px. Body text 16px minimum.\n- Color: `--bg #FFFFFF`, `--surface #F6F7F9`, `--text #141518`, `--text-muted #5B6068`, `--rule #E2E5EA`, `--accent #0A6B4F` (one accent), `--positive #1B7F3B`, `--danger #B42318`. Secondary text on the accent surface is tinted from the accent hue, not gray. All text pairs meet 4.5:1.\n- Spacing: 4px base; section gaps 32px; more space above a heading (32px) than below it (12px).\n- Radius: 6px on inputs and buttons only; regions are square.\n- Depth: toast only, shadow `0 4px 12px rgba(20,21,24,0.12)`; no halos.\n- Browser surfaces themed from the palette: selection color (accent at 20%), focus ring 2px accent with 2px offset, scrollbar thumb `--rule`, visited links `--text-muted` with the same underline.\n\n### D6. Responsive behavior (Pass 6)\n| Viewport | Layout |\n|---|---|\n| >= 1024px | Grid per D1: stat row 3 columns, feed 8 cols, settings 4 cols |\n| 768 to 1023px | Stat row 3 columns; feed full width; settings becomes a \"Settings\" tab beside \"Activity\" above the feed region, no sidebar |\n| < 768px | Stat row becomes one horizontal strip: three numbers side by side at 24px, labels at 12px uppercase, no deltas (deltas appear on tap of a stat, expanding a 48px detail line). Feed full width, 20 rows then \"Load 20 more\". Settings moves to the nav as its own screen reached from the avatar menu |\n\nTouch targets 44px minimum on all viewports. Nothing relies on hover.\n\n### D7. Accessibility (Pass 6)\n- Landmarks: `header` (nav), `main` (stats and feed), `aside` labeled \"Settings\", `nav` labeled \"Primary\".\n- Keyboard: tab order follows D1 reading order; stat cells are not focusable unless they expand (mobile). Feed \"Load more\" and every form control reachable; Save is a real `button type=\"submit\"`.\n- Live region: the feed container is `aria-live=\"polite\"`; new rows are announced as \"New activity: ...\" at most once every 10s to avoid flooding.\n- Labels: every settings field has a visible label above it; placeholders never act as labels. Errors linked with `aria-describedby`.\n- Contrast and color: sign conveyed by \"+\"/\"-\" text and color together; status dot paired with text.\n- Reduced motion: `prefers-reduced-motion` disables the row entrance; rows appear instantly.\n\n### D8. Real-time behavior (Pass 7)\nNew rows insert at the top only when the user is scrolled within 200px of the top; otherwise a pill \"3 new\" appears at the top of the feed and scrolls up on click. Status text shows \"Updated just now\" then \"N min ago\". Reconnect and offline treatment per D2.\n\n### D9. Edge cases (Pass 7)\n- Numbers: compact above 9,999 (12.4K, 1.2M) with the exact value in a `title` tooltip and visible on focus; engagement rate to one decimal.\n- Names: display names truncate at one line with ellipsis in the feed; a 47-char name never wraps rows. Full name in `title`.\n- Timestamps: today shows time, this week shows weekday, older shows date; absolute timestamp on hover and focus.\n- First-time user: empty treatments in D2; no onboarding tour.\n\n## NOT in scope\n- Charts or trend graphs: three numbers with deltas answer the weekly question; a chart needs a metric definition (U1) first.\n- Dark theme: deferred under U2 until the use scene is known.\n- Notifications center or filtering the feed: nothing in the plan requires it; subtraction default.\n\n## What already exists\n- No DESIGN.md, no existing UI components found in the repo. Reuse only Tailwind defaults for the grid and the browser-native form controls restyled per D5.\n\n## Unresolved Decisions\n| DECISION NEEDED | IF DEFERRED, WHAT HAPPENS |\n|---|---|\n| U1. Engagement rate formula and comparison period for every delta | Engineer picks a formula; the number means something different each sprint and users stop trusting it |\n| U2. Light or dark from the use scene (who, where, under what light) | Light ships by category default; wrong if the main scene is a creator checking at night |\n| U3. Does the settings panel include destructive actions (delete account, disconnect integrations)? | Engineer adds a red button next to Save; destructive and routine actions share one region |\n\n## TODOS.md updates\n- Run `/design-consultation` to turn D5 into a DESIGN.md. Why: tokens live in one plan today and will drift. Pros: shared vocabulary. Cons: an hour of setup. Context: this review found no design system. Depends on: nothing.\n- Resolve U1 to U3 above before implementation starts.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~20min)** — Dashboard layout — Build the D1 grid and D4 header; remove hero and cards\n - Surfaced by: Pass 1 and Pass 4 — hero and card grid hard rejections\n - Files: src/pages/Dashboard.tsx, src/components/StatRow.tsx\n - Verify: screenshot at 1280px matches D1 diagram; no gradient, no card borders\n- [ ] **T2 (P1, human: ~1d / CC: ~25min)** — All components — Implement every D2 state\n - Surfaced by: Pass 2 — no states specified\n - Files: src/components/StatRow.tsx, ActivityFeed.tsx, SettingsPanel.tsx\n - Verify: Storybook story per table cell; mock API failure and empty account\n- [ ] **T3 (P1, human: ~4h / CC: ~15min)** — Theme — Define D5 CSS variables in the Tailwind theme and theme browser surfaces\n - Surfaced by: Pass 5 — no tokens, no DESIGN.md\n - Files: tailwind.config.ts, src/styles/tokens.css\n - Verify: contrast check on every text pair >= 4.5:1; focus ring visible on all controls\n- [ ] **T4 (P1, human: ~1d / CC: ~20min)** — Layout — Implement D6 breakpoints and D7 a11y\n - Surfaced by: Pass 6 — no responsive or a11y spec\n - Files: src/pages/Dashboard.tsx, src/components/*\n - Verify: axe clean; keyboard-only walkthrough; 375px, 768px, 1280px screenshots\n- [ ] **T5 (P2, human: ~4h / CC: ~15min)** — ActivityFeed — Implement D8 live insert, \"N new\" pill, reconnect states\n - Surfaced by: Pass 7 — real-time behavior unspecified\n - Files: src/components/ActivityFeed.tsx, src/hooks/useActivitySocket.ts\n - Verify: kill the socket in devtools; pill appears when scrolled down\n- [ ] **T6 (P2, human: ~2h / CC: ~10min)** — Formatting utils — Implement D9 number, name and time formatting\n - Surfaced by: Pass 7 — edge cases\n - Files: src/lib/format.ts\n - Verify: unit tests for 9,999 / 10,000 / 1.2M, 47-char name, each timestamp bucket\n\n_No new tasks from Pass 3; the storyboard records requirements owned by other tasks._\n\n## Design Review Notes\n\nSystem Audit: no DESIGN.md (gap), no CLAUDE.md or TODOS.md, no prior reviews. UI scope: one page, three regions, live updates. Mockups: designer not available in this run, text-only review. Outside voices: skipped (non-interactive run).\n\nStep 0: initial design completeness 2/10. The plan names features and a stack but nothing the user sees. A 10 for this plan is D1 through D9 present with every state, viewport and a11y behavior specified.\n\n| Pass | Before | After | Rationale and what makes it a 10 |\n|---|---|---|---|\n| 1 Info Arch | 2 | 9 | Before: six bullets, no order, no diagram. After: D1. A 10 needs U3 settled so the settings region's content is final. |\n| 2 States | 0 | 9 | Before: none. After: D2 covers every feature and state. A 10 needs the settings field list fixed (U3). |\n| 3 Journey | 1 | 8 | Before: no user in the plan. After: D3 across all three horizons. A 10 needs U1 so the reflective horizon rests on a trusted metric. |\n| 4 AI Slop | 1 | 9 | Hard rejections hit before: #1 card grid as first impression, #7 app UI of stacked cards; blacklist #1 gradient hero, #3 decorative icons. Litmus before: 1 NO, 2 NO, 3 NO, 4 NO, 5 NO, 6 NOT SPEC'D, 7 NO. After D4 and D5: all cleared, litmus 1 to 5 and 7 YES, 6 YES (one authored moment). A 10 needs U2 so the look comes from the scene, not the category. |\n| 5 Design Sys | 1 | 8 | Before: \"Tailwind\" only. After: D5 tokens as CSS variables. A 10 needs DESIGN.md (TODO) so tokens are shared, not plan-local. |\n| 6 Responsive | 0 | 9 | Before: none. After: D6 and D7. A 10 needs a real-device pass on the mobile stat strip after build. |\n| 7 Decisions | unscored | 9 resolved, 3 deferred | D1 to D9 added; U1 to U3 open. |\n\nDecisions D1 to D9 were applied under the user's directive for this review run to edit the plan with the missing design decisions; no per-issue question was possible in non-interactive mode. U1 to U3 need a product owner and stay pending.\n\n```\n +====================================================================+\n | DESIGN PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | System Audit | no DESIGN.md; UI scope: 1 page, 3 regions |\n | Step 0 | 2/10, all 7 dimensions reviewed |\n | Pass 1 (Info Arch) | 2/10 → 9/10 after fixes |\n | Pass 2 (States) | 0/10 → 9/10 after fixes |\n | Pass 3 (Journey) | 1/10 → 8/10 after fixes |\n | Pass 4 (AI Slop) | 1/10 → 9/10 after fixes |\n | Pass 5 (Design Sys) | 1/10 → 8/10 after fixes |\n | Pass 6 (Responsive) | 0/10 → 9/10 after fixes |\n | Pass 7 (Decisions) | 9 resolved, 3 deferred |\n +--------------------------------------------------------------------+\n | NOT in scope | written (3 items) |\n | What already exists | written |\n | TODOS.md updates | 2 items proposed |\n | Approved Mockups | 0 generated, 0 approved |\n | Decisions made | 9 added to plan |\n | Decisions deferred | 3 (U1, U2, U3) |\n | Overall design score | 2/10 → 8/10 |\n +====================================================================+\n```\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | not run | — |\n| Outside Review | none recorded | Independent 2nd opinion | 0 | skipped | — |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | not run | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | score: 2/10 → 8/10, 9 decisions |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | not run | — |\n\n**OUTSIDE COVERAGE:** Codex and native subagent design voices skipped in this non-interactive run; no outside coverage recorded.\n\n**VERDICT:** Design Review ISSUES OPEN (3 deferred decisions); no review CLEAR; eng review required.\n\n**UNRESOLVED DECISIONS:**\n- U1. Engagement rate formula and delta comparison period\n- U2. Light or dark theme from the use scene\n- U3. Whether the settings panel includes destructive actions\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-derived-unrated-fail.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-derived-unrated-fail.json
new file mode 100644
index 000000000..e22774e8c
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-derived-unrated-fail.json
@@ -0,0 +1,23 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "c0a69951bb8e69c8",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37179171083-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-0513/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "failed",
+ "census_error": "expect(received).toBe(expected)",
+ "expected": "refuse",
+ "justification": "Plan-term credit needs a rated summary (44ef18c); an unrated summary with none of the design terms is still refused.",
+ "derived": "plan_after of 37179171083 kept; the summary's two \"N/10\" scores rewritten to words so it carries no rating",
+ "input": {
+ "exit_reason": "success",
+ "output": "Read-back verified: the report is the last section and ends on the unresolved-decisions block.\n\nDesign review of plan.md is saved. Overall design score zero → eight, with 6 decisions added and 4 left for you.\n\n- **Classified the surface as OPERATE** and cut the gradient hero. The page now opens on a 56px utility header and three tabular stat numerals (D1, with desktop and 375px ASCII diagrams).\n- **Replaced \"cards and icons\"** with a cardless hairline layout, functional 16px monochrome icons only, left-aligned, one motion moment (D4).\n- **Added the full state table** for stats, feed, live updates, settings, and page shell, including warm empty states and a non-shifting \"N new\" pill for WebSocket inserts (D2).\n- **Added the journey storyboard** across visceral, behavioral, and reflective horizons (D3).\n- **Specified inline tokens** since no DESIGN.md exists: IBM Plex Sans and Mono, 16/20/32 type scale, CSS color variables, 4px radius, no shadows (D5).\n- **Specified responsive and a11y behavior** per viewport, landmarks, skip link, 44px targets, visible labels, reduced motion (D6).\n- **Unresolved for you:** accent hue and typeface license, settings field inventory, engagement rate definition, and whether the API paginates activity.\n- Decisions were applied without AskUserQuestion under your non-interactive instruction. Mockups, outside voices, review log, and the JSONL task artifact were not run. Eng review is still required.",
+ "plan_after": "# Plan: User Dashboard\n\n## Context\nBuild a user dashboard that shows account stats, recent activity, and settings.\n\n## Implementation\n1. Create a dashboard page at /dashboard\n2. Show user stats (posts, followers, engagement rate)\n3. Add a recent activity feed\n4. Add a settings panel\n5. Visual treatment per D4 and D5 below (replaces \"clean, modern UI with cards and icons\")\n6. Compact page header per D1 below (replaces \"hero section with a gradient background\")\n\n## Technical Details\n- React components with Tailwind CSS (tokens from D5 exposed as CSS variables in the Tailwind theme)\n- API endpoint: GET /api/dashboard\n- WebSocket for real-time activity updates (insertion behavior per D2, row \"Live updates\")\n\n## Design Decisions\n\nSurface classification: **OPERATE** (app UI). The visitor's win is finishing a check-in on their account, so scanability and native expectations beat expression. App UI rules apply; a hero is a Persuade pattern and does not belong here.\n\n### D1. Information architecture\nHierarchy, in order of what the user sees: (1) three stat numbers, (2) activity feed, (3) settings, reached from the header rather than competing on the page.\n\n```\n/dashboard (>=1024px) /dashboard (375px)\n+------------------------------------------+ +------------------------+\n| Logo Dashboard [Settings] [Avatar] | | Logo [Settings] [Av]|\n+------------------------------------------+ +------------------------+\n| h1 Your account Updated 2 min ago | | h1 Your account |\n| 128 4,210 4.2% | | Updated 2 min ago |\n| posts followers engagement (30d) | | 128 4,210 4.2% |\n+---------------------------+--------------+ | posts follow. engage. |\n| Recent activity (main) | Settings | +------------------------+\n| [N new] pill when live | (aside, 4/12)| | Recent activity |\n| 09:41 @ana followed you | Display name | | [N new] |\n| 09:12 Post \"...\" liked | Email | | 09:41 @ana followed |\n| ... | Notifications| | 09:12 Post liked |\n| [Load older] | [Save] | | ... |\n+---------------------------+--------------+ | [Load older] |\n +------------------------+\nNav flow: any page -> header \"Dashboard\" -> /dashboard\n /dashboard -> header \"Settings\" -> /dashboard/settings (375-1023px: own route;\n >=1024px: same content rendered in the aside, link scrolls/focuses it)\n```\n\nConstraint worship: if only three things fit, they are the three stat numbers. The header is one row of utility chrome, 56px tall, no imagery, no gradient. The \"Updated 2 min ago\" timestamp is the only status text in the header.\n\n### D2. Interaction states\nWhat the user SEES per feature. Empty states carry warmth, one primary action, and context.\n\n| FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL |\n|---|---|---|---|---|---|\n| Stats strip | Three 32px skeleton bars at numeral width; labels render immediately | \"0\" numerals with label; engagement shows \"—\" plus \"Publish a post to start measuring\" | Numerals replaced by \"—\", one line \"Stats unavailable. Retry\" link (inline, not toast) | Tabular numerals, no animation on first paint | Any one stat missing shows \"—\" for that stat only; other two render |\n| Activity feed | 5 skeleton rows, 56px each, no shimmer when reduced-motion | Heading \"Nothing here yet\", body \"Activity from your posts and followers lands here\", primary button \"Write your first post\" | Row-list area shows \"Couldn't load activity\" with \"Try again\" button; stats and settings unaffected | Rows: time (tabular, left), 16px functional icon, one-line text, 47-char names truncated with ellipsis and full name in title | End of list: \"That's everything from the last 30 days\"; \"Load older\" button while more exist |\n| Live updates (WebSocket) | Nothing visible; no \"connecting\" badge | n/a | Inline notice above feed: \"Live updates paused. Reconnecting…\" then \"Live updates off. Refresh to update\" after 60s | New items never shift read content; a \"3 new\" pill appears at feed top, click inserts them and moves focus to first new row | Reconnect replays missed items through the same pill |\n| Settings panel | Field skeletons keep label text visible | n/a (fields always exist) | Field-level message below input, red text 16px, tied via aria-describedby; Save stays enabled | \"Saved\" text beside Save for 4s, no toast, no page jump | Unsaved changes: Save becomes primary, \"Unsaved changes\" text appears; navigating away prompts |\n| Page shell | Header renders synchronously; main region skeletons | n/a | 401: redirect to /login. 500: full-main message \"Dashboard unavailable\" with Retry; header stays | Normal | Offline: browser offline banner above header, content stays readable |\n\n### D3. User journey storyboard\n\n| STEP | USER DOES | USER FEELS | PLAN SPECIFIES? |\n|---|---|---|---|\n| 1 | Opens /dashboard on phone in the morning | Curious, 5-sec visceral check \"how did I do\" | D1: three numbers first, nothing above them but a 56px header |\n| 2 | Reads stat numbers | Oriented in under 3 seconds | D5: 32px tabular numerals, labels 16px muted |\n| 3 | Scans feed | Reassured by recognizable names and times | D2 feed success row spec; D5 icon rules |\n| 4 | Sees \"3 new\" pill, taps | In control, not interrupted | D2 Live updates row |\n| 5 | Hits error or loss of connection | Informed, not alarmed | D2 error cells: inline, scoped, actionable |\n| 6 | First-time user with zero posts | Welcomed, knows the next action | D2 empty cells: one primary action |\n| 7 | Changes a setting | Confident it took | D2 settings success/partial |\n| 8 | Returns daily for a year (5-year reflective) | Trusts the numbers | D4: no decoration to date it; D5 tokens hold across themes |\n\n### D4. Specific UI, no slop\n- Cardless. Stats, feed, and settings are regions separated by 1px hairline dividers and whitespace, not boxes. Cards appear only if a row becomes the interaction itself (none planned).\n- No hero, no gradient, no decorative blobs, no icons in colored circles. Icons are functional only: one 16px monochrome glyph per activity type (follow, like, comment, mention), aria-hidden, always paired with text.\n- Copy is utility language: \"Recent activity\", \"Your account\", \"Load older\". No \"Welcome to\", no mood statements.\n- Layout left-aligned throughout; numerals and times tabular. Nothing centered except nothing.\n- Motion: one authored moment, the \"N new\" pill fading in over 160ms ease-out. Skeletons do not shimmer. Honors prefers-reduced-motion.\n- Light or dark from the use scene: a creator checking numbers on a phone in daylight. Light is default; dark follows prefers-color-scheme with the same tokens.\n- Browser surfaces themed from D5: selection, caret, focus ring, scrollbar thumb, underline offset.\n\n### D5. Tokens (no DESIGN.md exists; these are this plan's inline design system)\n- Type: IBM Plex Sans for UI and body (explicitly assigned, Operate surface); IBM Plex Mono for numerals and times with `font-variant-numeric: tabular-nums`. Scale 16 / 20 / 32px; body never under 16px. h1 20px/600, stat numerals 32px/500, labels 16px muted.\n- Color as CSS variables: `--bg`, `--fg`, `--fg-muted`, `--hairline`, `--accent`, `--danger`, `--focus`. Body contrast ≥4.5:1, muted text ≥4.5:1. Muted text on any tinted surface is tinted from that hue, never gray. Accent hue is unresolved (U1); purple/indigo excluded.\n- Spacing scale: 4, 8, 12, 16, 24, 32, 48px. More space above a heading (24px) than below (8px).\n- Radius: 4px on inputs and buttons only. No shadows; depth comes from hairlines. Focus ring 2px `--focus`, offset 2px.\n- Links: underlined, visited color differs from unvisited.\n\n### D6. Responsive and accessibility\n- 375px: one column; stats in one row at 32px (fits at 3 × ~110px); settings is its own route; header 56px sticky; touch targets ≥44px; \"Load older\" full width.\n- 768px: one column, max-width 720px, stats row gains labels on the same line as numerals; settings still its own route.\n- ≥1024px: 12-column grid, max-width 1200px, feed 8 columns, settings aside 4 columns, sticky aside.\n- Landmarks: `header`, `nav`, `main` (feed), `aside` (settings). One h1. Skip link to main.\n- Feed is a `ul` with `aria-live=\"polite\"` on the \"N new\" pill only, not the list. Each row is a link with the full text as its name; `time` elements carry `datetime`.\n- Settings: visible labels above fields, never placeholder-as-label; errors tied via aria-describedby; Save reachable by Tab in order.\n- Keyboard: all actions on Tab order, visible focus per D5, Escape dismisses the unsaved-changes prompt.\n\n## NOT in scope\n- Charts or sparklines for stats: numbers alone meet the 3-second scan; add only once a trend question is validated.\n- Notification center or unread badges: the feed and \"N new\" pill cover this.\n- Theming beyond light/dark system preference: no user theme picker.\n- Avatar upload inside settings: separate upload flow with its own states.\n\n## What already exists\n- No DESIGN.md referenced by the plan; D5 stands in until `/design-consultation` produces one.\n- Tailwind is already the styling layer; D5 tokens map to theme extensions, no new dependency.\n- Existing auth redirect to /login is assumed and reused for the 401 state (verify in app shell).\n\n## TODOS.md updates\nApproved fixes are implementation tasks below, not TODOs. One deferred item proposed:\n- **What:** Create DESIGN.md from D5 tokens via `/design-consultation`. **Why:** Three future surfaces will otherwise re-derive tokens. **Pros:** One vocabulary for plans and code. **Cons:** ~1h of decisions including accent hue (U1). **Context:** This plan carries tokens inline because no system existed on 2026-10-04. **Depends on:** U1. Options: A) Add to TODOS.md (recommended) B) Skip C) Build now. Pending user choice.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~20min)** — Dashboard shell — Build header, grid, and landmarks per D1 and D6\n - Surfaced by: Pass 1 — no hierarchy, hero on an Operate surface\n - Files: src/pages/Dashboard.tsx, src/components/AppHeader.tsx\n - Verify: axe has 0 landmark/heading violations; 375/768/1024 screenshots match D1 diagram\n- [ ] **T2 (P1, human: ~1d / CC: ~20min)** — Stats strip, feed, settings — Implement every cell of the D2 state table\n - Surfaced by: Pass 2 — zero states specified\n - Files: src/components/StatsStrip.tsx, ActivityFeed.tsx, SettingsPanel.tsx\n - Verify: Storybook story per state cell; mock API for empty, 500, partial\n- [ ] **T3 (P1, human: ~4h / CC: ~10min)** — Live updates — \"N new\" pill and reconnect notice per D2\n - Surfaced by: Pass 2 and Pass 3 step 4\n - Files: src/hooks/useActivitySocket.ts, ActivityFeed.tsx\n - Verify: inject 3 socket events while scrolled; no layout shift; focus lands on first new row\n- [ ] **T4 (P1, human: ~4h / CC: ~10min)** — Tokens — Define D5 CSS variables in Tailwind theme, theme browser surfaces per D4\n - Surfaced by: Pass 4 and Pass 5\n - Files: tailwind.config.ts, src/styles/tokens.css\n - Verify: contrast check on all text pairs ≥4.5:1; no hex literals in components\n- [ ] **T5 (P2, human: ~4h / CC: ~10min)** — Responsive and a11y — Breakpoint behavior and keyboard paths per D6\n - Surfaced by: Pass 6\n - Files: Dashboard.tsx, SettingsPanel.tsx, routes\n - Verify: keyboard-only walkthrough; touch targets ≥44px at 375px; reduced-motion disables pill fade\n- _No new tasks from Pass 7 until U1–U4 are resolved._\n\n## Completion Summary\n\nDecisions D1–D6 were applied under this run's non-interactive delivery instruction (no AskUserQuestion available); each is reversible by editing its section. Mockups were not generated (designer not run). JSONL task artifact and review log were not written because no gstack binaries were run this pass.\n\n```\n +====================================================================+\n | DESIGN PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | System Audit | No DESIGN.md; UI scope: /dashboard page, |\n | | stats, feed, settings, realtime |\n | Step 0 | 2/10 initial; focus: all 7 passes |\n | Pass 1 (Info Arch) | 2/10 → 9/10 after fixes |\n | Pass 2 (States) | 0/10 → 9/10 after fixes |\n | Pass 3 (Journey) | 1/10 → 8/10 after fixes |\n | Pass 4 (AI Slop) | 1/10 → 8/10 after fixes |\n | Pass 5 (Design Sys) | 1/10 → 8/10 after fixes |\n | Pass 6 (Responsive) | 0/10 → 9/10 after fixes |\n | Pass 7 (Decisions) | 6 resolved, 4 deferred |\n +--------------------------------------------------------------------+\n | NOT in scope | written (4 items) |\n | What already exists | written |\n | TODOS.md updates | 1 item proposed |\n | Approved Mockups | 0 generated, 0 approved |\n | Decisions made | 6 added to plan |\n | Decisions deferred | 4 (listed below) |\n | Overall design score | 0/10 → 8/10 |\n +====================================================================+\n```\n\nScore rationales and what a 10 needs:\n- **Pass 1, 2→9.** Was: six bullets, no order, hero first. Now: D1 ordered hierarchy and diagram. 10 needs settings field inventory (U2) so the aside hierarchy is real.\n- **Pass 2, 0→9.** Was: no states. Now: D2 covers all five states for five features. 10 needs the pagination contract (U4) to confirm the PARTIAL feed cell.\n- **Pass 3, 1→8.** Was: no user mentioned. Now: D3 covers visceral, behavioral, and reflective horizons. 10 needs brand voice (U1) for the empty-state copy to feel like this product.\n- **Pass 4, 1→8.** Hard rejections hit on input: #7 app UI of stacked cards, plus blacklist #1 gradient and #3 icons as decoration. Litmus on D1–D5: brand unmistakable YES (logo and h1 in header), one anchor YES (stats strip), scannable by headings YES, one job per section YES, cards necessary NO so none used, motion improves hierarchy YES (one pill moment), premium without shadows YES (none specified). All hard rejections cleared. 10 needs the accent hue chosen (U1), since an unchosen color invites the purple default back.\n- **Pass 5, 1→8.** No DESIGN.md; D5 supplies tokens inline. 10 needs DESIGN.md so tokens are cited by path, per the TODO above.\n- **Pass 6, 0→9.** Was: nothing. Now: D6 per-viewport layouts and a11y. 10 needs the 768px settings decision confirmed once U2 fixes field count.\n\nPass 7 register:\n\n```\n DECISION NEEDED | IF DEFERRED, WHAT HAPPENS\n ----------------------------------------------|--------------------------------------------\n U1 Accent hue and type license for Plex | Engineer picks Tailwind indigo; blacklist #1\n U2 Which settings fields exist in the panel | Panel ships with display name only, or bloats\n U3 Engagement rate definition and period | Label says \"engagement\"; users distrust number\n U4 Does /api/dashboard paginate activity? | Feed loads everything; \"Load older\" is fake\n```\n\nPlan is not design-complete: four decisions above are deferred to the user. Passes 4 and 5 stay at 8 until U1 and DESIGN.md land. Run /design-review after implementation for visual QA.\n\n## Unresolved Decisions\n- U1: accent hue and typeface licensing (blocks Pass 4 and 5 reaching 10).\n- U2: settings panel field inventory (D1 aside and 768px behavior depend on it).\n- U3: engagement rate formula and window (D1 label currently assumes 30 days).\n- U4: activity pagination in GET /api/dashboard (D2 PARTIAL cell is conditional).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | not run | — |\n| Outside Review | not run (Codex not invoked this pass) | Independent 2nd opinion | 0 | skipped | — |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | not run | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | score: 0/10 → 8/10, 6 decisions |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | not run | — |\n\nPrior review log was not read this pass (gstack-review-read not run); prior run counts shown as 0 are unverified.\n\n**OUTSIDE COVERAGE:** provider codex, phase design, skipped (non-interactive run, no outside process started), no findings. Missing coverage, not clean.\n\n**VERDICT:** No reviews CLEAR. Design review ISSUES OPEN (4 unresolved). Eng review required.\n\n**UNRESOLVED DECISIONS:**\n- U1: accent hue and typeface licensing\n- U2: settings panel field inventory\n- U3: engagement rate formula and window\n- U4: activity pagination contract in GET /api/dashboard\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-derived-unsaved-fail.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-derived-unsaved-fail.json
new file mode 100644
index 000000000..77b3a200c
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-derived-unsaved-fail.json
@@ -0,0 +1,23 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "c0a69951bb8e69c8",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37179171083-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-0513/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "failed",
+ "census_error": "expect(received).toBe(expected)",
+ "expected": "refuse",
+ "justification": "A summary that claims a saved review must not pass when plan.md is unchanged; the plan edit and its design additions are the contract.",
+ "derived": "output of 37179171083 kept; plan_after set to the untouched seed, as if the Write never landed (null = plan.md left as the seed)",
+ "input": {
+ "exit_reason": "success",
+ "output": "Read-back verified: the report is the last section and ends on the unresolved-decisions block.\n\nDesign review of plan.md is saved. Overall design score 0/10 → 8/10, with 6 decisions added and 4 left for you.\n\n- **Classified the surface as OPERATE** and cut the gradient hero. The page now opens on a 56px utility header and three tabular stat numerals (D1, with desktop and 375px ASCII diagrams).\n- **Replaced \"cards and icons\"** with a cardless hairline layout, functional 16px monochrome icons only, left-aligned, one motion moment (D4).\n- **Added the full state table** for stats, feed, live updates, settings, and page shell, including warm empty states and a non-shifting \"N new\" pill for WebSocket inserts (D2).\n- **Added the journey storyboard** across visceral, behavioral, and reflective horizons (D3).\n- **Specified inline tokens** since no DESIGN.md exists: IBM Plex Sans and Mono, 16/20/32 type scale, CSS color variables, 4px radius, no shadows (D5).\n- **Specified responsive and a11y behavior** per viewport, landmarks, skip link, 44px targets, visible labels, reduced motion (D6).\n- **Unresolved for you:** accent hue and typeface license, settings field inventory, engagement rate definition, and whether the API paginates activity.\n- Decisions were applied without AskUserQuestion under your non-interactive instruction. Mockups, outside voices, review log, and the JSONL task artifact were not run. Eng review is still required.",
+ "plan_after": null
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-fail.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-fail.json
new file mode 100644
index 000000000..67b6a47dc
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37179171083-t1-fail.json
@@ -0,0 +1,22 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "c0a69951bb8e69c8",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37179171083-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-0513/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "failed",
+ "census_error": "expect(received).toBe(expected)",
+ "expected": "pass",
+ "justification": "Fixed false red: the rated summary left the design terms to the plan it wrote (all four added there); d603a63 credits terms the review added to plan.md and 44ef18c limits that credit to a rated summary.",
+ "input": {
+ "exit_reason": "success",
+ "output": "Read-back verified: the report is the last section and ends on the unresolved-decisions block.\n\nDesign review of plan.md is saved. Overall design score 0/10 → 8/10, with 6 decisions added and 4 left for you.\n\n- **Classified the surface as OPERATE** and cut the gradient hero. The page now opens on a 56px utility header and three tabular stat numerals (D1, with desktop and 375px ASCII diagrams).\n- **Replaced \"cards and icons\"** with a cardless hairline layout, functional 16px monochrome icons only, left-aligned, one motion moment (D4).\n- **Added the full state table** for stats, feed, live updates, settings, and page shell, including warm empty states and a non-shifting \"N new\" pill for WebSocket inserts (D2).\n- **Added the journey storyboard** across visceral, behavioral, and reflective horizons (D3).\n- **Specified inline tokens** since no DESIGN.md exists: IBM Plex Sans and Mono, 16/20/32 type scale, CSS color variables, 4px radius, no shadows (D5).\n- **Specified responsive and a11y behavior** per viewport, landmarks, skip link, 44px targets, visible labels, reduced motion (D6).\n- **Unresolved for you:** accent hue and typeface license, settings field inventory, engagement rate definition, and whether the API paginates activity.\n- Decisions were applied without AskUserQuestion under your non-interactive instruction. Mockups, outside voices, review log, and the JSONL task artifact were not run. Eng review is still required.",
+ "plan_after": "# Plan: User Dashboard\n\n## Context\nBuild a user dashboard that shows account stats, recent activity, and settings.\n\n## Implementation\n1. Create a dashboard page at /dashboard\n2. Show user stats (posts, followers, engagement rate)\n3. Add a recent activity feed\n4. Add a settings panel\n5. Visual treatment per D4 and D5 below (replaces \"clean, modern UI with cards and icons\")\n6. Compact page header per D1 below (replaces \"hero section with a gradient background\")\n\n## Technical Details\n- React components with Tailwind CSS (tokens from D5 exposed as CSS variables in the Tailwind theme)\n- API endpoint: GET /api/dashboard\n- WebSocket for real-time activity updates (insertion behavior per D2, row \"Live updates\")\n\n## Design Decisions\n\nSurface classification: **OPERATE** (app UI). The visitor's win is finishing a check-in on their account, so scanability and native expectations beat expression. App UI rules apply; a hero is a Persuade pattern and does not belong here.\n\n### D1. Information architecture\nHierarchy, in order of what the user sees: (1) three stat numbers, (2) activity feed, (3) settings, reached from the header rather than competing on the page.\n\n```\n/dashboard (>=1024px) /dashboard (375px)\n+------------------------------------------+ +------------------------+\n| Logo Dashboard [Settings] [Avatar] | | Logo [Settings] [Av]|\n+------------------------------------------+ +------------------------+\n| h1 Your account Updated 2 min ago | | h1 Your account |\n| 128 4,210 4.2% | | Updated 2 min ago |\n| posts followers engagement (30d) | | 128 4,210 4.2% |\n+---------------------------+--------------+ | posts follow. engage. |\n| Recent activity (main) | Settings | +------------------------+\n| [N new] pill when live | (aside, 4/12)| | Recent activity |\n| 09:41 @ana followed you | Display name | | [N new] |\n| 09:12 Post \"...\" liked | Email | | 09:41 @ana followed |\n| ... | Notifications| | 09:12 Post liked |\n| [Load older] | [Save] | | ... |\n+---------------------------+--------------+ | [Load older] |\n +------------------------+\nNav flow: any page -> header \"Dashboard\" -> /dashboard\n /dashboard -> header \"Settings\" -> /dashboard/settings (375-1023px: own route;\n >=1024px: same content rendered in the aside, link scrolls/focuses it)\n```\n\nConstraint worship: if only three things fit, they are the three stat numbers. The header is one row of utility chrome, 56px tall, no imagery, no gradient. The \"Updated 2 min ago\" timestamp is the only status text in the header.\n\n### D2. Interaction states\nWhat the user SEES per feature. Empty states carry warmth, one primary action, and context.\n\n| FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL |\n|---|---|---|---|---|---|\n| Stats strip | Three 32px skeleton bars at numeral width; labels render immediately | \"0\" numerals with label; engagement shows \"—\" plus \"Publish a post to start measuring\" | Numerals replaced by \"—\", one line \"Stats unavailable. Retry\" link (inline, not toast) | Tabular numerals, no animation on first paint | Any one stat missing shows \"—\" for that stat only; other two render |\n| Activity feed | 5 skeleton rows, 56px each, no shimmer when reduced-motion | Heading \"Nothing here yet\", body \"Activity from your posts and followers lands here\", primary button \"Write your first post\" | Row-list area shows \"Couldn't load activity\" with \"Try again\" button; stats and settings unaffected | Rows: time (tabular, left), 16px functional icon, one-line text, 47-char names truncated with ellipsis and full name in title | End of list: \"That's everything from the last 30 days\"; \"Load older\" button while more exist |\n| Live updates (WebSocket) | Nothing visible; no \"connecting\" badge | n/a | Inline notice above feed: \"Live updates paused. Reconnecting…\" then \"Live updates off. Refresh to update\" after 60s | New items never shift read content; a \"3 new\" pill appears at feed top, click inserts them and moves focus to first new row | Reconnect replays missed items through the same pill |\n| Settings panel | Field skeletons keep label text visible | n/a (fields always exist) | Field-level message below input, red text 16px, tied via aria-describedby; Save stays enabled | \"Saved\" text beside Save for 4s, no toast, no page jump | Unsaved changes: Save becomes primary, \"Unsaved changes\" text appears; navigating away prompts |\n| Page shell | Header renders synchronously; main region skeletons | n/a | 401: redirect to /login. 500: full-main message \"Dashboard unavailable\" with Retry; header stays | Normal | Offline: browser offline banner above header, content stays readable |\n\n### D3. User journey storyboard\n\n| STEP | USER DOES | USER FEELS | PLAN SPECIFIES? |\n|---|---|---|---|\n| 1 | Opens /dashboard on phone in the morning | Curious, 5-sec visceral check \"how did I do\" | D1: three numbers first, nothing above them but a 56px header |\n| 2 | Reads stat numbers | Oriented in under 3 seconds | D5: 32px tabular numerals, labels 16px muted |\n| 3 | Scans feed | Reassured by recognizable names and times | D2 feed success row spec; D5 icon rules |\n| 4 | Sees \"3 new\" pill, taps | In control, not interrupted | D2 Live updates row |\n| 5 | Hits error or loss of connection | Informed, not alarmed | D2 error cells: inline, scoped, actionable |\n| 6 | First-time user with zero posts | Welcomed, knows the next action | D2 empty cells: one primary action |\n| 7 | Changes a setting | Confident it took | D2 settings success/partial |\n| 8 | Returns daily for a year (5-year reflective) | Trusts the numbers | D4: no decoration to date it; D5 tokens hold across themes |\n\n### D4. Specific UI, no slop\n- Cardless. Stats, feed, and settings are regions separated by 1px hairline dividers and whitespace, not boxes. Cards appear only if a row becomes the interaction itself (none planned).\n- No hero, no gradient, no decorative blobs, no icons in colored circles. Icons are functional only: one 16px monochrome glyph per activity type (follow, like, comment, mention), aria-hidden, always paired with text.\n- Copy is utility language: \"Recent activity\", \"Your account\", \"Load older\". No \"Welcome to\", no mood statements.\n- Layout left-aligned throughout; numerals and times tabular. Nothing centered except nothing.\n- Motion: one authored moment, the \"N new\" pill fading in over 160ms ease-out. Skeletons do not shimmer. Honors prefers-reduced-motion.\n- Light or dark from the use scene: a creator checking numbers on a phone in daylight. Light is default; dark follows prefers-color-scheme with the same tokens.\n- Browser surfaces themed from D5: selection, caret, focus ring, scrollbar thumb, underline offset.\n\n### D5. Tokens (no DESIGN.md exists; these are this plan's inline design system)\n- Type: IBM Plex Sans for UI and body (explicitly assigned, Operate surface); IBM Plex Mono for numerals and times with `font-variant-numeric: tabular-nums`. Scale 16 / 20 / 32px; body never under 16px. h1 20px/600, stat numerals 32px/500, labels 16px muted.\n- Color as CSS variables: `--bg`, `--fg`, `--fg-muted`, `--hairline`, `--accent`, `--danger`, `--focus`. Body contrast ≥4.5:1, muted text ≥4.5:1. Muted text on any tinted surface is tinted from that hue, never gray. Accent hue is unresolved (U1); purple/indigo excluded.\n- Spacing scale: 4, 8, 12, 16, 24, 32, 48px. More space above a heading (24px) than below (8px).\n- Radius: 4px on inputs and buttons only. No shadows; depth comes from hairlines. Focus ring 2px `--focus`, offset 2px.\n- Links: underlined, visited color differs from unvisited.\n\n### D6. Responsive and accessibility\n- 375px: one column; stats in one row at 32px (fits at 3 × ~110px); settings is its own route; header 56px sticky; touch targets ≥44px; \"Load older\" full width.\n- 768px: one column, max-width 720px, stats row gains labels on the same line as numerals; settings still its own route.\n- ≥1024px: 12-column grid, max-width 1200px, feed 8 columns, settings aside 4 columns, sticky aside.\n- Landmarks: `header`, `nav`, `main` (feed), `aside` (settings). One h1. Skip link to main.\n- Feed is a `ul` with `aria-live=\"polite\"` on the \"N new\" pill only, not the list. Each row is a link with the full text as its name; `time` elements carry `datetime`.\n- Settings: visible labels above fields, never placeholder-as-label; errors tied via aria-describedby; Save reachable by Tab in order.\n- Keyboard: all actions on Tab order, visible focus per D5, Escape dismisses the unsaved-changes prompt.\n\n## NOT in scope\n- Charts or sparklines for stats: numbers alone meet the 3-second scan; add only once a trend question is validated.\n- Notification center or unread badges: the feed and \"N new\" pill cover this.\n- Theming beyond light/dark system preference: no user theme picker.\n- Avatar upload inside settings: separate upload flow with its own states.\n\n## What already exists\n- No DESIGN.md referenced by the plan; D5 stands in until `/design-consultation` produces one.\n- Tailwind is already the styling layer; D5 tokens map to theme extensions, no new dependency.\n- Existing auth redirect to /login is assumed and reused for the 401 state (verify in app shell).\n\n## TODOS.md updates\nApproved fixes are implementation tasks below, not TODOs. One deferred item proposed:\n- **What:** Create DESIGN.md from D5 tokens via `/design-consultation`. **Why:** Three future surfaces will otherwise re-derive tokens. **Pros:** One vocabulary for plans and code. **Cons:** ~1h of decisions including accent hue (U1). **Context:** This plan carries tokens inline because no system existed on 2026-10-04. **Depends on:** U1. Options: A) Add to TODOS.md (recommended) B) Skip C) Build now. Pending user choice.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~20min)** — Dashboard shell — Build header, grid, and landmarks per D1 and D6\n - Surfaced by: Pass 1 — no hierarchy, hero on an Operate surface\n - Files: src/pages/Dashboard.tsx, src/components/AppHeader.tsx\n - Verify: axe has 0 landmark/heading violations; 375/768/1024 screenshots match D1 diagram\n- [ ] **T2 (P1, human: ~1d / CC: ~20min)** — Stats strip, feed, settings — Implement every cell of the D2 state table\n - Surfaced by: Pass 2 — zero states specified\n - Files: src/components/StatsStrip.tsx, ActivityFeed.tsx, SettingsPanel.tsx\n - Verify: Storybook story per state cell; mock API for empty, 500, partial\n- [ ] **T3 (P1, human: ~4h / CC: ~10min)** — Live updates — \"N new\" pill and reconnect notice per D2\n - Surfaced by: Pass 2 and Pass 3 step 4\n - Files: src/hooks/useActivitySocket.ts, ActivityFeed.tsx\n - Verify: inject 3 socket events while scrolled; no layout shift; focus lands on first new row\n- [ ] **T4 (P1, human: ~4h / CC: ~10min)** — Tokens — Define D5 CSS variables in Tailwind theme, theme browser surfaces per D4\n - Surfaced by: Pass 4 and Pass 5\n - Files: tailwind.config.ts, src/styles/tokens.css\n - Verify: contrast check on all text pairs ≥4.5:1; no hex literals in components\n- [ ] **T5 (P2, human: ~4h / CC: ~10min)** — Responsive and a11y — Breakpoint behavior and keyboard paths per D6\n - Surfaced by: Pass 6\n - Files: Dashboard.tsx, SettingsPanel.tsx, routes\n - Verify: keyboard-only walkthrough; touch targets ≥44px at 375px; reduced-motion disables pill fade\n- _No new tasks from Pass 7 until U1–U4 are resolved._\n\n## Completion Summary\n\nDecisions D1–D6 were applied under this run's non-interactive delivery instruction (no AskUserQuestion available); each is reversible by editing its section. Mockups were not generated (designer not run). JSONL task artifact and review log were not written because no gstack binaries were run this pass.\n\n```\n +====================================================================+\n | DESIGN PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | System Audit | No DESIGN.md; UI scope: /dashboard page, |\n | | stats, feed, settings, realtime |\n | Step 0 | 2/10 initial; focus: all 7 passes |\n | Pass 1 (Info Arch) | 2/10 → 9/10 after fixes |\n | Pass 2 (States) | 0/10 → 9/10 after fixes |\n | Pass 3 (Journey) | 1/10 → 8/10 after fixes |\n | Pass 4 (AI Slop) | 1/10 → 8/10 after fixes |\n | Pass 5 (Design Sys) | 1/10 → 8/10 after fixes |\n | Pass 6 (Responsive) | 0/10 → 9/10 after fixes |\n | Pass 7 (Decisions) | 6 resolved, 4 deferred |\n +--------------------------------------------------------------------+\n | NOT in scope | written (4 items) |\n | What already exists | written |\n | TODOS.md updates | 1 item proposed |\n | Approved Mockups | 0 generated, 0 approved |\n | Decisions made | 6 added to plan |\n | Decisions deferred | 4 (listed below) |\n | Overall design score | 0/10 → 8/10 |\n +====================================================================+\n```\n\nScore rationales and what a 10 needs:\n- **Pass 1, 2→9.** Was: six bullets, no order, hero first. Now: D1 ordered hierarchy and diagram. 10 needs settings field inventory (U2) so the aside hierarchy is real.\n- **Pass 2, 0→9.** Was: no states. Now: D2 covers all five states for five features. 10 needs the pagination contract (U4) to confirm the PARTIAL feed cell.\n- **Pass 3, 1→8.** Was: no user mentioned. Now: D3 covers visceral, behavioral, and reflective horizons. 10 needs brand voice (U1) for the empty-state copy to feel like this product.\n- **Pass 4, 1→8.** Hard rejections hit on input: #7 app UI of stacked cards, plus blacklist #1 gradient and #3 icons as decoration. Litmus on D1–D5: brand unmistakable YES (logo and h1 in header), one anchor YES (stats strip), scannable by headings YES, one job per section YES, cards necessary NO so none used, motion improves hierarchy YES (one pill moment), premium without shadows YES (none specified). All hard rejections cleared. 10 needs the accent hue chosen (U1), since an unchosen color invites the purple default back.\n- **Pass 5, 1→8.** No DESIGN.md; D5 supplies tokens inline. 10 needs DESIGN.md so tokens are cited by path, per the TODO above.\n- **Pass 6, 0→9.** Was: nothing. Now: D6 per-viewport layouts and a11y. 10 needs the 768px settings decision confirmed once U2 fixes field count.\n\nPass 7 register:\n\n```\n DECISION NEEDED | IF DEFERRED, WHAT HAPPENS\n ----------------------------------------------|--------------------------------------------\n U1 Accent hue and type license for Plex | Engineer picks Tailwind indigo; blacklist #1\n U2 Which settings fields exist in the panel | Panel ships with display name only, or bloats\n U3 Engagement rate definition and period | Label says \"engagement\"; users distrust number\n U4 Does /api/dashboard paginate activity? | Feed loads everything; \"Load older\" is fake\n```\n\nPlan is not design-complete: four decisions above are deferred to the user. Passes 4 and 5 stay at 8 until U1 and DESIGN.md land. Run /design-review after implementation for visual QA.\n\n## Unresolved Decisions\n- U1: accent hue and typeface licensing (blocks Pass 4 and 5 reaching 10).\n- U2: settings panel field inventory (D1 aside and 768px behavior depend on it).\n- U3: engagement rate formula and window (D1 label currently assumes 30 days).\n- U4: activity pagination in GET /api/dashboard (D2 PARTIAL cell is conditional).\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | not run | — |\n| Outside Review | not run (Codex not invoked this pass) | Independent 2nd opinion | 0 | skipped | — |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | not run | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | score: 0/10 → 8/10, 6 decisions |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | not run | — |\n\nPrior review log was not read this pass (gstack-review-read not run); prior run counts shown as 0 are unverified.\n\n**OUTSIDE COVERAGE:** provider codex, phase design, skipped (non-interactive run, no outside process started), no findings. Missing coverage, not clean.\n\n**VERDICT:** No reviews CLEAR. Design review ISSUES OPEN (4 unresolved). Eng review required.\n\n**UNRESOLVED DECISIONS:**\n- U1: accent hue and typeface licensing\n- U2: settings panel field inventory\n- U3: engagement rate formula and window\n- U4: activity pagination contract in GET /api/dashboard\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37193478719-t1-pass.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37193478719-t1-pass.json
new file mode 100644
index 000000000..c388cd334
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37193478719-t1-pass.json
@@ -0,0 +1,22 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "6b54ea60e2b7fcd3",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37193478719-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-0953/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Rated summary without the four terms; passes only through the plan-term credit (d603a63, 44ef18c), so the pre-repair detector would have refused this good review too.",
+ "input": {
+ "exit_reason": "success",
+ "output": "Read-back verified: all design decisions, the seven passes, the completion summary and the report are saved, with the report as the last section and the unresolved-decisions block as its final lines.\n\nDesign review of `plan.md` saved. Overall score 0/10 to 7/10, status issues_open.\n\n- **Hero and cards removed.** Classified the page as OPERATE app UI. The gradient hero became a one-line identity bar, and \"cards and icons\" became a hairline stats strip plus feed and settings columns (D1 diagram).\n- **Tokens replace \"clean, modern UI\".** D2 names IBM Plex Sans/Mono, one green accent, text and surface colors, radius, hairlines and themed browser surfaces.\n- **States specified.** D3 is a full loading/empty/error/success/partial table per feature. D4 gives three empty states with copy and an action. D5 defines live WebSocket arrival as a \"3 new · Show\" pill with one authored motion.\n- **Responsive and a11y.** D6 sets distinct layouts at three breakpoints with a bottom tab bar on phones. D7 covers landmarks, feed role, live region, keyboard, contrast and reduced motion.\n- **Review artifacts.** Journey storyboard, seven scored passes with 10/10 rationales, seven implementation tasks, two TODO proposals, and the terminal GSTACK REVIEW REPORT.\n- **Left open for you:** U1 canonical palette and typeface via a DESIGN.md, U2 light vs dark from the use scene, U3 mobile settings tab behavior.\n\nSkipped per your instructions: preamble, mockups, outside voices, review log, telemetry and the JSONL task artifact.",
+ "plan_after": "# Plan: User Dashboard\n\n## Context\nBuild a user dashboard that shows account stats, recent activity, and settings.\n\nSurface mode (Pass 4 classifier): **OPERATE**. The visitor's win is \"check my numbers, see what happened, change a setting, leave.\" App UI rules apply; no marketing hero.\n\n## Implementation\n1. Create a dashboard page at /dashboard with the layout in D1.\n2. Show user stats (posts, followers, engagement rate) as the Stats strip per D1 and D3.\n3. Add a recent activity feed per D1, D3, D5.\n4. Add a settings panel per D8.\n5. Apply the type and color tokens in D2 (replaces \"clean, modern UI with cards and icons\").\n6. Replace the gradient hero with the Identity bar in D1 (hero removed; see Pass 4).\n7. Implement every state in the D3 table and the D4 empty states.\n8. Implement responsive layouts per D6 and accessibility per D7.\n\n## Technical Details\n- React components with Tailwind CSS (tokens from D2 exposed as CSS variables in `tailwind.config`)\n- API endpoint: GET /api/dashboard\n- WebSocket for real-time activity updates (behavior in D5)\n\n## Design Decisions\n\nEach decision is specified once here. Passes, tasks and the report refer to these IDs.\n\n### D1. Information architecture (Pass 1)\nThree things, in this order: **the numbers, what changed, where to act**. Everything else is subordinate.\n\n```\n+----------------------------------------------------------------------+\n| [Logo] Dashboard Posts Audience Settings [Avatar] | <- global nav, current page underlined\n+----------------------------------------------------------------------+\n| Identity bar: \"@handle · Last 30 days ▾\" [New post] | <- 1 line, 56px, flat surface, one accent button\n+----------------------------------------------------------------------+\n| STATS STRIP (one row, hairline dividers, no cards) |\n| Posts 128 ▲4 Followers 2,310 ▲62 Engagement 4.8% ▼0.3 | <- tabular numerals, delta vs previous period\n+------------------------------------------+---------------------------+\n| RECENT ACTIVITY (primary workspace, 2/3) | SETTINGS (context, 1/3) |\n| ● \"Jane liked your post\" · 2m | Email digest [toggle] |\n| ● \"New follower: @sam\" · 14m | Profile visibility [▾] |\n| ● \"Your post reached 1k\" · 1h | Timezone [▾] |\n| ... | Manage account → |\n| [Show earlier activity] | |\n+------------------------------------------+---------------------------+\n```\nNavigation flow: nav → /dashboard (default landing after login). Activity rows link to the referenced post or profile. \"Manage account\" links to the full settings route. The stats strip is not clickable (no hidden affordances).\n\n### D2. Type and color tokens (Pass 5, replaces \"clean, modern UI\")\n| Token | Value | Role |\n|---|---|---|\n| `--font-ui` | IBM Plex Sans 400/600 | body and UI, 16px base, 1.5 line height |\n| `--font-num` | IBM Plex Mono 500, `font-variant-numeric: tabular-nums` | stats, deltas, timestamps |\n| `--text-1` / `--text-2` | `#17191C` / `#5A6068` | primary / secondary text on `--surface` |\n| `--surface` / `--surface-2` | `#FFFFFF` / `#F4F5F7` | page / settings column |\n| `--accent` | `#0B6E4F` | one accent: primary button, focus ring, active nav |\n| `--up` / `--down` | `#0B6E4F` / `#B42318` | deltas; always paired with ▲/▼ glyph, never color alone |\n| `--hairline` | `#E3E5E8` 1px | dividers; no card borders, no shadows, no gradients |\n| `--radius` | 6px (controls only) | containers are square |\n| Type scale | 32/24/16/14 px | stat value / section heading / body / meta |\nBrowser surfaces themed from these tokens: selection `--accent` at 20% alpha, focus ring 2px `--accent` offset 2px, caret `--accent`. Light mode chosen because the use scene is a creator checking numbers at a desk or on a phone in daylight; see U2.\n\n### D3. Interaction state table (Pass 2)\nWhat the user SEES, per feature.\n\n| FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL |\n|---|---|---|---|---|---|\n| Stats strip | 3 skeleton bars at value height, labels visible, 200ms delay before showing | See D4-a | Values replaced by \"—\", one inline line: \"Couldn't load stats. Retry\" (button) | Values with ▲/▼ delta and period label | Loaded stats shown; missing stat shows \"—\" with tooltip \"Not available yet\" |\n| Activity feed | 5 skeleton rows, 200ms delay | See D4-b | Inline notice at feed top: \"Activity isn't loading. Retry\"; last known rows stay visible | Rows newest first, relative time, each row one line, 47-char names truncated with ellipsis and full name in `title` | First page shown; \"Show earlier activity\" button; if an earlier page fails, the button shows \"Retry loading earlier\" |\n| Live updates (WS) | Dot in feed heading: \"Live\" when connected | n/a | Heading dot goes gray: \"Reconnecting…\", then \"Offline, refresh to update\" after 30s | New items per D5 | Connected but no events: heading stays \"Live\", nothing else changes |\n| Settings panel | Controls render disabled with current values grayed until loaded | n/a (always has settings) | Field-level: \"Couldn't save. Try again\" under the control, previous value restored | Control reflects new value, 2s \"Saved\" text next to it, no toast | One field fails, others saved; only the failed field shows its error |\n| Identity bar / period picker | Handle shown immediately from session; period picker disabled until stats load | n/a | Picker stays on last good period; stats error shown in strip | Changing period reloads stats and feed with skeletons | n/a |\n\n### D4. Empty states (Pass 2)\n- **D4-a Stats, no posts yet:** values show \"0\", engagement shows \"—\". Under the strip, one sentence in `--text-2`: \"Your stats start with your first post.\" Primary action is the existing New post button, which gains a 1-line caption; no illustration.\n- **D4-b Feed, no activity:** feed area shows a single row-height line: \"Nothing yet. When people follow or react, it shows up here.\" plus a secondary link \"Share your profile\" (copies URL). No icon, no empty-state artwork.\n- **D4-c Feed, filtered period empty:** \"No activity in the last 30 days. Show all time\" (link switches period).\n\n### D5. Live update behavior (Pass 2/3)\nNew WebSocket events do not insert into the list while the user is reading. A pill appears at feed top: \"3 new · Show\". Clicking inserts the rows with one 200ms ease-out fade (the page's single authored motion moment) and scrolls to the top of the feed. If the user is already scrolled to the top and idle for 5s, items insert directly. Stats strip values update in place without animation when the payload includes new totals.\n\n### D6. Responsive behavior (Pass 6)\n| Viewport | Layout decision |\n|---|---|\n| ≥1024px | D1 as drawn: 2/3 feed, 1/3 settings, stats in one row |\n| 768–1023px | Stats one row; settings column moves below the feed as a full-width section headed \"Settings\"; feed shows 10 rows before \"Show earlier\" |\n| <768px | Global nav collapses to bottom tab bar (Dashboard, Posts, Audience, Settings) at 56px; identity bar wraps to two lines with New post full-width below; stats become a 3-up single row with values at 24px and labels at 12px, horizontally fixed (no scroll); feed is the page body; settings panel is NOT shown inline, the Settings tab routes to the full settings page; live-update pill is sticky under the identity bar |\nTouch targets 44×44px minimum on all viewports. Activity rows are 56px tall on <768px.\n\n### D7. Accessibility (Pass 6)\n- Landmarks: `header` (nav), `main` (stats + feed), `aside` (settings, labeled \"Settings\"), `nav` with `aria-current=\"page\"`.\n- Headings: h1 \"Dashboard\" visually hidden; h2 for Stats, Recent activity, Settings.\n- Feed is `role=\"feed\"` with `aria-busy` during loading; new items announced via `aria-live=\"polite\"` region reading \"3 new activity items\" (not each item).\n- Keyboard: Tab order follows D1 reading order; period picker and settings selects are native ``; toggles are `` with visible label left of control; focus ring per D2; skip link to main.\n- Contrast: all text pairs in D2 meet 4.5:1 (`--text-2` on `--surface` = 5.9:1). Deltas never rely on color alone (glyph plus sign).\n- Reduced motion: D5 fade disabled under `prefers-reduced-motion`.\n- Labels are always visible; no placeholder-as-label anywhere in the settings panel.\n\n### D8. Settings panel scope (Pass 7)\nInline panel holds exactly: Email digest (switch), Profile visibility (select), Timezone (select), and a \"Manage account →\" link. Anything requiring confirmation (password, delete account, billing) lives on the full settings route, never inline. Saves are per-control and immediate; no Save button.\n\n## Journey Storyboard (Pass 3)\n| STEP | USER DOES | USER FEELS | PLAN SPECIFIES? |\n|---|---|---|---|\n| 1 | Lands on /dashboard after login (5s visceral) | \"My numbers, instantly\" | D1 order, D3 skeletons at 200ms, D2 tabular numerals |\n| 2 | Scans deltas | Reassured or alert, not confused | D2 glyph+color deltas, period label |\n| 3 | Reads activity (5min behavioral) | In control; nothing jumps | D5 pill, no auto-insert while reading |\n| 4 | Flips a setting | Done, trusted | D3 inline \"Saved\", field-level errors |\n| 5 | Loses connection | Informed, not alarmed | D3 live-update row, stale rows kept |\n| 6 | First-time user with no posts | Oriented, nudged | D4-a/b, New post as the one action |\n| 7 | Returns daily for a year (5yr reflective) | Nothing to notice | D2 calm surface, no decoration to tire of |\n\n## NOT in scope\n- Dark mode: deferred until U2 is settled; tokens in D2 are named so a dark set can be added without layout change.\n- Charts or sparklines in the stats strip: deltas carry the trend; add only if users ask for history.\n- Notification preferences beyond email digest: belongs to the full settings route.\n- Activity filtering/search: feed is chronological only; revisit if the feed exceeds a few hundred items per month.\n\n## What already exists\n- No DESIGN.md is referenced by the plan and none is in scope; D2 defines provisional tokens. Run `/design-consultation` to make them canonical (U1).\n- Tailwind is already the styling layer; D2 tokens map to `theme.extend` rather than a new system.\n- Native ``, `` and `role=\"feed\"` cover every control in D1 and D8; no component library needed.\n\n## Design Review (7 passes)\n\nNon-interactive run: each FIX TO 10 below was applied as the recommended option and recorded as D1–D8. Items that need the user's own call are kept open in Pass 7 and the report.\n\n**Step 0 initial rating: 2/10.** The plan said what the backend returns and named four UI blocks, with no hierarchy, states, tokens, viewport or a11y decisions. A 10 for this plan: D1–D8 specified, a DESIGN.md to calibrate against, and mockups approved.\n\n### Pass 1: Information Architecture — 2/10 → 9/10\nBefore: four blocks listed with no order; the hero would have been the first thing seen. After: D1 fixes first/second/third (numbers, change, action) and the nav flow. Not 10 because the Posts and Audience destinations are named but not designed, so the trunk test is only verifiable for this page.\n\n### Pass 2: Interaction State Coverage — 0/10 → 9/10\nBefore: no state mentioned. After: D3 covers five states per feature, D4 gives empty states a sentence, an action and context, D5 defines live arrival. Not 10 until copy is proofread against product voice and the skeleton timings are checked on a slow device.\n\n### Pass 3: User Journey & Emotional Arc — 1/10 → 8/10\nBefore: no user in the plan. After: the storyboard above covers all three time horizons and the first-time path. Not 10 because the arc for a user whose numbers drop (step 2 \"alert\") has no supportive follow-through beyond the glyph; a 10 would decide whether the dashboard points to a cause.\n\n### Pass 4: AI Slop Risk — 1/10 → 8/10\nMode: OPERATE. Hard rejections hit before fixes: #1 generic card grid as first impression, #7 app UI made of stacked cards. Blacklist hits: #1 gradient background, #3 icons as decoration. All removed by D1 and D2 (hairlines, no cards, no icons, one accent).\nLitmus, after fixes: 1 Brand unmistakable: NO, brand is logo-only until U1 resolves. 2 One visual anchor: YES, the stats strip. 3 Scannable by headings: YES, three h2s state the area. 4 One job per section: YES. 5 Cards necessary: NO, and none used. 6 Motion improves hierarchy: YES, D5 single moment. 7 Premium without shadows: YES, none specified.\nCapped at 8 because litmus 1 fails until the palette and type voice are owned by a DESIGN.md (U1).\n\n### Pass 5: Design System Alignment — 1/10 → 7/10\nNo DESIGN.md exists; rated on explicit token and component specification. Before: \"cards and icons\" only. After: D2 defines type, color, radius, hairline and browser-surface tokens; D7 and D8 fix components to native elements. 7, not higher, because the tokens are provisional and the DESIGN.md gap is unresolved (U1). A 10 cites tokens by path from a DESIGN.md with YAML front matter.\n\n### Pass 6: Responsive & Accessibility — 0/10 → 8/10\nBefore: nothing. After: D6 gives each viewport its own layout decision rather than stacking, and D7 covers landmarks, keyboard, screen reader announcements, contrast and touch targets. Not 10 until the bottom tab bar is validated against the rest of the app's mobile nav and an RTL check is done on the delta glyph placement.\n\n### Pass 7: Unresolved Design Decisions (unscored)\n| DECISION NEEDED | IF DEFERRED, WHAT HAPPENS |\n|---|---|\n| U1 Canonical brand palette and typeface (D2 is provisional) | Engineer ships D2 as-is; brand stays logo-only and litmus 1 keeps failing |\n| U2 Light vs dark from the actual use scene (D2 assumes daylight desk/phone) | Light ships; night-time creators get a bright screen with no alternative |\n| U3 Whether the mobile Settings tab should open the inline subset (D8) or the full route | D6 routes to the full page; mobile users lose one-tap toggles |\nResolved in this run: D1–D8 (8 decisions, recommended options applied).\n\n## Implementation Tasks\nSynthesized from this review's findings. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~20min)** — Dashboard layout — Build D1 regions with D2 tokens as CSS variables; remove hero and cards\n - Surfaced by: Pass 1, Pass 4\n - Files: `src/pages/Dashboard.tsx`, `tailwind.config.ts`, `src/styles/tokens.css`\n - Verify: Storybook/Playwright screenshot at 1280px matches D1; `grep -r \"gradient\" src/pages` returns nothing\n- [ ] **T2 (P1, human: ~1d / CC: ~25min)** — Stats strip, feed, settings — Implement every D3 cell and D4 empty states\n - Surfaced by: Pass 2\n - Files: `src/components/StatsStrip.tsx`, `src/components/ActivityFeed.tsx`, `src/components/SettingsPanel.tsx`\n - Verify: MSW scenarios for loading/empty/error/partial per feature; snapshot each\n- [ ] **T3 (P1, human: ~4h / CC: ~10min)** — Live updates — Implement D5 pill, idle auto-insert, reconnect states\n - Surfaced by: Pass 2, Pass 3\n - Files: `src/hooks/useActivitySocket.ts`, `src/components/ActivityFeed.tsx`\n - Verify: Unit test pill count; manual test dropping the socket shows \"Reconnecting…\" then \"Offline\"\n- [ ] **T4 (P1, human: ~1d / CC: ~20min)** — Responsive — Implement D6 breakpoints and bottom tab bar\n - Surfaced by: Pass 6\n - Files: `src/pages/Dashboard.tsx`, `src/components/BottomTabs.tsx`\n - Verify: Playwright at 375/768/1280; touch targets ≥44px via axe\n- [ ] **T5 (P1, human: ~4h / CC: ~10min)** — Accessibility — Landmarks, feed role, live region, focus ring, reduced motion per D7\n - Surfaced by: Pass 6\n - Files: same as T1–T4\n - Verify: axe-core zero violations; VoiceOver pass announces \"3 new activity items\"\n- [ ] **T6 (P2, human: ~2h / CC: ~5min)** — Settings — Per-control save with inline \"Saved\" and field-level errors per D8/D3\n - Surfaced by: Pass 2, Pass 7 (D8)\n - Files: `src/components/SettingsPanel.tsx`, `src/api/settings.ts`\n - Verify: Failing one PATCH shows error on that field only\n- [ ] **T7 (P3, human: ~2h / CC: ~10min)** — Design system — Run `/design-consultation`, write DESIGN.md, replace D2 values with cited tokens\n - Surfaced by: Pass 5, U1\n - Files: `DESIGN.md`, `src/styles/tokens.css`\n - Verify: Every D2 token has a DESIGN.md path\nEffort ratios assume feature work at ~30x human÷CC. JSONL artifact not written in this non-interactive run.\n\n## TODOS.md updates\n- **Dark mode token set.** What: add `--surface`/`--text` dark values. Why: U2 unresolved. Pros: no layout change needed. Cons: doubles contrast checks. Context: D2 names tokens for this. Blocked by: U2.\n- **Downturn follow-through.** What: decide whether a falling engagement delta links to a cause. Why: Pass 3 gap. Pros: turns alarm into action. Cons: needs analytics the API doesn't expose. Blocked by: API scope.\n\n## Completion Summary\n```\n +====================================================================+\n | DESIGN PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | System Audit | No DESIGN.md; UI scope: /dashboard page |\n | Step 0 | 2/10, focus: all 7 passes |\n | Pass 1 (Info Arch) | 2/10 → 9/10 after fixes |\n | Pass 2 (States) | 0/10 → 9/10 after fixes |\n | Pass 3 (Journey) | 1/10 → 8/10 after fixes |\n | Pass 4 (AI Slop) | 1/10 → 8/10 after fixes |\n | Pass 5 (Design Sys) | 1/10 → 7/10 after fixes |\n | Pass 6 (Responsive) | 0/10 → 8/10 after fixes |\n | Pass 7 (Decisions) | 8 resolved, 3 deferred |\n +--------------------------------------------------------------------+\n | NOT in scope | written (4 items) |\n | What already exists | written |\n | TODOS.md updates | 2 items proposed |\n | Approved Mockups | 0 generated, 0 approved |\n | Decisions made | 8 added to plan |\n | Decisions deferred | 3 (listed below) |\n | Overall design score | 0/10 → 7/10 |\n +====================================================================+\n```\nPass 5 is below 8 because U1 is deferred; the plan is not design-complete until DESIGN.md exists.\n\n## Unresolved Decisions\n- U1 Canonical palette and typeface (DESIGN.md via `/design-consultation`)\n- U2 Light vs dark from the real use scene\n- U3 Mobile Settings tab: inline subset or full route\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — |\n| Outside Review | codex (design voice), skipped | Independent 2nd opinion | 0 | skipped | — |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | score: 0/10 → 7/10, 8 decisions |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |\n\n**OUTSIDE COVERAGE:** provider codex, phase design, skipped in this non-interactive run; no outside coverage. Native review only.\n**VERDICT:** No reviews CLEAR. Design review has 3 open decisions and Pass 5 at 7/10. eng review required.\n\n**UNRESOLVED DECISIONS:**\n- U1 Canonical brand palette and typeface; D2 tokens are provisional until DESIGN.md exists\n- U2 Light vs dark mode decided from the actual use scene; D2 assumes light\n- U3 Mobile Settings tab opens inline subset (D8) or full settings route (D6 assumes full route)\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37195203538-t1-pass.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37195203538-t1-pass.json
new file mode 100644
index 000000000..10e3968de
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37195203538-t1-pass.json
@@ -0,0 +1,22 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "9eb4635ae1888148",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37195203538-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-1025/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Rated summary without the four terms, plan finished by four Edits after the Write; plan-term credit applies to the final edited plan.",
+ "input": {
+ "exit_reason": "success",
+ "output": "Design review of plan.md is saved and verified. Overall design score moved from 0/10 to 7/10 across the seven passes; it is not yet design-complete because Pass 5 (design system) is capped at 7 with no DESIGN.md.\n\n- Classified the surface as OPERATE and removed the gradient hero, cards, and decorative icons (hard rejection 7, blacklist 1 and 3). A compact page header and a cardless three-number stats strip replace them (D1, D4).\n- Added a five-state interaction table for stats, feed, live connection, and settings form, with warm empty states and one action each (D2).\n- Added a seven-step journey storyboard with time horizons (D3) and real-time behavior: buffered \"N new\" pill, no scroll jump, reconnect backoff (D8).\n- Specified provisional tokens, IBM Plex type, one accent, themed browser surfaces, and a single motion moment (D5).\n- Added per-viewport layouts at 375, 768, and 1024+ with 44px targets (D6) and accessibility: landmarks, tab order, live region, contrast, reduced motion (D7).\n- Moved settings to its own route with explicit Save and inline errors (D9).\n- Pass 7 register holds four unresolved decisions: brand palette and typeface, settings fields, engagement-rate definition, RTL. One TODO proposed.\n- Eight implementation tasks and the terminal GSTACK REVIEW REPORT were written. Skipped in this non-interactive run: AskUserQuestion approvals, mockups, outside voices, the JSONL artifact, and the review-log call.",
+ "plan_after": "# Plan: User Dashboard\n\n## Context\nBuild a user dashboard that shows account stats, recent activity, and settings.\n\n## Implementation\n1. Create a dashboard page at /dashboard (layout per D1)\n2. Show user stats (posts, followers, engagement rate) as the stats strip (D1; states D2)\n3. Add a recent activity feed (D1; states D2; real-time behavior D8)\n4. Add settings at /dashboard/settings instead of a panel on the dashboard (D9)\n5. Visual language per D5 (replaces \"clean, modern UI with cards and icons\")\n6. Compact page header per D1 (replaces \"hero section with gradient\", see Pass 4)\n\n## Technical Details\n- React components with Tailwind CSS; D5 tokens are CSS variables mapped into the Tailwind theme\n- API endpoint: GET /api/dashboard (per-metric error payloads so D2 partial state is possible)\n- WebSocket for real-time activity updates (buffer and reconnect per D8)\n\n## Design Decisions\nSurface mode: OPERATE (app UI). Each requirement is specified once here; passes, tasks and report cells cite the ID.\n\n### D1 Information architecture and page header\nAttention order: 1) stats strip, 2) activity feed, 3) header wayfinding. Settings live on their own route (D9). No hero. Header holds: logo, h1 \"Dashboard\", period select (7d / 30d / 90d, default 30d), Settings link. Display name is not in the header; it belongs to the global nav.\n\nDesktop (>=1024px):\n```\n+------------------------------------------------------------------+\n| [Logo] Dashboard [Period: 30d v] Settings |\n+------------------------------------------------------------------+\n| 1,248 posts 12,904 followers 4.2% engagement |\n| ^ 3.1% vs prior ^ 120 vs prior v 0.4 pt vs prior |\n+------------------------------------------------------------------+\n| Recent activity [ 3 new ^ ] |\n| 09:41 ana liked \"Launch week\" |\n| 09:40 sam followed you |\n| ... |\n| [ Load more ] |\n+------------------------------------------------------------------+\n```\nNavigation: /dashboard -> /dashboard/settings; breadcrumb \"Dashboard / Settings\"; Back restores feed scroll position. Trunk test: logo top-left, h1 names the page, current nav item underlined in accent.\n\n### D4 Component anatomy: stats strip and feed row\nStats strip: three numbers in one row, no cards. Hairline rule above and below. Number 32px tabular mono; label 14px sentence case muted; delta line 14px with direction glyph plus text (never color alone). Feed row: time column (relative, `` with absolute title) and one line of text; actor names truncate at 24 chars with ellipsis and full name in title.\n\n### D2 Interaction states\nWhat the user sees in each state.\n\n| FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL |\n|---|---|---|---|---|---|\n| Stats strip | Skeleton bars at final size after a 300ms delay; no layout shift | Numbers read 0 with one line: \"Your first post starts the count.\" + link \"Create a post\" | \"Stats unavailable\" + Retry; cached values shown dimmed with \"as of 09:12\" | Numbers and deltas; delta reads \"no prior data\" when the prior period is empty | One failed metric shows a dash placeholder in its slot with tooltip \"Engagement couldn't load\" + Retry; others render |\n| Activity feed | 6 skeleton rows at real row height | \"Nothing yet. When people follow, like or reply, it shows up here first.\" + link \"Share your profile\"; no illustration | \"Couldn't load activity.\" + Retry; section heading stays | Newest first, 20 per page, Load more button | Fewer than 20 items: no Load more; items whose target was deleted read \"a post that was removed\" |\n| Live connection | Not shown | Not shown | After 3 failed reconnects: inline bar \"Live updates paused.\" + Reconnect; auto backoff 1/2/4/8/30s | New items buffered behind the \"N new\" pill (D8) | Socket down, REST up: feed works, pill hidden, no bar until the 3rd failure |\n| Settings form (D9) | Fields disabled with skeleton values | n/a, fields always hold current values | Per-field inline error under the field with icon; focus moves to first error; save failure line \"Couldn't save. Your changes are kept.\" | \"Saved\" text beside the button for 3s; values persist on reload | Server reports per-field results; failed fields keep their inline error, saved fields clear |\n\n### D3 Journey storyboard\n| STEP | USER DOES | USER FEELS | PLAN SPECIFIES? |\n|---|---|---|---|\n| 1 | Opens /dashboard | \"Where do I stand?\" | Stats first, numbers or skeleton within 300ms (D1, D2) |\n| 2 | Scans deltas | Reassured or alerted | Direction glyph plus text vs prior period (D1) |\n| 3 | Reads activity | Connected to their audience | Named actors, relative time (D1) |\n| 4 | Sees \"3 new\" | In control, not interrupted | Manual insert pill, no scroll jump (D8) |\n| 5 | Loses connection | Mild worry | Paused bar with Reconnect (D2) |\n| 6 | Opens settings | Trusts changes stick | Explicit Save, inline confirmation (D9) |\n| 7 | New account | Welcomed and nudged | Empty copy with one action (D2) |\n\nTime horizons: 5 seconds, three numbers legible at a glance; 5 minutes, a calm feed with one motion moment (D5); 5 years, three zones and nothing else (subtraction default).\n\n### D5 Visual language and tokens (provisional until DESIGN.md exists, see U1)\n- Light by default: use scene is a desk at work in daylight. `prefers-color-scheme: dark` remaps the same tokens.\n- No cards, no gradients, no icons in circles, no decorative shadows. The only shadow: period select menu, `0 4px 12px rgba(0,0,0,.08)`.\n- Type: IBM Plex Sans for body and UI (explicit Operate-surface assignment); IBM Plex Mono with `font-variant-numeric: tabular-nums` for numbers only. Scale 32/24/18/16/14; body text >= 16px.\n- Tokens: `--color-bg #FFFFFF`, `--color-surface #F6F7F9`, `--color-text #16181D`, `--color-text-muted #5B6472`, `--color-accent #0F6E56` (the one accent), `--color-danger #B42318`, `--color-rule #E3E6EB`, `--radius 4px`, space scale 4/8/12/16/24/32/48.\n- Browser surfaces: selection accent at 20%, focus ring 2px accent with 2px offset, caret accent, link underline offset 3px, visited links in `--color-text-muted`.\n- Motion: one moment. Stats count in from 0 over 400ms ease-out on first load. Pill fades 150ms. Both off under `prefers-reduced-motion`.\n- Copy: utility language. No \"Welcome back\", no \"Unlock\". Headings: 32px above, 12px below.\n- Layout max-width 1040px, left-aligned, never centered text.\n\n### D6 Responsive\n| Viewport | Layout |\n|---|---|\n| >=1024px | D1 diagram. Feed rows two columns: time, text |\n| 768 to 1023px | Stats stay one row; deltas drop to a second line under each number. Period select stays in header. Feed full width |\n| <=767px (375 target) | Header: h1 plus a labeled settings icon button; period select moves below the h1 at full width. Stats become a vertical list: number left, label and delta right, hairline between rows. \"N new\" pill sticks under the header. Load more is a 48px full-width button. Feed rows: time above text |\n\nAll interactive targets >= 44px on every viewport. Period select is a native ``.\n\n### D7 Accessibility\n- Landmarks: `header` (banner), `nav`, `main`; stats as ``; feed as `` with ``; one h1.\n- Keyboard: skip link, then header, period select, Settings, stats Retry when present, \"N new\" pill, feed items, Load more. Visible focus ring per D5.\n- Screen readers: deltas read \"up 3.1 percent versus prior 30 days\". Feed region `aria-live=\"polite\"` announces only \"3 new activities\", not each item. Loading uses `aria-busy`.\n- Contrast >= 4.5:1 text, >= 3:1 rules and glyphs. Direction never by color alone.\n- Forms (D9): visible labels above fields, errors linked with `aria-describedby`.\n\n### D8 Real-time feed behavior\nWebSocket items are buffered. A pill \"N new\" (99+ cap) appears at the top of the feed; activating it inserts the items at the top with a 150ms fade and preserves scroll position. Items dedupe by id. On reconnect, fetch items since the last seen id over REST, then resume the socket.\n\n### D9 Settings route\n/dashboard/settings. Single column, max-width 560px, sections Profile, Notifications, Privacy (field inventory pending, U2). \"Save changes\" button disabled until dirty; unsaved-changes prompt on navigate away. Destructive actions are out of this plan.\n\n## NOT in scope\n- Stat charts: number plus delta covers v1; a chart adds a dependency and a second visual grammar.\n- Custom dark-mode art: token remap only.\n- Onboarding tour: sizzle in the way; empty states (D2) do the teaching.\n- Activity export.\n\n## What already exists\nNo DESIGN.md was provided. The plan references no existing components. Reuse: Tailwind config as the token host (D5); native ``, ``, `` over custom widgets; `prefers-*` media queries over JS toggles.\n\n## Design Review (plan-design-review, 2026-10-04)\nRun mode: non-interactive at the user's direction. Per-issue AskUserQuestion gates could not fire; remedies D1 to D9 were applied under that instruction and are recorded here rather than individually approved. Mockups: none generated (designer not invoked). Outside voices: skipped.\n\n**Step 0:** 2/10. The plan lists UI nouns and backend details but never says what the user sees first, in what state, at what width. A 10 for this plan: ordered hierarchy, a state table, a journey, tokens, responsive and a11y specs. D1 to D9 supply them. DESIGN.md: none found; run /design-consultation (U1).\n\n**Pass 1 Info Arch:** 2 → 9. Was four unordered items plus a hero. Now D1 orders attention with a diagram and navigation, and D4 fixes the anatomy of the two primary components. To 10: settings inventory (U2).\n\n**Pass 2 States:** 0 → 9. Nothing was specified. D2 covers five states for four features with warm empty copy and one action each. To 10: engagement-rate definition drives zero and delta copy (U3).\n\n**Pass 3 Journey:** 1 → 8. D3 adds seven steps and the three time horizons. To 10: the 5-year layer and power-user differences need product goals this plan does not state.\n\n**Pass 4 AI Slop:** 1 → 9. Classifier: OPERATE. Original hits: hard rejection 7 (app UI as stacked cards), blacklist 1 (gradient), blacklist 3 (icons as decoration), \"clean, modern\" vibes, hero on an Operate surface. Litmus after fixes: 1 brand unmistakable NO until U1; 2 one anchor YES (stats strip); 3 scan by headlines YES; 4 one job per section YES; 5 cards necessary NO, removed; 6 motion improves YES (one count-in); 7 premium without shadows YES. To 10: palette and typeface are provisional (U1).\n\n**Pass 5 Design System:** 1 → 7. No DESIGN.md. D5 defines explicit tokens and a component vocabulary (header, stats strip, feed row, pill, form). Capped at 7 until tokens are confirmed against a brand (U1).\n\n**Pass 6 Responsive & a11y:** 0 → 9. D6 gives intentional layouts per viewport; D7 covers landmarks, keyboard, screen readers, contrast, reduced motion. To 10: RTL (U4).\n\n**Pass 7 Unresolved decisions** (unscored register):\n\n| DECISION NEEDED | IF DEFERRED, WHAT HAPPENS |\n|---|---|\n| U1 Brand palette and typeface (no DESIGN.md) | D5 provisional green accent and Plex ship and may clash with the brand |\n| U2 Settings field inventory | Engineer invents fields; D9 structure unverifiable |\n| U3 Engagement-rate definition and period | Delta and zero-state copy may mislead |\n| U4 RTL and localization | Layout does not mirror; relative times hardcoded in English |\n\n**TODOS.md updates:** one proposed, pending disposition (A add / B skip / C build now).\n- TODO-1 RTL support (U4). What: mirror D1/D6 layouts and localize D2 copy and times. Why: non-LTR users get a broken reading order. Pros: unlocks those markets. Cons: logical CSS properties and copy extraction across every component. Context: no locale list exists yet. Depends on: product locale decision.\n\n## Implementation Tasks\nSynthesized from this review's findings. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~20min)** — Header + stats strip — Build per D1, D4 and D5\n - Surfaced by: Pass 1, Pass 4 (hero and cards removed)\n - Files: src/pages/Dashboard.tsx, src/components/StatsStrip.tsx, src/components/PageHeader.tsx\n - Verify: visual check at 1280px against the D1 diagram; no gradient or card classes in the diff\n- [ ] **T2 (P1, human: ~1d / CC: ~20min)** — All features — Implement the D2 state table\n - Surfaced by: Pass 2\n - Files: src/components/StatsStrip.tsx, src/components/ActivityFeed.tsx, src/api/dashboard.ts\n - Verify: Storybook story per cell of D2; mock per-metric failure\n- [ ] **T3 (P1, human: ~1d / CC: ~20min)** — Activity feed — Buffer, pill and reconnect per D8\n - Surfaced by: Pass 2, Pass 3 step 4\n - Files: src/components/ActivityFeed.tsx, src/hooks/useActivitySocket.ts\n - Verify: kill the socket in devtools; bar appears after 3rd failure; scroll position unchanged on insert\n- [ ] **T4 (P1, human: ~4h / CC: ~10min)** — All components — Responsive layouts per D6\n - Surfaced by: Pass 6\n - Files: same as T1 to T3\n - Verify: screenshots at 375, 768, 1280; targets >= 44px\n- [ ] **T5 (P1, human: ~4h / CC: ~10min)** — All components — Accessibility per D7\n - Surfaced by: Pass 6\n - Files: same as T1 to T3, src/components/SkipLink.tsx\n - Verify: axe clean; keyboard-only walkthrough of the D7 tab order; VoiceOver reads deltas as specified\n- [ ] **T6 (P2, human: ~1d / CC: ~15min)** — Settings route — Build per D9 and D2 form row\n - Surfaced by: Pass 1 (settings moved off the dashboard)\n - Files: src/pages/DashboardSettings.tsx\n - Verify: dirty tracking, inline errors, \"Saved\" confirmation\n- [ ] **T7 (P2, human: ~3h / CC: ~10min)** — Theme — D5 tokens as CSS variables in Tailwind, browser surfaces themed\n - Surfaced by: Pass 5\n - Files: tailwind.config.ts, src/styles/tokens.css\n - Verify: no raw hex in components; selection, focus ring and visited links use tokens\n- [ ] **T8 (P2, human: ~1h / CC: ~5min)** — Journey — Manual walkthrough of D3 steps 1 to 7 before ship\n - Surfaced by: Pass 3\n - Files: none\n - Verify: each step's \"PLAN SPECIFIES?\" column observed in the running app\n\nJSONL task artifact: not written (gstack binaries not run in this mode; rerun /plan-design-review interactively to emit it).\n\n### Completion Summary\n```\n +====================================================================+\n | DESIGN PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | System Audit | no DESIGN.md; UI scope: dashboard, feed, |\n | | settings; mode OPERATE |\n | Step 0 | 2/10; focus: all 7 passes |\n | Pass 1 (Info Arch) | 2/10 → 9/10 after fixes |\n | Pass 2 (States) | 0/10 → 9/10 after fixes |\n | Pass 3 (Journey) | 1/10 → 8/10 after fixes |\n | Pass 4 (AI Slop) | 1/10 → 9/10 after fixes |\n | Pass 5 (Design Sys) | 1/10 → 7/10 after fixes |\n | Pass 6 (Responsive) | 0/10 → 9/10 after fixes |\n | Pass 7 (Decisions) | 9 resolved, 4 deferred |\n +--------------------------------------------------------------------+\n | NOT in scope | written (4 items) |\n | What already exists | written |\n | TODOS.md updates | 1 item proposed |\n | Approved Mockups | 0 generated, 0 approved |\n | Decisions made | 9 added to plan (D1–D9) |\n | Decisions deferred | 4 (listed below) |\n | Overall design score | 0/10 → 7/10 |\n +====================================================================+\n```\nPass 5 stays below 8, so the plan is not yet design-complete: the token set needs a brand decision (U1). Run /design-review after implementation for visual QA.\n\n### Unresolved Decisions\n- U1 Brand palette and typeface; D5 is provisional.\n- U2 Settings field inventory for D9.\n- U3 Engagement-rate definition and comparison period for D1 and D2 copy.\n- U4 RTL and localization; TODO-1 disposition pending.\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | not run | — |\n| Outside Review | codex via `/plan-design-review` (design phase) | Independent 2nd opinion | 0 | skipped | — |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | not run | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | score: 0/10 → 7/10, 9 decisions |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | not run | — |\n\n**OUTSIDE COVERAGE:** provider codex, phase design, skipped in this non-interactive run; Claude subagent voice not run; no outside findings. Missing coverage, not clean.\n\n**VERDICT:** No reviews CLEARED. Design review has issues open (Pass 5 at 7/10, 4 unresolved decisions). Eng review required.\n\n**UNRESOLVED DECISIONS:**\n- U1 Brand palette and typeface (no DESIGN.md); D5 provisional\n- U2 Settings field inventory (D9)\n- U3 Engagement-rate definition and comparison period (D1, D2)\n- U4 RTL and localization; TODO-1 disposition (add / skip / build now) pending\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/37198445662-t1-pass.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37198445662-t1-pass.json
new file mode 100644
index 000000000..40e4d33cf
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/37198445662-t1-pass.json
@@ -0,0 +1,22 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-design-review-plan-mode",
+ "detector": "test/skill-e2e-design.test.ts#plan-design-review-plan-mode.validate",
+ "series_identity": "9eb4635ae1888148",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "periodic",
+ "artifact": "native-captures-ci-37198445662-1-eval-slices-9",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-1123/plan-design-review-plan-mode.ndjson"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Latest series: summary names hierarchy and the plan (Write then three Edits) adds the design states.",
+ "input": {
+ "exit_reason": "success",
+ "output": "Design review of plan.md is saved and verified. Overall score 0/10 → 7/10, status issues_open.\n\n- **Classified as OPERATE (app UI).** Hero, gradient, card grid, and decorative icons removed; they hit hard rejections 1 and 7 plus three blacklist items.\n- **D1–D2 hierarchy and layout.** Stats strip first, feed second, settings moved to its own route. ASCII grid and nav flow added.\n- **D3 tokens.** CSS variables, type and spacing scales, tabular numerals, no gradients or decorative shadows.\n- **D7 state table.** Loading, empty, error, success, partial for stats, feed, WebSocket, and settings. Empty states name a next action.\n- **D8–D9 responsive and a11y.** Intentional layouts at 768 and 375, 44px targets, landmarks, live-region pill, focus ring, contrast, reduced motion.\n- **Journey storyboard, NOT in scope, What already exists, two TODO proposals, tasks T1–T8.**\n- **Unresolved:** U1 typeface and accent hue (no DESIGN.md), U2 light or dark default. Pass 5 stays at 7/10 until U1 lands.\n- **Skipped:** mockups, outside voices, JSONL artifact, and review-log commands, per the non-interactive instruction. Report notes missing outside coverage.\n\nNext: run `/design-consultation` to settle U1 and U2, then `/plan-eng-review`.",
+ "plan_after": "# Plan: User Dashboard\n\n## Context\nBuild a user dashboard that shows account stats, recent activity, and settings.\n\n## Implementation\n1. Create a dashboard page at /dashboard\n2. Show user stats (posts, followers, engagement rate)\n3. Add a recent activity feed\n4. Add a settings panel (see D1: settings is its own route, not a panel)\n5. ~~Use a clean, modern UI with cards and icons~~ Replaced by D1–D4, D11 and D12 (no cards, no decorative icons)\n6. ~~Add a hero section at the top with a gradient background~~ Replaced by D2 page header (no hero, no gradient)\n\n## Technical Details\n- React components with Tailwind CSS (theme tokens from D3 go in `tailwind.config` as CSS variables)\n- API endpoint: GET /api/dashboard\n- WebSocket for real-time activity updates (UI behavior in D5)\n\n## Design Decisions\nSurface mode: OPERATE (app UI). The visitor's win is \"check my numbers, see what happened, change a setting\". App UI rules apply; no landing-page patterns.\n\n### D1 — Information hierarchy\nFirst: stats strip (three numbers). Second: activity feed. Third: settings, moved to `/dashboard/settings` as a nav tab. A settings form competing with live numbers on one screen breaks \"if you can only show 3 things\".\n\n### D2 — Layout (desktop ≥1024px)\nPage header replaces the hero: left `h1` \"Dashboard\", right a period selector (7d / 30d / 90d). 12-column grid, 1200px max, 24px gutters.\n\n```\n+------------------------------------------------------------------+\n| header: Dashboard [ 7d | 30d | 90d ] |\n+------------------------------------------------------------------+\n| STATS STRIP (full width, one surface, hairline dividers) |\n| Posts Followers Engagement |\n| 1,284 +12 24.1K +3.4% 4.8% -0.2 |\n+---------------------------------------------+--------------------+\n| ACTIVITY FEED (8 cols) | RAIL (4 cols) |\n| Today | avatar, name, |\n| o Ana followed you 2m | handle |\n| o Post \"...\" got 40 likes 1h | Settings -> |\n| Yesterday | |\n| ... | |\n| [ Show more ] | |\n+---------------------------------------------+--------------------+\nNav flow: /dashboard <-tab-> /dashboard/settings (back via same tab)\n```\n\n### D3 — Tokens (CSS variables, no raw hex in components)\n`--color-bg`, `--color-surface`, `--color-text`, `--color-text-muted`, `--color-accent` (one accent, hue per U1), `--color-positive`, `--color-negative`, `--color-border`. Type scale 14 / 16 / 20 / 28 / 40px, body 16px minimum, stats values 40px with `font-variant-numeric: tabular-nums`. Spacing scale 4 / 8 / 12 / 16 / 24 / 32 / 48. Radius 6px on controls only. Depth: 1px borders; the only shadow is on menus (0 4px 12px, soft). No gradients anywhere.\n\n### D4 — Stats strip\nThree figures, left-aligned, label (14px muted) above value (40px). Delta vs prior period beside the value as sign + arrow glyph + tinted text, never color alone. Values ≥100,000 use compact notation (24.1K) with the full number in `title` and `aria-label`. No icons, no cards.\n\n### D5 — Activity feed\nOne list grouped by day with sticky day headings. Row: 32px avatar, one sentence of utility copy (\"Ana followed you\"), relative time with absolute time on `title`. Rows 56px tall. Pagination via a \"Show more\" button, 20 per page. Real-time: new WebSocket items never shift the list; a \"3 new updates\" pill appears at the top of the feed and inserts them on click. A name longer than 32 characters truncates with an ellipsis and keeps the full name in `title`.\n\n### D6 — Settings route\nForm sections (Profile, Notifications, Privacy), each with its own Save. Labels sit above fields and stay visible; placeholders are never the only label. Inline validation on blur; errors in `--color-negative` text plus an icon, tied to the field with `aria-describedby`.\n\n### D7 — Interaction states (what the user sees)\n\n| FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL |\n|---|---|---|---|---|---|\n| Stats strip | 3 skeleton numerals after a 150ms delay, no spinner | Values \"0\"; copy \"Publish your first post to start tracking\"; button \"Write a post\" | Strip keeps last values greyed; inline \"Couldn't refresh. Retry\" | Numbers count up once (D11) | Period change shows skeleton on numbers only; labels stay |\n| Activity feed | 6 skeleton rows matching D5 row height | \"Nothing yet. Activity from your posts and followers shows up here.\" plus link \"Share your profile\" | Inline block in the feed slot: \"Couldn't load activity. Retry\"; header and stats stay usable | List renders; new items via D5 pill | Page 2+ failure: \"Show more\" becomes \"Retry loading more\"; loaded rows stay |\n| Real-time (WS) | n/a | n/a | Footer line \"Updated 3 min ago. Reconnecting…\", retries with backoff; never a modal | Pill \"N new updates\" | Reconnected after gap: feed refetches page 1 silently, pill shows the diff |\n| Settings | Skeleton form | Never empty; fields show current values | Section-level error above the Save it belongs to | Inline \"Saved\" text beside the button for 3s, then fades | One section failing leaves other sections untouched |\n\n### D8 — Responsive\n- 768–1023px: stats stay 3-across; feed goes full width; rail becomes a single row under the stats with the Settings link right-aligned.\n- <768px: stats become a 1-column list, label and value on one line, delta right-aligned. Period selector becomes a native ``. Rail contents move into the header (avatar menu with Settings). Sticky day headings off. Feed rows 64px for touch. Every tappable target ≥44×44px.\n\n### D9 — Accessibility\nLandmarks: `header`, `nav`, `main`, `aside`. One `h1`, day headings as `h2`. Feed is a `` with the new-updates pill in an `aria-live=\"polite\"` region; the list itself is not live. Keyboard order: header → period selector → stats → feed → rail; \"Show more\" and the pill are buttons. Focus ring 2px `--color-accent`, 2px offset, on every interactive element. Contrast ≥4.5:1 body, ≥3:1 for 40px numerals. Deltas carry sign and glyph (D4). `prefers-reduced-motion` disables D11 and the pill fade. Visited links in feed rows use a muted variant of the link color.\n\n### D10 — Browser surfaces\nSelection color, caret, focus ring, and scrollbar thumb themed from `--color-accent` and `--color-border`.\n\n### D11 — Motion\nOne authored moment: stats numerals count up over 400ms, exponential ease-out, from a visible 0. Nothing else animates except the 150ms pill fade.\n\n### D12 — Copy\nUtility language only: orientation, status, action. No \"Welcome back\", no mood lines. Section headings name the area (\"Recent activity\", \"Profile\").\n\n### D13 — Light/dark\nShip light by default (U2 pending). Tokens in D3 are the only thing a dark theme would swap.\n\n## User Journey Storyboard\n\n| STEP | USER DOES | USER FEELS | PLAN SPECIFIES? |\n|---|---|---|---|\n| 1 | Opens /dashboard (5 sec) | \"Are my numbers up?\" | D2 header + D4 strip first; D7 loading skeleton keeps layout stable |\n| 2 | Scans deltas | Relief or concern | D4 sign + glyph + tint; D11 count-up draws the eye once |\n| 3 | Reads feed (5 min) | Curious who engaged | D5 sentences, day grouping, relative time |\n| 4 | Leaves tab open, returns | Wants what changed | D5 pill, D7 WS reconnect row |\n| 5 | First-time user with zero data | Possibly discouraged | D7 empty states name the next action |\n| 6 | Changes a setting | Needs confidence it stuck | D6 per-section Save, inline \"Saved\" |\n| 7 | Months later (5 yr) | Trusts the numbers | D4 full values on hover, no decoration drift, D3 tokens |\n\n## NOT in scope\n- Dark theme: tokens ready (D3), scene decision open (U2).\n- Charts or trend sparklines: adds a second visual system; revisit when there is a history endpoint.\n- Export or share of stats: no user ask yet.\n- Notification center: feed covers the need; a bell icon would be decoration.\n\n## What already exists\nNo DESIGN.md, no existing UI components, no prior design reviews (greenfield repo, one commit). Tailwind is the declared styling layer; D3 tokens live in its theme config so later components share one vocabulary.\n\n## TODOS.md updates\n1. **What:** Create DESIGN.md via `/design-consultation`. **Why:** U1 and U2 cannot be settled by principle alone; they are brand calls. **Pros:** one vocabulary for every future screen. **Cons:** ~30 min of founder time. **Context:** This review defined structure and tokens but left hue and typeface open. **Depends on:** nothing. Proposed: A) Add to TODOS.md.\n2. **What:** Dark theme. **Why:** likely for a tool left open all day. **Pros:** D3 makes it a token swap. **Cons:** doubles contrast QA. **Context:** see U2. **Depends on:** U2. Proposed: A) Add to TODOS.md.\n\n## Implementation Tasks\nSynthesized from this review's findings. Each task derives from a specific finding above. Run with Claude Code or Codex; checkbox as you ship.\n\n- [ ] **T1 (P1, human: ~1d / CC: ~20min)** — Layout — Build D2 grid, header, and `/dashboard/settings` route; delete hero and card wrappers\n - Surfaced by: Pass 1 / Pass 4 — hard rejections 1 and 7\n - Files: `src/pages/Dashboard.tsx`, `src/pages/DashboardSettings.tsx`, router\n - Verify: no `bg-gradient` or card wrapper classes in dashboard components; trunk test passes with nav only\n- [ ] **T2 (P1, human: ~2h / CC: ~10min)** — Tokens — Add D3 CSS variables to `tailwind.config` and `globals.css`\n - Surfaced by: Pass 5 — no tokens specified\n - Verify: grep components for raw hex; expect zero\n- [ ] **T3 (P1, human: ~1d / CC: ~20min)** — Stats strip — Implement D4 with compact notation and tabular numerals\n - Surfaced by: Pass 1, Pass 4 (icons in circles)\n - Verify: render 1,284 / 24,100 / 1,200,000; check `title` holds full value\n- [ ] **T4 (P1, human: ~1.5d / CC: ~30min)** — Activity feed — Implement D5 list, pagination, and new-updates pill over WebSocket\n - Surfaced by: Pass 2 — partial and real-time states unspecified\n - Verify: push 3 WS events; list does not shift; pill reads \"3 new updates\"\n- [ ] **T5 (P1, human: ~1d / CC: ~20min)** — States — Implement every D7 cell; add Storybook stories per cell\n - Surfaced by: Pass 2 — no loading, empty, error states\n - Verify: stories exist for all 20 cells; empty state shows \"Write a post\" button\n- [ ] **T6 (P1, human: ~1d / CC: ~20min)** — Responsive — Implement D8 at 375, 768, 1024, 1440\n - Surfaced by: Pass 6 — no viewport behavior\n - Verify: screenshots at the four widths; all targets ≥44px at 375\n- [ ] **T7 (P1, human: ~1d / CC: ~20min)** — Accessibility — Implement D9 and D10\n - Surfaced by: Pass 6 — no keyboard, ARIA, or contrast spec\n - Verify: axe reports zero violations; keyboard-only run through storyboard steps 1–6\n- [ ] **T8 (P2, human: ~2h / CC: ~5min)** — Motion — Implement D11 with `prefers-reduced-motion` guard\n - Surfaced by: Pass 4 — litmus 6\n - Verify: toggle reduced motion in devtools; numerals render static\n\nJSONL artifact not written: this run was non-interactive with no shell access requested; write it on the next interactive run.\n\n## Design Review Passes\n\nReview mode: non-interactive at the user's instruction. Per-issue AskUserQuestion gates, mockups (Step 0.5), outside voices, and Prior Learnings search were not run. Decisions D1–D13 were applied under that instruction; U1–U2 stay open.\n\n**Step 0.** Initial design completeness 2/10. The plan names features, not what the user sees. A 10 for this plan: hierarchy, every state, viewport behavior, a11y, and tokens, which is what D1–D13 now hold. No DESIGN.md; universal principles applied.\n\n### Pass 1: Information Architecture — 2/10 → 9/10\nWas: four features in a list, hero on top of a tool. Fix: D1, D2. A 10 adds a verified trunk test on the built nav; one tab plus a header is thin wayfinding until `/dashboard/settings` exists.\n\n### Pass 2: Interaction State Coverage — 1/10 → 9/10\nWas: no state named. Fix: D7, with empty states as features. A 10 needs the WebSocket backoff timings and the \"Show more\" end-of-list copy, left to implementation.\n\n### Pass 3: User Journey & Emotional Arc — 1/10 → 8/10\nWas: no user named. Fix: storyboard above, D7 empty states for the discouraged first-timer. A 10 would validate step 5 copy with two real new users and settle U1 so the 5-second visceral read is intentional.\n\n### Pass 4: AI Slop Risk — 2/10 → 9/10\nClassifier: OPERATE. Hard rejections hit in the original: #1 generic card grid as first impression, #7 app UI of stacked cards. Blacklist hits: #1 gradient background, #3 icons as decoration, #11 Tailwind default `system-ui` stack implied by naming no typeface. Litmus on the revised plan: 1 brand unmistakable: NO until U1; 2 one anchor: YES, stats strip; 3 scannable by headings: YES (D12); 4 one job per section: YES; 5 cards necessary: NO, and none used; 6 motion improves hierarchy: YES (D11); 7 premium without shadows: YES (D3 has none). All hard rejections resolved by D1–D4. A 10 requires U1 so litmus 1 flips to YES.\n\n### Pass 5: Design System Alignment — 1/10 → 7/10\nNo DESIGN.md exists; rated on explicit tokens. Was: \"Tailwind\" only. Fix: D3 plus D10. Remaining gap: typeface and accent hue (U1) are the two loudest tokens and both are open, so this pass stays below 8. A 10 is DESIGN.md with YAML front matter and D3 citing its paths.\n\n### Pass 6: Responsive & Accessibility — 0/10 → 9/10\nWas: nothing. Fix: D8, D9. A 10 adds RTL mirroring of the delta glyphs and a screen-reader script for the pill, both cheap once T7 exists.\n\n### Pass 7: Unresolved Design Decisions\n\n| DECISION NEEDED | IF DEFERRED, WHAT HAPPENS |\n|---|---|\n| U1 Typeface and accent hue (no DESIGN.md) | Tailwind ships `system-ui` and a blue-500 button; litmus 1 fails, Pass 4 and 5 capped |\n| U2 Light or dark default, from the use scene | Engineer picks by category (\"dashboards are dark\"), failing the mode-by-category rule |\n\nResolved in this review: D1–D13 (13 decisions). Deferred: U1, U2.\n\n### Completion Summary\n```\n +====================================================================+\n | DESIGN PLAN REVIEW — COMPLETION SUMMARY |\n +====================================================================+\n | System Audit | no DESIGN.md; UI scope: dashboard + settings|\n | Step 0 | 2/10; focus: all 7 passes |\n | Pass 1 (Info Arch) | 2/10 → 9/10 after fixes |\n | Pass 2 (States) | 1/10 → 9/10 after fixes |\n | Pass 3 (Journey) | 1/10 → 8/10 after fixes |\n | Pass 4 (AI Slop) | 2/10 → 9/10 after fixes |\n | Pass 5 (Design Sys) | 1/10 → 7/10 after fixes |\n | Pass 6 (Responsive) | 0/10 → 9/10 after fixes |\n | Pass 7 (Decisions) | 13 resolved, 2 deferred |\n +--------------------------------------------------------------------+\n | NOT in scope | written (4 items) |\n | What already exists | written |\n | TODOS.md updates | 2 items proposed |\n | Approved Mockups | 0 generated, 0 approved |\n | Decisions made | 13 added to plan |\n | Decisions deferred | 2 (listed below) |\n | Overall design score | 0/10 → 7/10 |\n +====================================================================+\n```\nNot design-complete: Pass 5 sits at 7/10 because U1 is open. Run `/design-consultation`, then re-run this review for a quick pass.\n\n### Unresolved Decisions\n- U1 — Typeface and accent hue; needs DESIGN.md or a founder call.\n- U2 — Light or dark default; needs one sentence on who uses this, where, under what light.\n\n## GSTACK REVIEW REPORT\n\n| Review | Trigger | Why | Runs | Status | Findings |\n|--------|---------|-----|------|--------|----------|\n| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | not run | — |\n| Outside Review | none recorded | Independent 2nd opinion | 0 | skipped | — |\n| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | not run | — |\n| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open | score: 0/10 → 7/10, 13 decisions |\n| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | not run | — |\n\n**OUTSIDE COVERAGE:** none. Design outside voices (Codex + Claude subagent) skipped in this non-interactive run; no outside provider completed. Missing coverage, not clean.\n\n**VERDICT:** No reviews CLEAR. Design review has issues open (U1, U2); eng review required.\n\n**UNRESOLVED DECISIONS:**\n- U1 — Typeface and accent hue (no DESIGN.md)\n- U2 — Light or dark default from the use scene\n"
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-design-review-plan-mode/inventory.json b/test/fixtures/detector-corpora/plan-design-review-plan-mode/inventory.json
new file mode 100644
index 000000000..7bfc16c6f
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-design-review-plan-mode/inventory.json
@@ -0,0 +1,77 @@
+[
+ {
+ "run": "37174266054",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "c0a69951bb8e69c8",
+ "artifact": "native-captures-ci-37174266054-1-eval-slices-9",
+ "available": true,
+ "entry": "37174266054-t1-pass.json"
+ },
+ {
+ "run": "37176837432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "c0a69951bb8e69c8",
+ "artifact": "native-captures-ci-37176837432-1-eval-slices-9",
+ "available": true,
+ "entry": null,
+ "reason": "Redundant pass: the summary names interaction state and hierarchy, the same output-term path as 37174266054 on the same series; its stray Write to a mistyped sibling directory never reaches plan.md, which is all the detector reads."
+ },
+ {
+ "run": "37179171083",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "c0a69951bb8e69c8",
+ "artifact": "native-captures-ci-37179171083-1-eval-slices-9",
+ "available": true,
+ "entry": "37179171083-t1-fail.json"
+ },
+ {
+ "run": "37182865432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "5d5f1fccfc7b08e4",
+ "artifact": "native-captures-ci-37182865432-1-eval-slices-9",
+ "available": true,
+ "entry": null,
+ "reason": "Redundant pass: the summary names interaction state, the same output-term path as 37174266054."
+ },
+ {
+ "run": "37186854666",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "6b54ea60e2b7fcd3",
+ "artifact": "native-captures-ci-37186854666-1-eval-slices-9",
+ "available": true,
+ "entry": null,
+ "reason": "Redundant pass: the summary names interaction state and hierarchy, the same output-term path as 37174266054."
+ },
+ {
+ "run": "37193478719",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "6b54ea60e2b7fcd3",
+ "artifact": "native-captures-ci-37193478719-1-eval-slices-9",
+ "available": true,
+ "entry": "37193478719-t1-pass.json"
+ },
+ {
+ "run": "37195203538",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "9eb4635ae1888148",
+ "artifact": "native-captures-ci-37195203538-1-eval-slices-9",
+ "available": true,
+ "entry": "37195203538-t1-pass.json"
+ },
+ {
+ "run": "37198445662",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "9eb4635ae1888148",
+ "artifact": "native-captures-ci-37198445662-1-eval-slices-9",
+ "available": true,
+ "entry": "37198445662-t1-pass.json"
+ }
+]
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37162480720-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37162480720-t1-pass.json
new file mode 100644
index 000000000..86b41776a
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37162480720-t1-pass.json
@@ -0,0 +1,111 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "5553dbf327080983",
+ "source": {
+ "run": "37162480720",
+ "sha": "a818a991fb63699b6af142d5022d781fb418b2ef",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37162480720-1-eval-slices-1/plan-eng-review-1791070951503-gDx2l5"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "54bbb594-bd16-449c-b2ee-724def405c1b",
+ "toolUseId": "toolu_01AGyoSfTck7SrmtMJxU4Qzr",
+ "answeredAt": "2026-10-03T23:45:30.589Z",
+ "answer": "Library hooks + custom backoff function (recommended)",
+ "question": {
+ "question": "D1 — Retry mechanism: reuse the job library's retry hooks or build a custom inline scheduler?\nProject/branch/task: main, \"Add background job retry framework\" plan review; repo holds only PLAN.md, so this is judged on the plan text.\nELI10: The plan rebuilds retry scheduling inside every worker even though the job library already has retry hooks that do the same thing. The stated reason is wanting control over the backoff curve, but libraries in this class let you hand them your own backoff function, so you keep the curve and drop the rebuild. A custom scheduler also has to re-solve things the library already handles: remembering how many attempts happened, surviving a worker crash mid-wait, and showing up in the library's dashboards.\nStakes if we pick wrong: a hand-rolled scheduler means five places where attempt counting or delay math can drift, retries lost when a worker restarts, and a retry framework nobody else on the team recognizes. Picking the library when its hooks turn out to lack a needed feature costs a day of rework, which option C removes.\nRecommendation: A because the plan's only reason to leave the library (\"full control over the curve\") is met by a custom backoff function, so the rebuild buys nothing and costs persistence, observability and five maintenance sites.\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only, approves nothing)\nPros / cons:\nA) Library hooks + custom backoff function (recommended)\n ✅ Attempt count, delay persistence and crash recovery stay library-owned; nothing to re-implement per worker\n ✅ The backoff curve is still fully yours: one function returns the delay for attempt N (human: ~1 day / CC: ~20 min)\n ✅ Finding 2 (five copy-pasted envelopes) disappears: the envelope is config, not worker code\n ❌ Depends on the library's hook API actually accepting a custom backoff function; not verified here because the library is not in this repo\nB) Custom inline scheduler per worker (as planned)\n ✅ Zero dependency on the library's hook surface; works even if its hooks are rigid\n ✅ Lets each worker tune its curve independently without touching queue config\n ❌ Each worker must own attempt tracking, timers and restart recovery; a crash mid-backoff silently drops the retry unless you also build persistence (human: ~3 days / CC: ~1 h)\n ❌ Keeps the five-copy duplication alive and puts the retry path outside the library's metrics and dashboards\nC) Investigate the hook API first, then decide\n ✅ Replaces the unverified assumption in A with a 5-minute read of the library's retry/backoff hook docs or source\n ✅ Costs almost nothing and makes the A/B call on evidence instead of plan text\n ❌ Defers the decision; nothing in this plan can be approved until the read-back is done\nNet: trade an unverified hook-API assumption (A) against a known rebuild of persistence, observability and five maintenance sites (B); C buys the evidence for a few minutes.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hooks + custom backoff function (recommended)",
+ "description": "Reuse the job library's built-in retry hooks; supply one custom backoff function for the curve. Library owns attempt count, delay persistence and crash recovery. Makes the five-copy envelope duplication moot. Human ~1 day / CC ~20 min. Risk: assumes the hook API accepts a custom backoff function (unverified, library not in repo). Completeness 9/10."
+ },
+ {
+ "label": "Custom inline scheduler per worker (as planned)",
+ "description": "Build the exponential-backoff scheduler inline in each of ~5 workers as written in PLAN.md:7-9. Each worker owns attempt tracking, timers and restart recovery; retry path sits outside library metrics. Keeps the five copies pending a separate dedupe decision. Human ~3 days / CC ~1 h. Completeness 5/10."
+ },
+ {
+ "label": "Investigate the hook API first",
+ "description": "Bounded read of the library's retry/backoff hook API (docs or source, human ~1 h / CC ~5 min) before choosing. Approves nothing; R1 and every dependent row stay pending until the read-back is reported."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "54bbb594-bd16-449c-b2ee-724def405c1b",
+ "toolUseId": "toolu_01HvPRvQBD8KTcgtMQpwKVU1",
+ "answeredAt": "2026-10-03T23:46:55.937Z",
+ "answer": "At-least-once with idempotency key (recommended)",
+ "question": {
+ "question": "D2 — Webhook delivery: what guarantee do receivers get once `processWebhookJob()` can retry?\nProject/branch/task: main, \"Add background job retry framework\"; R1 settled on the library's retry hooks, now the webhook flow's semantics.\nELI10: Today a webhook is sent at most once: if it fails, it is gone, but a receiver never sees the same event twice. The moment you add retries, a request that timed out after reaching the receiver gets sent again, and the receiver sees a duplicate. You have to pick on purpose: embrace duplicates and give receivers a key to spot them, only retry when you are sure the request never left, or keep webhooks out of the retry framework entirely.\nStakes if we pick wrong: silently flipping to at-least-once means customers' systems double-charge, double-notify or double-create records with no way to detect it. Keeping at-most-once means the common failure (a 5xx or a slow receiver) still loses the event.\nRecommendation: A because at-least-once with a documented idempotency key is the standard webhook contract and the only option that both recovers lost deliveries and lets receivers protect themselves.\nCompleteness: A=9/10, B=6/10, C=4/10\nPros / cons:\nA) At-least-once with idempotency key (recommended)\n ✅ Recovers deliveries lost to 5xx and read timeouts, which is most of what a retry framework exists for\n ✅ Receivers get a stable key per event and a documented \"may deliver more than once\" contract they can dedupe on (human: ~1.5 days / CC: ~30 min)\n ❌ Changes the receiver-facing contract; any receiver that does not dedupe will see duplicates until they do\nB) Retry only pre-send failures\n ✅ Preserves the current at-most-once guarantee exactly; no receiver sees a duplicate\n ✅ Still recovers the cheap failures (connection refused, DNS, TLS handshake) with no contract change (human: ~1 day / CC: ~20 min)\n ❌ The most common failures (5xx, slow receiver) stay lost, and classifying \"provably never sent\" per HTTP client is brittle\nC) Exclude the webhook flow from retries\n ✅ Zero contract risk and almost zero work; the other four workers still get retries (human: ~0 / CC: ~2 min)\n ✅ Buys time to decide the webhook contract with receivers separately\n ❌ The webhook flow gets no reliability gain from this change, and the plan's rewrite of `processWebhookJob()` would need to be dropped\nNet: trade a documented contract change that receivers must honor (A) against preserving at-most-once at the cost of still losing the common failures (B, C).",
+ "header": "Webhook delivery",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "At-least-once with idempotency key (recommended)",
+ "description": "Webhook retries enabled through the library hooks; every delivery carries a stable idempotency key (job id + event id) header and each attempt is recorded (attempt number, response). Receiver-facing docs updated to \"at-least-once, dedupe on the key\". Human ~1.5 days / CC ~30 min. Completeness 9/10. R3 regression contract stays pending."
+ },
+ {
+ "label": "Retry only pre-send failures",
+ "description": "Retry only when the request provably never left (connection refused, DNS, TLS handshake failure); read timeouts and 5xx are terminal. Preserves at-most-once. Each attempt recorded. Human ~1 day / CC ~20 min. Completeness 6/10: 5xx and slow receivers still lose the event. R3 stays pending."
+ },
+ {
+ "label": "Exclude the webhook flow from retries",
+ "description": "Keep `processWebhookJob()` on its current no-retry path; the other four workers adopt the library hooks. No contract change, no reliability gain for webhooks. Human ~0 / CC ~2 min. Completeness 4/10. R3 stays pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "54bbb594-bd16-449c-b2ee-724def405c1b",
+ "toolUseId": "toolu_01H8or2ZvqSURzutGrFcrb2p",
+ "answeredAt": "2026-10-03T23:47:48.921Z",
+ "answer": "Full jitter (recommended)",
+ "question": {
+ "question": "D3 — Jitter: randomize retry delays, or keep the pure exponential curve?\nProject/branch/task: main, \"Add background job retry framework\"; the curve now lives in one custom backoff function supplied to the library hooks (D1).\nELI10: If a downstream service goes down for a minute, hundreds of jobs fail at the same moment. With a pure exponential curve they all wake up and retry at the same instants, hammering the service just as it recovers. Jitter adds randomness to each delay so the retries spread out instead of arriving as a wave.\nStakes if we pick wrong: without jitter the first retry wave after an outage can knock the dependency over again and extend the incident; with jitter the only cost is that a given retry may fire a little earlier or later than the textbook curve.\nRecommendation: A because full jitter is the documented safe default for background jobs, it is a one-line change inside the backoff function, and every real-world retry-storm bug found in research traced back to its absence.\nCompleteness: A=9/10, B=8/10, C=4/10\nPros / cons:\nA) Full jitter (recommended)\n ✅ Spreads retries across the whole window, the strongest protection against a lockstep wave after a shared outage\n ✅ One line inside the custom backoff function; no new state, no new dependency (human: ~1 h incl. test / CC: ~3 min)\n ❌ A retry can fire almost immediately after a failure (random lower bound is 0), which can look surprising in logs\nB) Equal jitter\n ✅ Guarantees a minimum wait of half the computed delay, so no retry fires \"too soon\"\n ✅ Still breaks up the lockstep wave, just over half the window (human: ~1 h incl. test / CC: ~3 min)\n ❌ Weaker spread than full jitter; only worth it if a hard minimum wait is a real requirement here, and none is stated\nC) No jitter (as planned)\n ✅ Delays are exactly predictable, which makes the curve easy to reason about and test\n ✅ Zero extra code beyond the curve itself\n ❌ Every job that failed in the same outage retries at the same instants; this is the classic retry-storm bug reported against real queue libraries\nNet: a few minutes of code (A or B) against a known outage amplifier (C); A unless a hard minimum wait is a stated requirement.",
+ "header": "Jitter",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Full jitter (recommended)",
+ "description": "Custom backoff function returns random(0, base * 2^attempt). One line plus a test asserting delays stay within [0, computed]. Human ~1 h / CC ~3 min. Completeness 9/10. R6 cap, R7 max attempts/dead-letter stay pending."
+ },
+ {
+ "label": "Equal jitter",
+ "description": "Custom backoff function returns computed/2 + random(0, computed/2). Guaranteed minimum wait. One line plus a bounds test. Human ~1 h / CC ~3 min. Completeness 8/10. R6, R7 stay pending."
+ },
+ {
+ "label": "No jitter (as planned)",
+ "description": "Custom backoff function returns base * 2^attempt exactly. Lockstep retries after a shared outage are accepted. Human ~0 / CC ~0. Completeness 4/10. R6, R7 stay pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-03T23:45:08.602Z",
+ "write": "# Eng review: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in `` (branch `main`, commit c03143b), titled \"Plan: Add background job retry framework\".\nReport file: this file, at the path the user requested.\nReviewer: Claude (/plan-eng-review), session 689-1791070937-5a52bce9, 2026-10-03.\n\n## Original plan (unchanged copy of PLAN.md)\n\n```markdown\nProceed directly to the requested engineering review; skip the optional /office-hours prerequisite.\nPlease review this plan thoroughly. As you go, write your plan-mode plan to (use Edit/Write to that exact path).\n\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Review preparation record\n\n- Design doc: none found. /office-hours prerequisite skipped at the user's explicit request.\n- Repo evidence: the repository contains only `PLAN.md` and `CLAUDE.md`. No worker files, no `processWebhookJob()`, no job library config, no tests are checked in. Every code-level claim below is calibrated against the plan text only; runtime evidence is `unknown` throughout.\n- Retrospective history: `git log -- PLAN.md` shows one commit (c03143b \"Seed review plan\"); no reverts. Worker files and `processWebhookJob()` are named by the plan but have no path here: `not available`.\n- Test framework: unknown (no runtime markers, 0 test files).\n- TODOS.md: absent.\n- Prior learnings: cross-project learnings set to `false`; project-scoped search returned none.\n- Web research: Aside unavailable; one host WebSearch ran (read-only, untrusted). Takeaways: mainstream job libraries (BullMQ is the documented example) accept a custom backoff strategy function, so \"full control over the curve\" does not require leaving the library's retry hooks; pure exponential backoff without jitter produces lockstep retry storms during a provider outage (reported as real bugs in two queue projects); full or decorrelated jitter plus a delay cap and a dead-letter path are the baseline pattern.\n- Outside Voice: `codex_reviews` is `disabled`; the outside-voice step will be recorded as disabled, not replaced.\n\n## Scope Challenge\n\n### A. Assessment\n\n**What already solves each sub-problem?**\n- Retry scheduling, attempt counting, delay persistence: the plan itself says the existing job library has built-in retry hooks and that the custom scheduler is the \"same shape as the library version\" (PLAN.md:7-9). [Layer 1] reuse candidate. The library's hooks were not inspected (not in repo); label: plan-stated interface, unverified.\n- Backoff curve: libraries in this class take a custom backoff function. The plan's only stated reason to leave the library (\"full control over the curve\") is satisfiable inside the hooks. Assumption, not verified against the actual library.\n- Retry envelope (compute delay, log attempt, dispatch): if the library owns retries, the envelope ceases to exist in worker code. Otherwise it is one small helper, not five copies.\n\n**Minimum change to achieve the goal:** configure the library's retry hooks per queue (attempts, backoff function, jitter, cap, dead-letter), add attempt logging in one place, protect the webhook flow's delivery semantics with a test. The custom scheduler and the five copies are deferrable without blocking anything.\n\n**Complexity check (estimates, plan-stated):** ~5 worker files + the `processWebhookJob()` flow (likely one of the 5, counted once) = ~5-6 changed files; 0 new classes/services (scheduler is inline). Below the 8-file / 2-class gate, so B's selectors are skipped.\n\n**Search check:** done above (WebSearch). Recommendation labels: library hooks [Layer 1]; custom inline scheduler departs from standard practice without a stated reason the library cannot meet.\n\n**TODOS cross-reference:** no TODOS.md.\n\n**Completeness check:** the plan explicitly cuts three corners (duplication, regression test, graph recompute). Each saves CC minutes, not days. Completeness is cheap here.\n\n**Distribution check:** no new artifact; internal worker change only.\n\n### B. Complexity selectors\n\nSkipped: fewer than 8 files and fewer than 2 new classes/services.\n\n### C. Findings\n\n1. `[P1] (confidence: 8/10) PLAN.md:7-9 — Rebuilds retry scheduling the job library already provides (\"Same shape as the library version\"). Custom inline scheduler must re-own attempt persistence, delay timers, crash recovery and observability per worker. Reviewer: Claude.` Motivating lines: \"We'll roll a custom exponential-backoff scheduler inline in each worker / rather than use the existing job library's built-in retry hooks. Same / shape as the library version, but we want full control over the curve.\"\n2. `[P2] (confidence: 8/10) PLAN.md:12-14 — Retry envelope copy-pasted across 5 workers; a bug in delay math or attempt logging has to be fixed five times. Reviewer: Claude.` Motivating lines: \"The retry envelope (compute delay, log attempt, dispatch) is duplicated / across 5 worker files with copy-pasted bodies.\" Depends on finding 1: resolves itself if the library owns retries.\n\nFindings surfaced here but owned by later sections (not resolved in Scope Challenge): webhook at-most-once guarantee under retry (Architecture + Tests, PLAN.md:17-19); missing jitter / cap / max attempts / dead-letter (Architecture, PLAN.md:7-9); payload re-fetch and graph recompute per retry (Performance, PLAN.md:22-24).\n\nScope Challenge result: pending R1.\n\n## Decision ledger\n\n### R1: Retry mechanism — library built-in hooks vs custom inline scheduler\nFinding: Scope Challenge #1, P1, confidence 8/10, PLAN.md:7-9, reviewer Claude\nPlan baseline: original proposal — custom exponential-backoff scheduler inline in each of ~5 workers, bypassing the library's retry hooks (PLAN.md:7-9). Nothing approved yet.\nRuntime evidence: unknown. No worker source, library config or hook API is in the repo. The plan states the library has built-in retry hooks (plan-stated interface, unverified).\nComparison grid:\n\n| Choice | Current | A) Library hooks + custom backoff fn | B) Custom inline scheduler (as planned) | C) Investigate hook API first |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker, proposed | library's retry hooks own scheduling, attempt count and persistence; one custom backoff function supplies the curve | custom scheduler inline in each worker, as written | pending; bounded read of the library's retry hook API (human ~1h / CC ~5 min), no implementation |\n| Backoff curve control (\"full control over the curve\") | required by plan | retained: the custom backoff function returns any delay | retained | pending |\n| Attempt state across worker crash/restart | unspecified | library-owned (plan-stated capability, unverified) | must be built per worker | pending |\n| R2 webhook delivery semantics under retry | unspecified, pending | pending | pending | pending |\n| R3 regression contract for at-most-once webhook flow | none planned, pending | pending | pending | pending |\n| R4 retry envelope deduplication (5 copies) | 5 copy-pasted bodies, pending | becomes moot: envelope lives in library config, not worker code | pending | pending |\n| R5 jitter | unspecified, pending | pending | pending | pending |\n| R6 delay cap / max attempts / dead-letter | unspecified, pending | pending | pending | pending |\n| R7 dependency-graph caching on retry | not planned, pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Retry mechanism: reuse the job library's retry hooks or build a custom inline scheduler?\nProject/branch/task: main, \"Add background job retry framework\" plan review; repo holds only PLAN.md, so this is judged on the plan text.\nELI10: The plan rebuilds retry scheduling inside every worker even though the job library already has retry hooks that do the same thing. The stated reason is wanting control over the backoff curve, but libraries in this class let you hand them your own backoff function, so you keep the curve and drop the rebuild. A custom scheduler also has to re-solve things the library already handles: remembering how many attempts happened, surviving a worker crash mid-wait, and showing up in the library's dashboards.\nStakes if we pick wrong: a hand-rolled scheduler means five places where attempt counting or delay math can drift, retries lost when a worker restarts, and a retry framework nobody else on the team recognizes. Picking the library when its hooks turn out to lack a needed feature costs a day of rework, which option C removes.\nRecommendation: A because the plan's only reason to leave the library (\"full control over the curve\") is met by a custom backoff function, so the rebuild buys nothing and costs persistence, observability and five maintenance sites.\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only, approves nothing)\nPros / cons:\nA) Library hooks + custom backoff function (recommended)\n ✅ Attempt count, delay persistence and crash recovery stay library-owned; nothing to re-implement per worker\n ✅ The backoff curve is still fully yours: one function returns the delay for attempt N (human: ~1 day / CC: ~20 min)\n ✅ Finding 2 (five copy-pasted envelopes) disappears: the envelope is config, not worker code\n ❌ Depends on the library's hook API actually accepting a custom backoff function; not verified here because the library is not in this repo\nB) Custom inline scheduler per worker (as planned)\n ✅ Zero dependency on the library's hook surface; works even if its hooks are rigid\n ✅ Lets each worker tune its curve independently without touching queue config\n ❌ Each worker must own attempt tracking, timers and restart recovery; a crash mid-backoff silently drops the retry unless you also build persistence (human: ~3 days / CC: ~1 h)\n ❌ Keeps the five-copy duplication alive and puts the retry path outside the library's metrics and dashboards\nC) Investigate the hook API first, then decide\n ✅ Replaces the unverified assumption in A with a 5-minute read of the library's retry/backoff hook docs or source\n ✅ Costs almost nothing and makes the A/B call on evidence instead of plan text\n ❌ Defers the decision; nothing in this plan can be approved until the read-back is done\nNet: trade an unverified hook-API assumption (A) against a known rebuild of persistence, observability and five maintenance sites (B); C buys the evidence for a few minutes.\nHeader: Retry mechanism\nOptions:\nA) Library hooks + custom backoff function (recommended)\nReuse the job library's built-in retry hooks; supply one custom backoff function for the curve. Library owns attempt count, delay persistence and crash recovery. Makes the five-copy envelope duplication moot. Human ~1 day / CC ~20 min. Risk: assumes the hook API accepts a custom backoff function (unverified, library not in repo). Completeness 9/10.\nB) Custom inline scheduler per worker (as planned)\nBuild the exponential-backoff scheduler inline in each of ~5 workers as written in PLAN.md:7-9. Each worker owns attempt tracking, timers and restart recovery; retry path sits outside library metrics. Keeps the five copies pending a separate dedupe decision. Human ~3 days / CC ~1 h. Completeness 5/10.\nC) Investigate the hook API first\nBounded read of the library's retry/backoff hook API (docs or source, human ~1 h / CC ~5 min) before choosing. Approves nothing; R1 and every dependent row stay pending until the read-back is reported.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-03T23:46:32.676Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hooks + custom backoff function — D1 answer, 2026-10-03\nAccepted scope: Replace the custom inline per-worker scheduler (PLAN.md:7-9) with the job library's built-in retry hooks; supply one custom backoff function for the curve; library owns attempt count, delay persistence and crash recovery. R4 (five-copy envelope dedupe) is moot under this answer: the envelope becomes queue/hook configuration, not worker code. Carried pending: R2, R3, R5, R6, R7 and later rows. Assumption to confirm at implementation: the library's hook API accepts a custom backoff function.\nHistory: none\n\n### R2: Webhook delivery semantics once `processWebhookJob()` gains retries\nFinding: Architecture #1, P1, confidence 8/10, PLAN.md:17-19, reviewer Claude. Motivating lines: \"The existing `processWebhookJob()` flow gets rewritten as part of this / change. No regression test for the prior at-most-once delivery guarantee / is planned.\"\nPlan baseline: original proposal — the webhook flow is rewritten into the retry framework; its current at-most-once guarantee is named but no decision is made about what replaces it. R1 approved (library hooks) governs the mechanism; semantics still unspecified.\nRuntime evidence: unknown. `processWebhookJob()` is not in the repo; whether it records a send before the HTTP call, or what receivers rely on, cannot be checked here.\nComparison grid:\n\n| Choice | Current | A) At-least-once with idempotency key | B) Retry only pre-send failures | C) Exclude webhook flow from retries |\n|---|---|---|---|---|\n| R2 delivery guarantee receivers get | at-most-once (plan-stated), under rewrite with no replacement guarantee named | at-least-once; each delivery carries a stable idempotency key (job id + event id) so receivers can dedupe | at-most-once preserved: retry only when the request provably never left (connection refused, DNS, TLS handshake); post-send ambiguity (read timeout, 5xx) is terminal | at-most-once preserved by keeping `processWebhookJob()` on the current no-retry path |\n| Duplicate deliveries to receivers | none today | possible; deduplicable via key | none | none |\n| Lost deliveries on 5xx / read timeout | lost (no retry today) | retried | still lost | still lost |\n| Receiver-facing contract change | none | yes: documented key header + \"may deliver more than once\" | none | none |\n| Per-attempt delivery record (attempt no., response) | unspecified | yes, part of this behavior | yes, part of this behavior | no change |\n| R1 mechanism | approved D1 | library hooks, unchanged | library hooks, unchanged | library hooks for the other 4 workers, unchanged |\n| R3 regression contract for the webhook flow | pending | pending | pending | pending |\n| R5 jitter, R6 delay cap, R7 max attempts/dead-letter, R8 graph caching | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — Webhook delivery: what guarantee do receivers get once `processWebhookJob()` can retry?\nProject/branch/task: main, \"Add background job retry framework\"; R1 settled on the library's retry hooks, now the webhook flow's semantics.\nELI10: Today a webhook is sent at most once: if it fails, it is gone, but a receiver never sees the same event twice. The moment you add retries, a request that timed out after reaching the receiver gets sent again, and the receiver sees a duplicate. You have to pick on purpose: embrace duplicates and give receivers a key to spot them, only retry when you are sure the request never left, or keep webhooks out of the retry framework entirely.\nStakes if we pick wrong: silently flipping to at-least-once means customers' systems double-charge, double-notify or double-create records with no way to detect it. Keeping at-most-once means the common failure (a 5xx or a slow receiver) still loses the event.\nRecommendation: A because at-least-once with a documented idempotency key is the standard webhook contract and the only option that both recovers lost deliveries and lets receivers protect themselves.\nCompleteness: A=9/10, B=6/10, C=4/10\nPros / cons:\nA) At-least-once with idempotency key (recommended)\n ✅ Recovers deliveries lost to 5xx and read timeouts, which is most of what a retry framework exists for\n ✅ Receivers get a stable key per event and a documented \"may deliver more than once\" contract they can dedupe on (human: ~1.5 days / CC: ~30 min)\n ❌ Changes the receiver-facing contract; any receiver that does not dedupe will see duplicates until they do\nB) Retry only pre-send failures\n ✅ Preserves the current at-most-once guarantee exactly; no receiver sees a duplicate\n ✅ Still recovers the cheap failures (connection refused, DNS, TLS handshake) with no contract change (human: ~1 day / CC: ~20 min)\n ❌ The most common failures (5xx, slow receiver) stay lost, and classifying \"provably never sent\" per HTTP client is brittle\nC) Exclude the webhook flow from retries\n ✅ Zero contract risk and almost zero work; the other four workers still get retries (human: ~0 / CC: ~2 min)\n ✅ Buys time to decide the webhook contract with receivers separately\n ❌ The webhook flow gets no reliability gain from this change, and the plan's rewrite of `processWebhookJob()` would need to be dropped\nNet: trade a documented contract change that receivers must honor (A) against preserving at-most-once at the cost of still losing the common failures (B, C).\nHeader: Webhook delivery\nOptions:\nA) At-least-once with idempotency key (recommended)\nWebhook retries enabled through the library hooks; every delivery carries a stable idempotency key (job id + event id) header and each attempt is recorded (attempt number, response). Receiver-facing docs updated to \"at-least-once, dedupe on the key\". Human ~1.5 days / CC ~30 min. Completeness 9/10. R3 regression contract stays pending.\nB) Retry only pre-send failures\nRetry only when the request provably never left (connection refused, DNS, TLS handshake failure); read timeouts and 5xx are terminal. Preserves at-most-once. Each attempt recorded. Human ~1 day / CC ~20 min. Completeness 6/10: 5xx and slow receivers still lose the event. R3 stays pending.\nC) Exclude the webhook flow from retries\nKeep `processWebhookJob()` on its current no-retry path; the other four workers adopt the library hooks. No contract change, no reliability gain for webhooks. Human ~0 / CC ~2 min. Completeness 4/10. R3 stays pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-03T23:47:31.675Z",
+ "old": "Keep `processWebhookJob()` on its current no-retry path; the other four workers adopt the library hooks. No contract change, no reliability gain for webhooks. Human ~0 / CC ~2 min. Completeness 4/10. R3 stays pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Keep `processWebhookJob()` on its current no-retry path; the other four workers adopt the library hooks. No contract change, no reliability gain for webhooks. Human ~0 / CC ~2 min. Completeness 4/10. R3 stays pending.\n\nState: approved\nActual answer: A) At-least-once with idempotency key — D2 answer, 2026-10-03\nAccepted scope: `processWebhookJob()` retries through the library hooks (R1). Every delivery carries a stable idempotency key (job id + event id) header; each attempt is recorded (attempt number, response). Receiver-facing documentation updated to \"at-least-once, dedupe on the key\". Intentional difference from today: at-most-once is replaced by at-least-once. Carried pending: R3 (regression contract for the webhook flow), R5, R6, R7, R8.\nHistory: none\n\n### R5: Jitter on retry delays\nFinding: Architecture #2, P2, confidence 8/10, PLAN.md:7-9, reviewer Claude. Motivating lines: \"We'll roll a custom exponential-backoff scheduler ... we want full control over the curve.\" No jitter is mentioned anywhere in the plan.\nPlan baseline: original proposal — pure exponential curve, jitter unspecified. R1 approved: the curve is supplied by one custom backoff function in the library hooks.\nRuntime evidence: unknown (no backoff code in repo). Web research: two queue projects filed real bugs for lockstep retries after a provider outage because their exponential backoff had no jitter.\nComparison grid:\n\n| Choice | Current | A) Full jitter | B) Equal jitter | C) No jitter |\n|---|---|---|---|---|\n| R5 jitter | unspecified, pending | delay = random(0, base * 2^attempt) | delay = half fixed + half random of base * 2^attempt | delay = base * 2^attempt exactly, as planned |\n| Lockstep retry storm after a shared outage | unmitigated | spread across the full window | spread across half the window, guaranteed minimum wait | all failed jobs retry at the same instants |\n| Where it lives | n/a | inside the one custom backoff function (R1) | inside the one custom backoff function (R1) | inside the one custom backoff function (R1) |\n| R1 mechanism, R2 webhook semantics | approved D1, D2 | unchanged | unchanged | unchanged |\n| R3 regression contract, R6 delay cap, R7 max attempts/dead-letter, R8 graph caching | pending | pending | pending | pending |\n\nQuestion D3:\nD3 — Jitter: randomize retry delays, or keep the pure exponential curve?\nProject/branch/task: main, \"Add background job retry framework\"; the curve now lives in one custom backoff function supplied to the library hooks (D1).\nELI10: If a downstream service goes down for a minute, hundreds of jobs fail at the same moment. With a pure exponential curve they all wake up and retry at the same instants, hammering the service just as it recovers. Jitter adds randomness to each delay so the retries spread out instead of arriving as a wave.\nStakes if we pick wrong: without jitter the first retry wave after an outage can knock the dependency over again and extend the incident; with jitter the only cost is that a given retry may fire a little earlier or later than the textbook curve.\nRecommendation: A because full jitter is the documented safe default for background jobs, it is a one-line change inside the backoff function, and every real-world retry-storm bug found in research traced back to its absence.\nCompleteness: A=9/10, B=8/10, C=4/10\nPros / cons:\nA) Full jitter (recommended)\n ✅ Spreads retries across the whole window, the strongest protection against a lockstep wave after a shared outage\n ✅ One line inside the custom backoff function; no new state, no new dependency (human: ~1 h incl. test / CC: ~3 min)\n ❌ A retry can fire almost immediately after a failure (random lower bound is 0), which can look surprising in logs\nB) Equal jitter\n ✅ Guarantees a minimum wait of half the computed delay, so no retry fires \"too soon\"\n ✅ Still breaks up the lockstep wave, just over half the window (human: ~1 h incl. test / CC: ~3 min)\n ❌ Weaker spread than full jitter; only worth it if a hard minimum wait is a real requirement here, and none is stated\nC) No jitter (as planned)\n ✅ Delays are exactly predictable, which makes the curve easy to reason about and test\n ✅ Zero extra code beyond the curve itself\n ❌ Every job that failed in the same outage retries at the same instants; this is the classic retry-storm bug reported against real queue libraries\nNet: a few minutes of code (A or B) against a known outage amplifier (C); A unless a hard minimum wait is a stated requirement.\nHeader: Jitter\nOptions:\nA) Full jitter (recommended)\nCustom backoff function returns random(0, base * 2^attempt). One line plus a test asserting delays stay within [0, computed]. Human ~1 h / CC ~3 min. Completeness 9/10. R6 cap, R7 max attempts/dead-letter stay pending.\nB) Equal jitter\nCustom backoff function returns computed/2 + random(0, computed/2). Guaranteed minimum wait. One line plus a bounds test. Human ~1 h / CC ~3 min. Completeness 8/10. R6, R7 stay pending.\nC) No jitter (as planned)\nCustom backoff function returns base * 2^attempt exactly. Lockstep retries after a shared outage are accepted. Human ~0 / CC ~0. Completeness 4/10. R6, R7 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37174266054-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37174266054-t1-pass.json
new file mode 100644
index 000000000..487f1ae16
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37174266054-t1-pass.json
@@ -0,0 +1,116 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "57a825b033078a7b",
+ "source": {
+ "run": "37174266054",
+ "sha": "a8ace39030dd92745b45ac16e5d733e63c936c0f",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37174266054-1-eval-slices-1/plan-eng-review-1791084772101-4G6gCj"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "dd4cbb83-123f-42d0-b789-0c217651632d",
+ "toolUseId": "toolu_01Jy4XCdUXYNhyhFhgDtRCTe",
+ "answeredAt": "2026-10-04T03:35:21.281Z",
+ "answer": "Library hook + custom backoff (recommended)",
+ "question": {
+ "question": "D1 — Retry mechanism: library retry hooks or a custom inline scheduler?\nProject/branch/task: main — plan \"Add background job retry framework\" (PLAN.md).\nELI10: Your job library already knows how to retry a failed job later. The plan rebuilds that machinery by hand inside each of 5 workers because you want to control the delay curve. Almost every job library lets you hand it your own backoff function, so you can own the curve without owning the scheduler. Hand-rolled schedulers are where lost jobs, double-runs and 3am retry storms come from.\nStakes if we pick wrong: a bug in the delay math or dispatch gets copied into 5 places, and failed jobs either vanish silently or hammer a downstream service in lockstep.\nRecommendation: A because the library hook gives you the curve you want and keeps attempt tracking, persistence and dispatch in code that already runs in production.\nCompleteness: A=9/10, B=6/10, C=4/10\nPros / cons:\nA) Library hook + custom backoff function (recommended) [Layer 1] (human: ~1 day / CC: ~20 min)\n ✅ Attempt counting, delay persistence and dispatch stay in battle-tested library code used by many teams\n ✅ The curve becomes one small pure function that is trivially unit-testable and shared by all 5 workers\n ❌ If the hook truly cannot express the desired curve you need a thin wrapper or an upstream patch; verify first\nB) Custom inline scheduler as planned (human: ~3-4 days / CC: ~1 h)\n ✅ Total control over delay, logging and dispatch timing with no library constraints at all\n ✅ Curve changes never wait on a library release or its configuration surface\n ❌ Re-implements attempt persistence and dispatch five times; every scheduling bug has a 5x blast radius and needs its own scheduler tests\nC) Investigate: 1-hour spike to confirm the hook can express the curve; R1 stays pending (human: ~1 h / CC: ~5 min)\n ✅ Removes the single unknown (hook expressiveness) before any code is written\n ✅ Cheap: read the library's retry docs and run one failing test job through its hook\n ❌ Nothing else in the plan can be locked until this returns; approves no implementation\nNet: owning the curve does not require owning the scheduler; the trade is library constraints versus a 5x hand-rolled blast radius.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hook + custom backoff (recommended)",
+ "description": "Plug one backoff function into the job library's existing retry hook; delete the planned inline scheduler. Attempt counting, persistence and dispatch stay in the library. Human ~1 day / CC ~20 min. Completeness 9/10. Risk: hook may not express the exact curve; verify in docs first. [Layer 1]"
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Build the exponential-backoff scheduler by hand inside each worker as the plan states. Full control over curve, logging and dispatch. Human ~3-4 days / CC ~1 h. Completeness 6/10. Risk: re-implements persistence and dispatch five times; scheduler bugs have a 5x blast radius and need dedicated tests."
+ },
+ {
+ "label": "Investigate hook first",
+ "description": "Bounded 1-hour spike: read the library's retry docs and push one failing job through its hook to confirm the curve is expressible. R1 stays pending; approves no implementation. Human ~1 h / CC ~5 min. Completeness 4/10."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "dd4cbb83-123f-42d0-b789-0c217651632d",
+ "toolUseId": "toolu_01Xh21vyj1n4igxYL95Smfwn",
+ "answeredAt": "2026-10-04T03:37:02.866Z",
+ "answer": "At-least-once + idempotency key (recommended)",
+ "question": {
+ "question": "D2 — Webhook retries: at-least-once with idempotency key, or keep at-most-once?\nProject/branch/task: main — plan \"Add background job retry framework\" (PLAN.md), webhook path.\nELI10: Today the webhook job promises \"at most once\": a delivery can be lost but is never duplicated. Retries flip that promise. A webhook that timed out after the receiver already handled it will be sent again. Receivers that charge a card or send an email on each webhook will do it twice unless every event carries a stable idempotency key they can dedupe on. You have to pick which promise you keep and write it down; Section 3 then protects it with a test.\nStakes if we pick wrong: silent duplicate webhooks to customers' endpoints (double charges, double emails), or no retry benefit on the path most likely to fail.\nRecommendation: A because retries without an idempotency key are duplicates by design, and at-least-once plus a stable event id is the contract every major webhook provider ships.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once + idempotency key per event (recommended) (human: ~1-2 days / CC: ~30 min)\n ✅ Failed deliveries are actually recovered, which is the whole point of the retry framework\n ✅ A stable event id persisted before the first attempt lets receivers dedupe; matches Stripe/GitHub webhook contracts\n ❌ Changes the documented guarantee; receivers must be told to dedupe, and the id must survive the payload re-fetch\nB) Keep at-most-once: webhook job opts out of retries (human: ~2 h / CC: ~10 min)\n ✅ Zero duplicate risk; existing receiver behavior is untouched\n ✅ Small change: max attempts 1 for this job type plus a recorded-failure row for manual replay\n ❌ Webhook failures stay unrecovered; operators replay by hand\nC) Retry webhooks with no idempotency key, as the plan reads (human: ~0 / CC: ~0)\n ✅ No extra work beyond the framework itself\n ✅ No receiver coordination needed\n ❌ Guaranteed duplicates on every timeout-after-success; the at-most-once promise breaks silently with nothing telling you\nNet: you are choosing between recovering failed webhooks (and asking receivers to dedupe) or keeping them un-duplicated (and un-recovered).",
+ "header": "Webhook guarantee",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "At-least-once + idempotency key (recommended)",
+ "description": "Webhook retries enabled under the library hook. Each webhook event gets a stable idempotency id persisted before the first attempt and sent as a header; the guarantee is documented as at-least-once. Human ~1-2 days / CC ~30 min. Completeness 9/10. Risk: receivers must dedupe; id must survive the payload re-fetch."
+ },
+ {
+ "label": "Keep at-most-once (opt out)",
+ "description": "Webhook job type is excluded from retries (max attempts 1). Failures are recorded for manual replay. Existing guarantee untouched. Human ~2 h / CC ~10 min. Completeness 7/10. Risk: webhook failures remain unrecovered."
+ },
+ {
+ "label": "Retry with no idempotency key",
+ "description": "Plan as written: webhook retried like any job, no event id, guarantee undocumented. Human ~0 / CC ~0. Completeness 3/10. Risk: silent duplicates on every timeout-after-success."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "dd4cbb83-123f-42d0-b789-0c217651632d",
+ "toolUseId": "toolu_01WE9ouMGiiYz1BFJJmxfZg8",
+ "answeredAt": "2026-10-04T03:37:56.630Z",
+ "answer": "Cap + dead-letter + alert (recommended)",
+ "question": {
+ "question": "D3 — Bounded retries: cap attempts and dead-letter exhausted jobs, or leave it to the library default?\nProject/branch/task: main — plan \"Add background job retry framework\" (PLAN.md), all 5 workers.\nELI10: Some jobs will never succeed: a bad payload, a customer endpoint that is gone for good. Without an explicit cap they retry until the curve stretches into days, and without a parking spot they are either dropped silently or sit in the queue forever. The fix is boring: a per-job-type attempt cap, and a dead-letter place where exhausted jobs land with an alert so a human sees them.\nStakes if we pick wrong: poison jobs burn worker capacity for days, or real customer jobs vanish with no record and no alert.\nRecommendation: A because the library hook (D1) makes the cap one config value and the dead-letter one handler, and it turns a silent loss into a visible, replayable one.\nCompleteness: A=9/10, B=6/10, C=3/10\nPros / cons:\nA) Explicit cap (default 5, per job type) + dead-letter with alert (recommended) (human: ~half day / CC: ~15 min)\n ✅ Exhausted jobs are preserved with their payload and error for inspection and manual replay\n ✅ An alert on dead-letter depth turns a silent failure into an operator signal within minutes\n ❌ One more queue/set to monitor and to drain; needs a replay path or it fills up quietly\nB) Explicit cap (default 5), exhausted jobs logged as failed only (human: ~1 h / CC: ~5 min)\n ✅ Bounds the retry storm with a single config value per job type\n ✅ No new queue or alert plumbing to maintain\n ❌ Exhausted jobs are gone after the log line; no replay and nobody is paged\nC) Library default cap, no explicit policy, as the plan reads (human: ~0 / CC: ~0)\n ✅ Zero work; the library's default behavior applies\n ✅ Fewer decisions to document now\n ❌ You do not know today what the default is or where exhausted jobs go; the first poison job teaches you in production\nNet: a cap without a dead-letter bounds the damage but still loses the job; the dead-letter is what makes the loss recoverable.",
+ "header": "Bounded retries",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Cap + dead-letter + alert (recommended)",
+ "description": "Per-job-type max attempts (default 5) in the library config; exhausted jobs land in a dead-letter queue/failed set with payload and last error; alert when depth > 0; documented replay command. Human ~half day / CC ~15 min. Completeness 9/10. Risk: DLQ needs draining discipline."
+ },
+ {
+ "label": "Cap only, log failures",
+ "description": "Per-job-type max attempts (default 5); exhausted jobs emit a structured failure log and are removed. No DLQ, no alert. Human ~1 h / CC ~5 min. Completeness 6/10. Risk: no replay, nobody paged."
+ },
+ {
+ "label": "Library default, no policy",
+ "description": "Plan as written: no explicit cap or destination; library defaults apply. Human ~0 / CC ~0. Completeness 3/10. Risk: unknown default discovered in production."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T03:34:47.260Z",
+ "write": "# Eng Review — Plan: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in repo `gstack-plan-count-PVw5NO`, branch `main`, commit 21338e5.\nReviewer: /plan-eng-review (Claude, native). Session 691-1791084764-0bd1c81e. Date 2026-10-04.\nEvidence limits: the repository contains only `PLAN.md` and `CLAUDE.md`. The 5 worker files, the job library and `processWebhookJob()` named in the plan are not present, so all runtime evidence below is **unknown** and findings are calibrated from the plan text itself.\n\n## Original plan (unchanged copy)\n\n```markdown\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Scope Challenge\n\n### A. Assessment\n\n- **What already solves each sub-problem:** The plan itself states the job library has built-in retry hooks (PLAN.md:7-9). Library not named in repo; its hook contract is unverified. Most mainstream job libraries (BullMQ, Sidekiq, Celery, Oban, Faktory, Resque, Rails ActiveJob) accept a custom backoff function or `retry_in`/`backoffStrategy` callback, so \"full control over the curve\" does not require owning the scheduler. **[Layer 1]**\n- **Minimum change to the goal:** a backoff function plugged into the library hook, plus idempotency for the webhook path, plus a regression test for the webhook rewrite. The hand-rolled scheduler and the 5x copy-paste are deferrable work that adds risk rather than reducing it.\n- **Complexity count (estimates):** 5 worker files + the webhook job path = ~5-6 changed files; 0 new classes/services (scheduler is inline). Below the 8-file / 2-service gate, so B's structure question is skipped.\n- **Search check:** Aside unavailable; host WebSearch used for one sanitized query on backoff/jitter/at-most-once practice (results in Section 1).\n- **TODOS cross-reference:** no `TODOS.md` in repo.\n- **Completeness check:** every shortcut in the plan (\"refactor later\", \"no regression test\", \"not planned\" cache) saves CC minutes, not days. Boil the ocean.\n- **Distribution check:** no new artifacts; not applicable.\n\n### B. Complexity selectors\n\nSkipped: fewer than 8 files and fewer than 2 new classes/services.\n\n### C. Findings\n\n1. **[P1] (confidence: 8/10) PLAN.md:7-9** — \"roll a custom exponential-backoff scheduler inline in each worker rather than use the existing job library's built-in retry hooks. Same shape as the library version\". Rebuilds attempt persistence, delay scheduling and dispatch that the library already provides, in 5 places, to gain a curve the hook almost certainly already lets you supply. Reviewer: native. Remedy: R1 below.\n\nScope Challenge result: pending D1.\n\n## Decision ledger\n\n### R1: Retry mechanism — library retry hooks or custom inline scheduler\nFinding: Scope Challenge #1, P1, confidence 8/10, PLAN.md:7-9, reviewer native\nPlan baseline: original proposal, custom exponential-backoff scheduler inline in each of 5 workers (PLAN.md:7-9); nothing approved yet\nRuntime evidence: unknown. Library not named and not in repo; hook contract unverified.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline per worker (PLAN.md:7-9), pending | library retry hook + one custom backoff function | custom inline scheduler as planned | pending; bounded 1h spike on hook expressiveness only |\n| R2 webhook delivery guarantee under retries | unspecified, pending | pending | pending | pending |\n| R3 shared retry envelope (5 workers) | duplicated, pending | pending (largely moot if A) | pending | pending |\n| R4 bounded retries + dead-letter | unspecified, pending | pending | pending | pending |\n| R5 jitter on backoff | unspecified, pending | pending | pending | pending |\n| R6 payload/graph caching across attempts | re-fetch every attempt, pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Retry mechanism: library retry hooks or a custom inline scheduler?\nProject/branch/task: main — plan \"Add background job retry framework\" (PLAN.md).\nELI10: Your job library already knows how to retry a failed job later. The plan rebuilds that machinery by hand inside each of 5 workers because you want to control the delay curve. Almost every job library lets you hand it your own backoff function, so you can own the curve without owning the scheduler. Hand-rolled schedulers are where lost jobs, double-runs and 3am retry storms come from.\nStakes if we pick wrong: a bug in the delay math or dispatch gets copied into 5 places, and failed jobs either vanish silently or hammer a downstream service in lockstep.\nRecommendation: A because the library hook gives you the curve you want and keeps attempt tracking, persistence and dispatch in code that already runs in production.\nCompleteness: A=9/10, B=6/10, C=4/10\nPros / cons:\nA) Library hook + custom backoff function (recommended) [Layer 1] (human: ~1 day / CC: ~20 min)\n ✅ Attempt counting, delay persistence and dispatch stay in battle-tested library code used by many teams\n ✅ The curve becomes one small pure function that is trivially unit-testable and shared by all 5 workers\n ❌ If the hook truly cannot express the desired curve you need a thin wrapper or an upstream patch; verify first\nB) Custom inline scheduler as planned (human: ~3-4 days / CC: ~1 h)\n ✅ Total control over delay, logging and dispatch timing with no library constraints at all\n ✅ Curve changes never wait on a library release or its configuration surface\n ❌ Re-implements attempt persistence and dispatch five times; every scheduling bug has a 5x blast radius and needs its own scheduler tests\nC) Investigate: 1-hour spike to confirm the hook can express the curve; R1 stays pending (human: ~1 h / CC: ~5 min)\n ✅ Removes the single unknown (hook expressiveness) before any code is written\n ✅ Cheap: read the library's retry docs and run one failing test job through its hook\n ❌ Nothing else in the plan can be locked until this returns; approves no implementation\nNet: owning the curve does not require owning the scheduler; the trade is library constraints versus a 5x hand-rolled blast radius.\nHeader: Retry mechanism\nOptions:\nA) Library hook + custom backoff (recommended)\nPlug one backoff function into the job library's existing retry hook; delete the planned inline scheduler. Attempt counting, persistence and dispatch stay in the library. Human ~1 day / CC ~20 min. Completeness 9/10. Risk: hook may not express the exact curve; verify in docs first. [Layer 1]\nB) Custom inline scheduler\nBuild the exponential-backoff scheduler by hand inside each worker as the plan states. Full control over curve, logging and dispatch. Human ~3-4 days / CC ~1 h. Completeness 6/10. Risk: re-implements persistence and dispatch five times; scheduler bugs have a 5x blast radius and need dedicated tests.\nC) Investigate hook first\nBounded 1-hour spike: read the library's retry docs and push one failing job through its hook to confirm the curve is expressible. R1 stays pending; approves no implementation. Human ~1 h / CC ~5 min. Completeness 4/10.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T03:36:24.932Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hook + custom backoff (recommended) — D1, user answer\nAccepted scope: Replace the planned inline per-worker scheduler with the job library's existing retry hook plus one shared pure backoff function (the \"curve\"). Attempt counting, delay persistence and dispatch remain in the library. Includes: verify in the library docs that the hook accepts a custom delay callback before coding; unit tests for the backoff function (attempt → delay table, cap, boundary at attempt 0 and at max). R2–R6 remain pending.\nHistory: none\n\n## Section 1 — Architecture review\n\n### Retry flow after D1 (library hook + custom curve)\n\n```\njob fails ──► library retry hook ──► backoff(attempt, err) ──► delay ──► re-enqueue\n │ │ │\n │ └─ shared pure fn (R1 approved) └─ library persistence\n ├─ attempt >= max? ──► dead-letter / alert (R4 pending)\n └─ webhook job? ──► same payload re-sent ──► receiver sees duplicate? (R2 pending)\n```\n\n### Findings\n\n1. **[P1] (confidence: 8/10) PLAN.md:17-19** — \"`processWebhookJob()` flow gets rewritten ... No regression test for the prior at-most-once delivery guarantee\". Adding retries to a path that promised at-most-once converts it to at-least-once. A webhook that timed out after the receiver already processed it is re-sent; receivers that act on webhooks (charges, emails) double-act unless each event carries a stable idempotency key. The plan does not name which guarantee it keeps. Reviewer: native. Remedy: R2.\n2. **[P2] (confidence: 7/10) PLAN.md:7-8** — \"custom exponential-backoff scheduler\" names no max-attempts bound and no dead-letter destination. A poison job (bad payload, permanently 4xx endpoint) retries until the curve exceeds any useful horizon, or is dropped silently when the library's default cap fires. Reviewer: native. Remedy: R4. Library hook (D1) makes this a config + alert task, not a build.\n3. **[P2] (confidence: 7/10) PLAN.md:7-9** — \"exponential-backoff ... full control over the curve\" with no jitter named. After a downstream outage every failed job across 5 workers retries on the same schedule: a synchronized retry storm against the recovering service. Full jitter (random in [0, min(cap, base·2^n)]) is the standard fix. Reviewer: native. Remedy: R5.\n\nSecurity: no new auth or data-access boundaries; webhook payload re-sends reuse the existing signing path (unverified, not in repo). Distribution: no new artifacts.\n\n### Failure modes (per new path)\n\n| Path | Realistic production failure | Error handling in plan | Test in plan | User-visible? |\n|---|---|---|---|---|\n| Webhook retry | Receiver processed then timed out; retry re-delivers | none | none | Silent duplicate to customer endpoint — **critical gap** until R2 lands |\n| Backoff curve | Off-by-one makes attempt 0 delay 0 ms → hot loop | library cap only | unit test (D1 scope) | Silent CPU burn |\n| Max attempts | Poison job retries indefinitely / dropped at library default | none | none | Silent loss — **critical gap** until R4 lands |\n| Outage recovery | All jobs retry in lockstep | none | none | Downstream re-fails; users see delayed jobs |\n\n### R2: Webhook delivery guarantee under retries\nFinding: Section 1 #1, P1, confidence 8/10, PLAN.md:17-19, reviewer native\nPlan baseline: original proposal rewrites `processWebhookJob()` under the retry framework with no stated guarantee and no regression test (PLAN.md:17-19); nothing approved for R2\nRuntime evidence: unknown. `processWebhookJob()` and its current at-most-once mechanism are not in the repo; the receiver contract (idempotency header, event id) is unverified.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | approved A (D1): library hook + shared backoff fn | same | same | same |\n| R2 webhook delivery guarantee | unspecified; at-most-once implied by existing code, pending | at-least-once + stable idempotency key per webhook event, documented | keep at-most-once: webhook job opted out of retries (max attempts 1), failures recorded for manual replay | retry webhooks with no idempotency key (plan as written) |\n| R3 shared retry envelope | pending | pending | pending | pending |\n| R4 bounded retries + dead-letter | pending | pending | pending | pending |\n| R5 jitter | pending | pending | pending | pending |\n| R6 payload/graph caching | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — Webhook retries: at-least-once with idempotency key, or keep at-most-once?\nProject/branch/task: main — plan \"Add background job retry framework\" (PLAN.md), webhook path.\nELI10: Today the webhook job promises \"at most once\": a delivery can be lost but is never duplicated. Retries flip that promise. A webhook that timed out after the receiver already handled it will be sent again. Receivers that charge a card or send an email on each webhook will do it twice unless every event carries a stable idempotency key they can dedupe on. You have to pick which promise you keep and write it down; Section 3 then protects it with a test.\nStakes if we pick wrong: silent duplicate webhooks to customers' endpoints (double charges, double emails), or no retry benefit on the path most likely to fail.\nRecommendation: A because retries without an idempotency key are duplicates by design, and at-least-once plus a stable event id is the contract every major webhook provider ships.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once + idempotency key per event (recommended) (human: ~1-2 days / CC: ~30 min)\n ✅ Failed deliveries are actually recovered, which is the whole point of the retry framework\n ✅ A stable event id persisted before the first attempt lets receivers dedupe; matches Stripe/GitHub webhook contracts\n ❌ Changes the documented guarantee; receivers must be told to dedupe, and the id must survive the payload re-fetch\nB) Keep at-most-once: webhook job opts out of retries (human: ~2 h / CC: ~10 min)\n ✅ Zero duplicate risk; existing receiver behavior is untouched\n ✅ Small change: max attempts 1 for this job type plus a recorded-failure row for manual replay\n ❌ Webhook failures stay unrecovered; operators replay by hand\nC) Retry webhooks with no idempotency key, as the plan reads (human: ~0 / CC: ~0)\n ✅ No extra work beyond the framework itself\n ✅ No receiver coordination needed\n ❌ Guaranteed duplicates on every timeout-after-success; the at-most-once promise breaks silently with nothing telling you\nNet: you are choosing between recovering failed webhooks (and asking receivers to dedupe) or keeping them un-duplicated (and un-recovered).\nHeader: Webhook guarantee\nOptions:\nA) At-least-once + idempotency key (recommended)\nWebhook retries enabled under the library hook. Each webhook event gets a stable idempotency id persisted before the first attempt and sent as a header; the guarantee is documented as at-least-once. Human ~1-2 days / CC ~30 min. Completeness 9/10. Risk: receivers must dedupe; id must survive the payload re-fetch.\nB) Keep at-most-once (opt out)\nWebhook job type is excluded from retries (max attempts 1). Failures are recorded for manual replay. Existing guarantee untouched. Human ~2 h / CC ~10 min. Completeness 7/10. Risk: webhook failures remain unrecovered.\nC) Retry with no idempotency key\nPlan as written: webhook retried like any job, no event id, guarantee undocumented. Human ~0 / CC ~0. Completeness 3/10. Risk: silent duplicates on every timeout-after-success.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T03:36:27.173Z",
+ "old": "Scope Challenge result: pending D1.",
+ "new": "Dispositions: #1 accepted — D1 answer A (library hook + shared backoff function); the custom inline scheduler is cut.\n\nScope Challenge result: **scope reduced per recommendation** (custom scheduler removed; library hook retained). MODE = SCOPE_REDUCED."
+ },
+ {
+ "at": "2026-10-04T03:37:41.143Z",
+ "old": "Plan as written: webhook retried like any job, no event id, guarantee undocumented. Human ~0 / CC ~0. Completeness 3/10. Risk: silent duplicates on every timeout-after-success.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Plan as written: webhook retried like any job, no event id, guarantee undocumented. Human ~0 / CC ~0. Completeness 3/10. Risk: silent duplicates on every timeout-after-success.\n\nState: approved\nActual answer: A) At-least-once + idempotency key (recommended) — D2, user answer\nAccepted scope: Webhook job retries run under the library hook (R1). Each webhook event gets a stable idempotency id generated and persisted with the job before the first attempt, sent on every attempt as an idempotency header; the delivery guarantee is documented as at-least-once and the receiver-dedupe expectation is written into the webhook docs. Necessary proof carried forward to Section 3: regression test asserting the same event id on attempt 1 and attempt N, and a test that a timeout-after-success re-sends with the identical id. R3–R6 remain pending.\nHistory: none\n\n### R4: Bounded retries and exhausted-job destination\nFinding: Section 1 #2, P2, confidence 7/10, PLAN.md:7-8, reviewer native\nPlan baseline: original proposal names no max-attempts bound and no destination for exhausted jobs (PLAN.md:7-8); nothing approved for R4\nRuntime evidence: unknown. Library default attempt cap and whether a dead-letter/failed set exists are unverified (library not in repo).\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | approved A (D1) | same | same | same |\n| R2 webhook guarantee | approved A (D2) | same | same | same |\n| R3 shared retry envelope | pending | pending | pending | pending |\n| R4a max attempts | unspecified, pending | explicit per-job-type cap, default 5 attempts | explicit per-job-type cap, default 5 attempts | library default, unspecified |\n| R4b exhausted-job destination | unspecified, pending | dead-letter queue/failed set + alert on depth > 0 | logged as failed only, no DLQ, no alert | whatever the library does by default |\n| R5 jitter | pending | pending | pending | pending |\n| R6 payload/graph caching | pending | pending | pending | pending |\n\nQuestion D3:\nD3 — Bounded retries: cap attempts and dead-letter exhausted jobs, or leave it to the library default?\nProject/branch/task: main — plan \"Add background job retry framework\" (PLAN.md), all 5 workers.\nELI10: Some jobs will never succeed: a bad payload, a customer endpoint that is gone for good. Without an explicit cap they retry until the curve stretches into days, and without a parking spot they are either dropped silently or sit in the queue forever. The fix is boring: a per-job-type attempt cap, and a dead-letter place where exhausted jobs land with an alert so a human sees them.\nStakes if we pick wrong: poison jobs burn worker capacity for days, or real customer jobs vanish with no record and no alert.\nRecommendation: A because the library hook (D1) makes the cap one config value and the dead-letter one handler, and it turns a silent loss into a visible, replayable one.\nCompleteness: A=9/10, B=6/10, C=3/10\nPros / cons:\nA) Explicit cap (default 5, per job type) + dead-letter with alert (recommended) (human: ~half day / CC: ~15 min)\n ✅ Exhausted jobs are preserved with their payload and error for inspection and manual replay\n ✅ An alert on dead-letter depth turns a silent failure into an operator signal within minutes\n ❌ One more queue/set to monitor and to drain; needs a replay path or it fills up quietly\nB) Explicit cap (default 5), exhausted jobs logged as failed only (human: ~1 h / CC: ~5 min)\n ✅ Bounds the retry storm with a single config value per job type\n ✅ No new queue or alert plumbing to maintain\n ❌ Exhausted jobs are gone after the log line; no replay and nobody is paged\nC) Library default cap, no explicit policy, as the plan reads (human: ~0 / CC: ~0)\n ✅ Zero work; the library's default behavior applies\n ✅ Fewer decisions to document now\n ❌ You do not know today what the default is or where exhausted jobs go; the first poison job teaches you in production\nNet: a cap without a dead-letter bounds the damage but still loses the job; the dead-letter is what makes the loss recoverable.\nHeader: Bounded retries\nOptions:\nA) Cap + dead-letter + alert (recommended)\nPer-job-type max attempts (default 5) in the library config; exhausted jobs land in a dead-letter queue/failed set with payload and last error; alert when depth > 0; documented replay command. Human ~half day / CC ~15 min. Completeness 9/10. Risk: DLQ needs draining discipline.\nB) Cap only, log failures\nPer-job-type max attempts (default 5); exhausted jobs emit a structured failure log and are removed. No DLQ, no alert. Human ~1 h / CC ~5 min. Completeness 6/10. Risk: no replay, nobody paged.\nC) Library default, no policy\nPlan as written: no explicit cap or destination; library defaults apply. Human ~0 / CC ~0. Completeness 3/10. Risk: unknown default discovered in production.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37176837432-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37176837432-t1-pass.json
new file mode 100644
index 000000000..d337debb7
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37176837432-t1-pass.json
@@ -0,0 +1,111 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "57a825b033078a7b",
+ "source": {
+ "run": "37176837432",
+ "sha": "7249f014648202840af294b730ac244e03ce514c",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37176837432-1-eval-slices-1/plan-eng-review-1791087925106-l3TgIg"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "f639c2ad-c78e-40de-be08-40b0ac614f36",
+ "toolUseId": "toolu_01UMCDB7ntEy9gZoZPgr2P6t",
+ "answeredAt": "2026-10-04T04:28:00.234Z",
+ "answer": "Library hooks + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Use the job library's retry hooks, or build a custom inline scheduler?\nProject/branch/task: gstack-plan-count-jikZRT on main, plan \"Add background job retry framework\".\nELI10: The plan writes its own retry timer inside each of 5 workers even though the job library already ships retry hooks that do the same job. The only stated reason is control over the delay curve, and retry hooks normally let you plug in your own delay function. Writing your own scheduler means you also own attempt counting, persistence of retry state across restarts, and every bug in those.\nStakes if we pick wrong: a hand-rolled scheduler that loses retry state on a worker restart drops or double-runs jobs silently, and you maintain five copies of it.\nRecommendation: A because the library hook almost certainly accepts a custom delay function, which gives the curve control the plan wants with none of the scheduling code [Layer 1: proven built-in over reinvention].\nCompleteness: A=10/10, B=6/10, C=n/a (investigation only)\nPros / cons:\nA) Library retry hooks + custom backoff function (recommended)\n ✅ The library already persists attempt counts and schedules the next run, so restarts do not lose retry state\n ✅ The curve lives in one small pure function that is trivial to unit test and reuse across all 5 workers\n ❌ If the hook does not expose a per-job delay function, you need a shim or a library upgrade before this works\nB) Custom inline scheduler in each worker (as planned)\n ✅ Complete control over timing with no dependency on the library's hook semantics\n ✅ No need to learn or verify the library's retry API before starting\n ❌ You own attempt counting, persistence across restarts and the dispatch loop, in five places (human: ~3 days / CC: ~40 min, plus ongoing maintenance)\nC) Investigate the hook API first (bounded, mechanism stays pending)\n ✅ Settles the one open fact (does the hook take a delay function) before committing to either design\n ✅ Cheap: read one library signature and one existing hook usage (human: ~1h / CC: ~5 min)\n ❌ Delays the decision; the review continues with the mechanism unresolved\nNet: you are trading a few minutes of reading the library's hook API against owning a scheduler forever.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hooks + custom curve (recommended)",
+ "description": "Use the existing job library's retry hook and pass the exponential-backoff curve in as one small function. The library keeps attempt counts and schedules the retry; workers only supply the curve. Completeness 10/10. Human ~1 day / CC ~10 min. Risk: hook may not accept a per-job delay function; verify at the first worker. Maintenance: one function."
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Build the exponential-backoff scheduler inline in each of the 5 workers as the plan proposes. Completeness 6/10. Human ~3 days / CC ~40 min. Risk: retry state lost on restart unless you also persist attempts; five copies to maintain. Maintenance: high."
+ },
+ {
+ "label": "Investigate hook API first",
+ "description": "Bounded investigation only: read the library's retry hook signature and one existing usage, confirm whether it accepts a custom delay function. Approves no implementation; R1 stays pending and all other rows stay pending. Human ~1h / CC ~5 min."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "f639c2ad-c78e-40de-be08-40b0ac614f36",
+ "toolUseId": "toolu_01JeD5JExNnckpJZk87yproz",
+ "answeredAt": "2026-10-04T04:29:18.756Z",
+ "answer": "Idempotent delivery + retry (recommended)",
+ "question": {
+ "question": "D2 — What delivery guarantee does processWebhookJob keep once it can retry?\nProject/branch/task: gstack-plan-count-jikZRT on main, plan \"Add background job retry framework\".\nELI10: Today a webhook is sent at most once: if the send fails, it is dropped, never duplicated. Adding retries flips that. If the request reaches the receiver but the response times out, the retry sends the same webhook again, and the receiver sees two. Receivers that charge a card or create a record on each webhook will do it twice.\nStakes if we pick wrong: duplicate side effects at every customer receiving your webhooks, with no way for them to tell a retry from a new event.\nRecommendation: A because an idempotency key plus a persisted delivery record keeps retries safe for receivers and costs one small table or column and one header.\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Idempotent delivery with per-job delivery record (recommended)\n ✅ Retries recover from transient receiver outages without ever recording two successful deliveries for one job\n ✅ The `Idempotency-Key` header is the standard way webhook receivers dedupe, so well-built receivers drop the duplicate automatically\n ❌ Needs a persisted delivery-state record (pending / delivered / ambiguous) written before each send, plus a migration (human: ~1 day / CC: ~15 min)\nB) Exclude processWebhookJob from the retry framework\n ✅ Preserves today's at-most-once behavior exactly, so the existing contract and its regression test stay simple\n ✅ Smallest change: this worker just does not wire the retry hook\n ❌ Webhooks still vanish on any transient failure, which is the pain this plan exists to fix\nC) At-least-once with no dedup\n ✅ Simplest retry wiring; no delivery table and no header\n ✅ Every job eventually delivers or dead-letters\n ❌ Every ambiguous send produces a visible duplicate at the receiver and pushes the dedup burden onto every customer\nNet: one header and one state record versus either keeping the drops or shipping duplicates to customers.",
+ "header": "Webhook semantics",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Idempotent delivery + retry (recommended)",
+ "description": "Persist a per-job delivery record (pending / delivered / ambiguous) written before each send; send `Idempotency-Key: ` on every attempt; on retry, skip the send if the record is already `delivered`, otherwise resend with the same key so the receiver can dedupe. Completeness 10/10. Human ~1 day / CC ~15 min. Risk: needs a migration; receivers that ignore the header may still see a duplicate after an ambiguous send, which is the documented limit."
+ },
+ {
+ "label": "Exclude webhooks from retries",
+ "description": "Keep processWebhookJob on its current at-most-once path and do not wire the retry hook for it. The other 4 workers retry. Completeness 7/10. Human ~0 / CC ~0. Risk: webhook drops on transient failure continue."
+ },
+ {
+ "label": "At-least-once, no dedup",
+ "description": "Wire the retry hook for processWebhookJob with no delivery record and no idempotency header; document to receivers that delivery is at-least-once. Completeness 3/10. Human ~1h / CC ~5 min. Risk: duplicate side effects at receivers."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "f639c2ad-c78e-40de-be08-40b0ac614f36",
+ "toolUseId": "toolu_01RKj7piBifaror2JZpqmmKJ",
+ "answeredAt": "2026-10-04T04:30:12.398Z",
+ "answer": "Max attempts + dead-letter (recommended)",
+ "question": {
+ "question": "D3 — What happens when a job keeps failing: cap attempts and dead-letter it, cap and drop, or retry forever?\nProject/branch/task: gstack-plan-count-jikZRT on main, plan \"Add background job retry framework\".\nELI10: Some jobs will never succeed: a receiver that returns 410 Gone, a payload that fails validation. The plan never says when to stop retrying or where the job goes when you do. Retry forever and those poison jobs sit in the queue burning worker time. Drop them and you lose the evidence of what went wrong. Dead-letter them and someone at 3am can see the job, the last error, and replay it.\nStakes if we pick wrong: either an ever-growing queue of jobs that will never complete, or silently lost work with no record.\nRecommendation: A because a finite cap plus a dead-letter set is the standard pattern and most job libraries already ship the dead set, so the cost is one setting and one alert.\nCompleteness: A=10/10, B=6/10, C=2/10\nPros / cons:\nA) Finite max attempts + dead-letter set with alert (recommended)\n ✅ Poison jobs leave the live queue after a bounded number of attempts, so healthy jobs are not starved\n ✅ The dead set keeps the job and its last error, so an operator can inspect and replay instead of guessing\n ❌ Needs a dead set (library-provided or one table) and one alert wired to it (human: ~half day / CC: ~10 min)\nB) Finite max attempts, discard on exhaustion\n ✅ Bounded retries with the least code: one setting and one error log line\n ✅ No dead-letter storage to operate or prune\n ❌ Exhausted jobs are gone; the only trace is a log line, so replay means re-creating the job by hand\nC) Unbounded retries (plan as written)\n ✅ No job is ever declared failed, so nothing needs an operator decision\n ✅ Zero additional code beyond the curve\n ❌ A permanently failing job retries forever, and once the delay cap is hit it consumes a worker slot on every cycle\nNet: a few lines of configuration and one alert versus either an unbounded poison queue or silently discarded work.",
+ "header": "Terminal policy",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Max attempts + dead-letter (recommended)",
+ "description": "Finite max attempts (default 5, per-worker override) on the library retry hook. On exhaustion, move the job and its last error to the library's dead set if it has one, otherwise to a dead-letter table; emit one metric and alert on dead-letter growth. Includes tests for the exhaustion path. Completeness 10/10. Human ~half day / CC ~10 min."
+ },
+ {
+ "label": "Max attempts, discard",
+ "description": "Finite max attempts (default 5, per-worker override). On exhaustion, log the job id and last error at error level and drop the job. Includes a test for the exhaustion path. Completeness 6/10. Human ~1h / CC ~5 min. Risk: no replay path."
+ },
+ {
+ "label": "Unbounded retries",
+ "description": "No attempt limit; the job retries on the curve until it succeeds. Completeness 2/10. Human ~0 / CC ~0. Risk: poison jobs never leave the queue."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T04:27:24.084Z",
+ "write": "# Eng review report: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in `gstack-plan-count-jikZRT` (branch `main`, commit e9dce01), plan title \"Add background job retry framework\".\nReport file: this document (destination requested by the user).\nReviewer: /plan-eng-review (gstack), session 692-1791087913-3b742802, 2026-10-04.\n\n## Original plan (unchanged copy)\n\n```markdown\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Review preparation notes\n\n- Repository contents at review time: `PLAN.md`, `CLAUDE.md` only. No worker source, job library, tests or `TODOS.md` are present, so every code-level claim below is calibrated against the plan text and marked unverified where the code would be needed.\n- Test framework: unknown (0 test files detected, no `## Testing` section in CLAUDE.md). The plan proposes no framework, so no framework question is asked.\n- Prior learnings: none found (project-scoped search).\n- Design doc: none. User asked to skip the /office-hours prerequisite.\n- Outside voice: `codex_reviews` is disabled in config.\n- Complexity count (proposed work): 5 worker files changed, 0 new classes/services (scheduler is inline per the plan). Below the 8-file / 2-class gate, so the structure question is skipped.\n\n## Decision ledger\n\n### R1: Retry mechanism — library retry hooks vs custom inline scheduler\nFinding: #1, P1, confidence 8/10, PLAN.md:7-9, reviewer: plan-eng-review (Scope Challenge)\nMotivating lines (PLAN.md:7-9): \"We'll roll a custom exponential-backoff scheduler inline in each worker rather than use the existing job library's built-in retry hooks. Same shape as the library version, but we want full control over the curve.\"\nPlan baseline: original proposal, custom inline exponential-backoff scheduler in each of 5 workers; no prior approval.\nRuntime evidence: unknown. The job library and worker files are not in this repository, so the hook API (whether it accepts a custom delay function) could not be read. The plan itself states the custom version has the \"same shape\" as the library's.\nComparison grid:\n\n| Choice | Current | A) Library hooks + custom curve | B) Custom inline scheduler | C) Investigate hook API first |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker (proposed) | library's retry hook, with the backoff curve supplied as one small function; library owns scheduling, persistence and attempt counting | custom scheduler written inline in each worker, as proposed | bounded: read the library's retry hook signature and confirm it accepts a per-job delay function; mechanism stays pending until confirmed |\n| R2 webhook delivery semantics | unspecified, pending | pending | pending | pending |\n| R3 terminal failure policy (max attempts, dead-letter) | unspecified, pending | pending | pending | pending |\n| R4 jitter | unspecified, pending | pending | pending | pending |\n| R5 delay cap | unspecified, pending | pending | pending | pending |\n| R6 shared retry envelope | duplicated across 5 workers (proposed) | pending | pending | pending |\n| R7 regression coverage for processWebhookJob | none planned | pending | pending | pending |\n| R8 dependency-graph caching | re-fetch and recompute every retry (proposed) | pending | pending | pending |\n\nQuestion D1:\nD1 — Use the job library's retry hooks, or build a custom inline scheduler?\nProject/branch/task: gstack-plan-count-jikZRT on main, plan \"Add background job retry framework\".\nELI10: The plan writes its own retry timer inside each of 5 workers even though the job library already ships retry hooks that do the same job. The only stated reason is control over the delay curve, and retry hooks normally let you plug in your own delay function. Writing your own scheduler means you also own attempt counting, persistence of retry state across restarts, and every bug in those.\nStakes if we pick wrong: a hand-rolled scheduler that loses retry state on a worker restart drops or double-runs jobs silently, and you maintain five copies of it.\nRecommendation: A because the library hook almost certainly accepts a custom delay function, which gives the curve control the plan wants with none of the scheduling code [Layer 1: proven built-in over reinvention].\nCompleteness: A=10/10, B=6/10, C=n/a (investigation only)\nPros / cons:\nA) Library retry hooks + custom backoff function (recommended)\n ✅ The library already persists attempt counts and schedules the next run, so restarts do not lose retry state\n ✅ The curve lives in one small pure function that is trivial to unit test and reuse across all 5 workers\n ❌ If the hook does not expose a per-job delay function, you need a shim or a library upgrade before this works\nB) Custom inline scheduler in each worker (as planned)\n ✅ Complete control over timing with no dependency on the library's hook semantics\n ✅ No need to learn or verify the library's retry API before starting\n ❌ You own attempt counting, persistence across restarts and the dispatch loop, in five places (human: ~3 days / CC: ~40 min, plus ongoing maintenance)\nC) Investigate the hook API first (bounded, mechanism stays pending)\n ✅ Settles the one open fact (does the hook take a delay function) before committing to either design\n ✅ Cheap: read one library signature and one existing hook usage (human: ~1h / CC: ~5 min)\n ❌ Delays the decision; the review continues with the mechanism unresolved\nNet: you are trading a few minutes of reading the library's hook API against owning a scheduler forever.\nHeader: Retry mechanism\nOptions:\nA) Library hooks + custom curve\nUse the existing job library's retry hook and pass the exponential-backoff curve in as one small function. The library keeps attempt counts and schedules the retry; workers only supply the curve. Completeness 10/10. Human ~1 day / CC ~10 min. Risk: hook may not accept a per-job delay function; verify at the first worker. Maintenance: one function.\nB) Custom inline scheduler\nBuild the exponential-backoff scheduler inline in each of the 5 workers as the plan proposes. Completeness 6/10. Human ~3 days / CC ~40 min. Risk: retry state lost on restart unless you also persist attempts; five copies to maintain. Maintenance: high.\nC) Investigate hook API first\nBounded investigation only: read the library's retry hook signature and one existing usage, confirm whether it accepts a custom delay function. Approves no implementation; R1 stays pending and all other rows stay pending. Human ~1h / CC ~5 min.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T04:28:51.035Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hooks + custom curve (D1 answer)\nAccepted scope: replace the proposed inline scheduler with the job library's retry hook in all 5 workers; the exponential-backoff curve is one small pure function passed to the hook; the library owns attempt counting, scheduling and persistence. Includes the unit test for the curve function and the hook wiring per worker. Verify at the first worker that the hook accepts a per-job delay function; if not, stop and reopen R1.\nHistory: none\n\nScope Challenge result: scope accepted as-is (feature list unchanged; D1 changes the mechanism, not the scope). MODE = FULL_REVIEW.\n\n### R2: processWebhookJob delivery semantics under retry\nFinding: #2, P1, confidence 9/10, PLAN.md:17-19, reviewer: plan-eng-review (Architecture)\nMotivating lines (PLAN.md:17-19): \"The existing `processWebhookJob()` flow gets rewritten as part of this change. No regression test for the prior at-most-once delivery guarantee is planned.\"\nPlan baseline: original proposal; the rewrite adds retries to a flow that today guarantees at-most-once delivery, with no statement of the new guarantee. No prior approval.\nRuntime evidence: unknown. `processWebhookJob()` is not in this repository; the at-most-once guarantee is taken from the plan's own statement. The semantic shift is definitional: any retry after an ambiguous send (timeout after the request left the box) can deliver the same webhook twice.\nComparison grid:\n\n| Choice | Current | A) Idempotent delivery + retry | B) Exclude webhooks from retries | C) At-least-once, no dedup |\n|---|---|---|---|---|\n| R1 retry mechanism | approved: library hooks + curve (D1) | unchanged (D1) | unchanged (D1) | unchanged (D1) |\n| R2 webhook delivery semantics | at-most-once today; rewrite adds retries with no stated guarantee | retries allowed; a persisted per-job delivery record plus an `Idempotency-Key` header (job id) guarantees at most one successful delivery is recorded and lets receivers drop duplicates from ambiguous sends | processWebhookJob keeps today's at-most-once path; retry hook not wired for this one worker | retries allowed; duplicates possible; receivers are told delivery is at-least-once |\n| R3 terminal failure policy | unspecified, pending | pending | pending | pending |\n| R4 jitter | unspecified, pending | pending | pending | pending |\n| R5 delay cap | unspecified, pending | pending | pending | pending |\n| R6 shared retry envelope | duplicated across 5 workers (proposed) | pending | pending | pending |\n| R7 regression coverage for processWebhookJob | none planned | pending (contract to protect becomes \"at most one successful delivery per job\") | pending (contract to protect stays \"at most one send per job\") | pending (contract becomes \"every job eventually delivered or dead-lettered\") |\n| R8 dependency-graph caching | re-fetch and recompute every retry (proposed) | pending | pending | pending |\n\nQuestion D2:\nD2 — What delivery guarantee does processWebhookJob keep once it can retry?\nProject/branch/task: gstack-plan-count-jikZRT on main, plan \"Add background job retry framework\".\nELI10: Today a webhook is sent at most once: if the send fails, it is dropped, never duplicated. Adding retries flips that. If the request reaches the receiver but the response times out, the retry sends the same webhook again, and the receiver sees two. Receivers that charge a card or create a record on each webhook will do it twice.\nStakes if we pick wrong: duplicate side effects at every customer receiving your webhooks, with no way for them to tell a retry from a new event.\nRecommendation: A because an idempotency key plus a persisted delivery record keeps retries safe for receivers and costs one small table or column and one header.\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Idempotent delivery with per-job delivery record (recommended)\n ✅ Retries recover from transient receiver outages without ever recording two successful deliveries for one job\n ✅ The `Idempotency-Key` header is the standard way webhook receivers dedupe, so well-built receivers drop the duplicate automatically\n ❌ Needs a persisted delivery-state record (pending / delivered / ambiguous) written before each send, plus a migration (human: ~1 day / CC: ~15 min)\nB) Exclude processWebhookJob from the retry framework\n ✅ Preserves today's at-most-once behavior exactly, so the existing contract and its regression test stay simple\n ✅ Smallest change: this worker just does not wire the retry hook\n ❌ Webhooks still vanish on any transient failure, which is the pain this plan exists to fix\nC) At-least-once with no dedup\n ✅ Simplest retry wiring; no delivery table and no header\n ✅ Every job eventually delivers or dead-letters\n ❌ Every ambiguous send produces a visible duplicate at the receiver and pushes the dedup burden onto every customer\nNet: one header and one state record versus either keeping the drops or shipping duplicates to customers.\nHeader: Webhook semantics\nOptions:\nA) Idempotent delivery + retry\nPersist a per-job delivery record (pending / delivered / ambiguous) written before each send; send `Idempotency-Key: ` on every attempt; on retry, skip the send if the record is already `delivered`, otherwise resend with the same key so the receiver can dedupe. Completeness 10/10. Human ~1 day / CC ~15 min. Risk: needs a migration; receivers that ignore the header may still see a duplicate after an ambiguous send, which is the documented limit.\nB) Exclude webhooks from retries\nKeep processWebhookJob on its current at-most-once path and do not wire the retry hook for it. The other 4 workers retry. Completeness 7/10. Human ~0 / CC ~0. Risk: webhook drops on transient failure continue.\nC) At-least-once, no dedup\nWire the retry hook for processWebhookJob with no delivery record and no idempotency header; document to receivers that delivery is at-least-once. Completeness 3/10. Human ~1h / CC ~5 min. Risk: duplicate side effects at receivers.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T04:29:54.828Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Idempotent delivery + retry (D2 answer)\nAccepted scope: processWebhookJob is wired to the retry hook (per D1). Add a persisted per-job delivery record with states pending / delivered / ambiguous, written before each send and updated after; send `Idempotency-Key: ` on every attempt; on retry, skip the send when the record is `delivered`, otherwise resend with the same key. Includes the migration for the record and the tests that prove the contract \"at most one successful delivery recorded per job\" (R7 carries the regression contract). Documented limit: receivers that ignore the header may see one duplicate after an ambiguous send.\nHistory: none\n\n### R3: Terminal failure policy — max attempts and dead-letter handling\nFinding: #3 (Architecture list item 2), P1, confidence 8/10, PLAN.md:7-9, reviewer: plan-eng-review (Architecture)\nMotivating lines (PLAN.md:7-9): \"We'll roll a custom exponential-backoff scheduler inline in each worker ... we want full control over the curve.\" No attempt limit or exhausted-job handling appears anywhere in the plan.\nPlan baseline: original proposal; retries with no stated attempt limit and no stated behavior when retries run out. No prior approval.\nRuntime evidence: unknown. The job library is not in this repository; most job libraries' retry hooks include a max-attempts setting and a dead/failed set, but this one's defaults are unverified.\nComparison grid:\n\n| Choice | Current | A) Max attempts + dead-letter | B) Max attempts, discard | C) Unbounded retries |\n|---|---|---|---|---|\n| R1 retry mechanism | approved (D1) | unchanged (D1) | unchanged (D1) | unchanged (D1) |\n| R2 webhook semantics | approved (D2) | unchanged (D2) | unchanged (D2) | unchanged (D2) |\n| R3 terminal failure policy | unspecified | finite max attempts (default 5, per-worker override, measured in attempts); on exhaustion move the job and its last error to the library's dead set or a dead-letter table and emit one metric/alert | finite max attempts (default 5); on exhaustion log the error and drop the job | no limit; job retries until it succeeds |\n| R4 jitter | unspecified, pending | pending | pending | pending |\n| R5 delay cap | unspecified, pending | pending | pending | pending |\n| R6 shared retry envelope | duplicated across 5 workers (proposed) | pending | pending | pending |\n| R7 regression coverage | pending (contract fixed by D2) | pending | pending | pending |\n| R8 dependency-graph caching | re-fetch and recompute every retry (proposed) | pending | pending | pending |\n\nQuestion D3:\nD3 — What happens when a job keeps failing: cap attempts and dead-letter it, cap and drop, or retry forever?\nProject/branch/task: gstack-plan-count-jikZRT on main, plan \"Add background job retry framework\".\nELI10: Some jobs will never succeed: a receiver that returns 410 Gone, a payload that fails validation. The plan never says when to stop retrying or where the job goes when you do. Retry forever and those poison jobs sit in the queue burning worker time. Drop them and you lose the evidence of what went wrong. Dead-letter them and someone at 3am can see the job, the last error, and replay it.\nStakes if we pick wrong: either an ever-growing queue of jobs that will never complete, or silently lost work with no record.\nRecommendation: A because a finite cap plus a dead-letter set is the standard pattern and most job libraries already ship the dead set, so the cost is one setting and one alert.\nCompleteness: A=10/10, B=6/10, C=2/10\nPros / cons:\nA) Finite max attempts + dead-letter set with alert (recommended)\n ✅ Poison jobs leave the live queue after a bounded number of attempts, so healthy jobs are not starved\n ✅ The dead set keeps the job and its last error, so an operator can inspect and replay instead of guessing\n ❌ Needs a dead set (library-provided or one table) and one alert wired to it (human: ~half day / CC: ~10 min)\nB) Finite max attempts, discard on exhaustion\n ✅ Bounded retries with the least code: one setting and one error log line\n ✅ No dead-letter storage to operate or prune\n ❌ Exhausted jobs are gone; the only trace is a log line, so replay means re-creating the job by hand\nC) Unbounded retries (plan as written)\n ✅ No job is ever declared failed, so nothing needs an operator decision\n ✅ Zero additional code beyond the curve\n ❌ A permanently failing job retries forever, and once the delay cap is hit it consumes a worker slot on every cycle\nNet: a few lines of configuration and one alert versus either an unbounded poison queue or silently discarded work.\nHeader: Terminal policy\nOptions:\nA) Max attempts + dead-letter\nFinite max attempts (default 5, per-worker override) on the library retry hook. On exhaustion, move the job and its last error to the library's dead set if it has one, otherwise to a dead-letter table; emit one metric and alert on dead-letter growth. Includes tests for the exhaustion path. Completeness 10/10. Human ~half day / CC ~10 min.\nB) Max attempts, discard\nFinite max attempts (default 5, per-worker override). On exhaustion, log the job id and last error at error level and drop the job. Includes a test for the exhaustion path. Completeness 6/10. Human ~1h / CC ~5 min. Risk: no replay path.\nC) Unbounded retries\nNo attempt limit; the job retries on the curve until it succeeds. Completeness 2/10. Human ~0 / CC ~0. Risk: poison jobs never leave the queue.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37179171083-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37179171083-t1-pass.json
new file mode 100644
index 000000000..d3978cc8a
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37179171083-t1-pass.json
@@ -0,0 +1,122 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "57a825b033078a7b",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37179171083-1-eval-slices-1/plan-eng-review-1791090853086-Li4aER"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "fe0156ca-72d4-4134-9411-fa163af1fec9",
+ "toolUseId": "toolu_01F5N5AsP7A3K1QHxnzZNetz",
+ "answeredAt": "2026-10-04T05:16:20.889Z",
+ "answer": "Library hook + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Rebuild retry scheduling, or drive the library's retry hook with a custom curve?\nProject/branch/task: main — plan \"Add background job retry framework\", PLAN.md:7-9.\nELI10: The job library you already run has a retry hook. The plan rewrites that machinery by hand inside every worker, admitting it is the \"same shape as the library version\", purely to own the delay curve. Most job libraries let you plug in your own delay function, so you can usually get full control over the curve without rebuilding attempt counting, max-attempt cutoffs, dead-lettering and crash recovery.\nStakes if we pick wrong: a hand-rolled scheduler that loses the attempt counter on a crash either retries forever or drops the job silently, and five copies means five places to get it wrong.\nRecommendation: A because the plan states no behavioral difference from the library version, and a custom backoff function inside the library hook gives the same curve control with the library's persistence and cutoffs intact. [Layer 1: reuse the installed dependency]\nCompleteness: A=10/10, B=4/10, C=n/a (investigation only, approves nothing)\nPros / cons:\nA) Library retry hook + custom backoff function (recommended) (human: ~1 day / CC: ~20 min)\n ✅ Keeps the library's attempt persistence, max-attempt cutoff and dead-letter handling, which the plan would otherwise have to reimplement\n ✅ Full control of the delay curve lives in one small pure function that is trivial to unit test\n ❌ Must confirm the installed library version exposes a custom delay callback; if it does not, the curve may need an upgrade or a thin adapter\nB) Custom inline scheduler per worker (as proposed) (human: ~1 week + ongoing / CC: ~2 h + ongoing)\n ✅ Zero dependence on the library's hook semantics or version\n ✅ Curve, logging and dispatch can be tuned per worker independently\n ❌ Reimplements attempt counting, cutoffs, dead-lettering, jitter and crash-mid-retry handling that the library already provides, and does so five times\nC) Investigate first: bounded probe of the library's hook API (human: ~1 h / CC: ~5 min)\n ✅ Settles the one open fact (does the hook accept a custom delay function?) before committing\n ✅ Costs almost nothing and leaves every other choice untouched\n ❌ Approves no implementation; R1 stays pending until the probe result comes back\nNet: you are trading a small compatibility check against owning (and testing) retry-state machinery five times over.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hook + custom curve (recommended)",
+ "description": "Use the existing job library's retry hook and supply one custom exponential-backoff function for the delay curve. Keeps the library's attempt persistence, max-attempt cutoff and dead-letter path. Completeness 10/10. Effort human ~1 day / CC ~20 min. Risk: the installed version must expose a custom delay callback. R2-R5 stay pending."
+ },
+ {
+ "label": "Custom inline scheduler (as planned)",
+ "description": "Keep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers. Must reimplement attempt counting, cutoffs, dead-lettering, jitter and crash-mid-retry recovery. Completeness 4/10. Effort human ~1 week plus ongoing maintenance / CC ~2 h plus ongoing. R2-R5 stay pending."
+ },
+ {
+ "label": "Investigate the library hook first",
+ "description": "Bounded probe only: read the installed job library's retry hook API and confirm whether it accepts a caller-supplied delay function. Approves no implementation. R1 stays pending until the probe reports back; R2-R5 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "fe0156ca-72d4-4134-9411-fa163af1fec9",
+ "toolUseId": "toolu_01PXLYa3xPTYq262TeeqQ3Sb",
+ "answeredAt": "2026-10-04T05:17:55.867Z",
+ "answer": "At-least-once + idempotency key (recommended)",
+ "question": {
+ "question": "D2 — When webhooks start retrying, what delivery guarantee do receivers get?\nProject/branch/task: main — plan \"Add background job retry framework\", PLAN.md:17-19.\nELI10: Today a webhook is sent at most once: if it fails, it is gone, but the receiver never sees a duplicate. Adding retries flips that. A send that timed out may have actually landed, so the retry delivers the same event twice. Receivers that charge a card or create a record on each webhook will do it twice. The standard fix is an idempotency key: a stable id per delivery that goes out with every attempt, plus a record on our side so a retry never re-sends something already confirmed.\nStakes if we pick wrong: duplicate webhooks cause double side effects at every customer endpoint, and nobody on our side sees an error.\nRecommendation: A because retries on webhooks are only safe when receivers can dedupe, and the key plus delivery record is a small addition that turns the silent-duplicate failure into a defined contract. [Layer 1: standard webhook practice]\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once with idempotency key + delivery record (recommended) (human: ~2 days / CC: ~30 min)\n ✅ Receivers can dedupe on a stable id; the contract becomes explicit and documentable\n ✅ A persisted delivery record stops a retry from re-sending an attempt that already succeeded\n ❌ Adds a small write per attempt and a schema change for the delivery record\nB) Keep retries off for processWebhookJob (human: ~1 h / CC: ~5 min)\n ✅ Preserves today's at-most-once guarantee with no receiver-facing change\n ✅ Smallest possible change to the webhook path\n ❌ Failed webhooks are still dropped silently, which is the pain this plan exists to fix\nC) Retries on, no idempotency key (as proposed) (human: ~0 / CC: ~0)\n ✅ No extra schema or header work\n ✅ Ships fastest\n ❌ Receivers get duplicate deliveries with no way to detect them; double side effects at customer endpoints\nNet: a small id plus one table row per delivery versus silently duplicating customer-visible side effects.",
+ "header": "Webhook guarantee",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "At-least-once + idempotency key (recommended)",
+ "description": "Enable retries on processWebhookJob through the library hook (R1). Send a stable per-delivery idempotency id on every attempt, persist a delivery record and check it before each send so a confirmed attempt is never re-sent. Completeness 10/10. Effort human ~2 days / CC ~30 min. R2-R5 stay pending; R3's regression contract will target this guarantee."
+ },
+ {
+ "label": "Retries off for webhooks only",
+ "description": "Keep processWebhookJob at-most-once with no retries; the other 4 workers get retries via R1. Failed webhooks remain dropped. Completeness 7/10. Effort human ~1 h / CC ~5 min. R2-R5 stay pending; R3's regression contract will target at-most-once."
+ },
+ {
+ "label": "Retries on, no idempotency (as planned)",
+ "description": "Enable retries on processWebhookJob with no idempotency key or delivery record. Receivers may receive duplicates. Completeness 3/10. Effort none beyond R1. R2-R5 stay pending; R3's regression contract will have to document the duplicate-delivery behavior."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "fe0156ca-72d4-4134-9411-fa163af1fec9",
+ "toolUseId": "toolu_018ey1K9ffDscnzBtDJ24dZ5",
+ "answeredAt": "2026-10-04T05:18:53.539Z",
+ "answer": "Full jitter (recommended)",
+ "question": {
+ "question": "D3 — Should the backoff curve add random jitter?\nProject/branch/task: main — plan \"Add background job retry framework\", PLAN.md:7-9.\nELI10: When a downstream service goes down, every job that touches it fails in the same second. With a pure exponential curve they all wake up again in the same second, hit the service together and knock it over again. Jitter spreads each retry randomly across its window so the retries trickle in instead of arriving as a wave. It is one line in the delay function.\nStakes if we pick wrong: a recovering dependency gets re-flattened by our own synchronized retry storm, and the outage lasts longer than it needed to.\nRecommendation: A because full jitter is the standard remedy for synchronized retries and costs one random call inside a function that is already being written. [Layer 1]\nCompleteness: A=10/10, B=7/10\nPros / cons:\nA) Full jitter (recommended) (human: ~30 min incl. test / CC: ~3 min)\n ✅ Spreads retries uniformly across the window, so a recovering dependency sees a trickle, not a wave\n ✅ One expression in the delay function; the unit test seeds the RNG and asserts the range\n ❌ Individual retry timing is no longer exactly predictable from the attempt number alone\nB) No jitter, deterministic exponential (human: ~0 / CC: ~0)\n ✅ Retry timing is exactly predictable, which makes log reading trivial\n ✅ Nothing extra to write or test\n ❌ Every job that failed together retries together, recreating the overload that caused the failure\nNet: predictable timestamps in logs versus not DDoSing your own dependencies after an outage.",
+ "header": "Jitter",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Full jitter (recommended)",
+ "description": "Delay = random(0, min(cap, base * 2^attempt)). Unit test seeds the RNG and asserts the delay stays within [0, bound]. Completeness 10/10. Effort human ~30 min / CC ~3 min. R5b (cap) and R5c (exhaustion) stay pending and are asked next."
+ },
+ {
+ "label": "No jitter",
+ "description": "Delay = base * 2^attempt, deterministic. Completeness 7/10. Effort none. R5b and R5c stay pending and are asked next."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T05:15:44.250Z",
+ "write": "# Eng Review — PLAN.md (\"Add background job retry framework\")\n\nTarget (fixed): \nReport file: this file (user-requested destination)\nReview: /plan-eng-review, session 693-1791090838-01c198b5, branch main, commit 2f3995b\nDate: 2026-10-04\nRuntime evidence note: the repository contains only CLAUDE.md and PLAN.md. The worker files, job library and `processWebhookJob()` named in the plan are not present in this checkout, so all runtime evidence below is `unknown` and findings are calibrated against the plan text.\n\n## Original plan (unchanged copy)\n\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n\n## Decision ledger\n\n### R1: Retry mechanism — library retry hooks vs custom inline scheduler\nFinding: S1, P1, confidence 8/10, PLAN.md:7-9, reviewer: plan-eng-review (native)\nPlan baseline: original proposal — \"roll a custom exponential-backoff scheduler inline in each worker rather than use the existing job library's built-in retry hooks. Same shape as the library version, but we want full control over the curve.\" No prior approval.\nRuntime evidence: unknown. The job library and worker files are not in this checkout; whether the library's retry hook accepts a caller-supplied delay function is unverified.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker (proposed) | library retry hook + one custom backoff function | custom inline scheduler per worker (as proposed) | unchanged, pending; bounded probe of the library hook API only |\n| R2 shared retry envelope vs 5 copies | 5 copy-pasted bodies (proposed) | pending | pending | pending |\n| R3 at-most-once regression test for processWebhookJob | none planned (proposed) | pending | pending | pending |\n| R4 dependency-graph cache across retries | none planned (proposed) | pending | pending | pending |\n| R5 backoff curve details (jitter, cap, max attempts) | unspecified | pending | pending | pending |\n\nQuestion D1:\nD1 — Rebuild retry scheduling, or drive the library's retry hook with a custom curve?\nProject/branch/task: main — plan \"Add background job retry framework\", PLAN.md:7-9.\nELI10: The job library you already run has a retry hook. The plan rewrites that machinery by hand inside every worker, admitting it is the \"same shape as the library version\", purely to own the delay curve. Most job libraries let you plug in your own delay function, so you can usually get full control over the curve without rebuilding attempt counting, max-attempt cutoffs, dead-lettering and crash recovery.\nStakes if we pick wrong: a hand-rolled scheduler that loses the attempt counter on a crash either retries forever or drops the job silently, and five copies means five places to get it wrong.\nRecommendation: A because the plan states no behavioral difference from the library version, and a custom backoff function inside the library hook gives the same curve control with the library's persistence and cutoffs intact. [Layer 1: reuse the installed dependency]\nCompleteness: A=10/10, B=4/10, C=n/a (investigation only, approves nothing)\nPros / cons:\nA) Library retry hook + custom backoff function (recommended) (human: ~1 day / CC: ~20 min)\n ✅ Keeps the library's attempt persistence, max-attempt cutoff and dead-letter handling, which the plan would otherwise have to reimplement\n ✅ Full control of the delay curve lives in one small pure function that is trivial to unit test\n ❌ Must confirm the installed library version exposes a custom delay callback; if it does not, the curve may need an upgrade or a thin adapter\nB) Custom inline scheduler per worker (as proposed) (human: ~1 week + ongoing / CC: ~2 h + ongoing)\n ✅ Zero dependence on the library's hook semantics or version\n ✅ Curve, logging and dispatch can be tuned per worker independently\n ❌ Reimplements attempt counting, cutoffs, dead-lettering, jitter and crash-mid-retry handling that the library already provides, and does so five times\nC) Investigate first: bounded probe of the library's hook API (human: ~1 h / CC: ~5 min)\n ✅ Settles the one open fact (does the hook accept a custom delay function?) before committing\n ✅ Costs almost nothing and leaves every other choice untouched\n ❌ Approves no implementation; R1 stays pending until the probe result comes back\nNet: you are trading a small compatibility check against owning (and testing) retry-state machinery five times over.\nHeader: Retry mechanism\nOptions:\nA) Library hook + custom curve (recommended)\nUse the existing job library's retry hook and supply one custom exponential-backoff function for the delay curve. Keeps the library's attempt persistence, max-attempt cutoff and dead-letter path. Completeness 10/10. Effort human ~1 day / CC ~20 min. Risk: the installed version must expose a custom delay callback. R2-R5 stay pending.\nB) Custom inline scheduler (as planned)\nKeep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers. Must reimplement attempt counting, cutoffs, dead-lettering, jitter and crash-mid-retry recovery. Completeness 4/10. Effort human ~1 week plus ongoing maintenance / CC ~2 h plus ongoing. R2-R5 stay pending.\nC) Investigate the library hook first\nBounded probe only: read the installed job library's retry hook API and confirm whether it accepts a caller-supplied delay function. Approves no implementation. R1 stays pending until the probe reports back; R2-R5 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T05:16:59.272Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hook + custom curve — D1 answer\nAccepted scope: Drop the custom inline scheduler from the plan. All 5 workers use the job library's built-in retry hook; the delay curve is one custom exponential-backoff function supplied to that hook. First implementation step: confirm the installed library version exposes a custom delay callback (if not, upgrade or add a thin adapter). Unit tests for the delay function are required proof of this approved behavior and are carried forward without a further question. R2-R5 remain pending.\nHistory: none\n\n### Scope Challenge record\nfeature answers: none proposed (feature list unchanged: retry framework across 5 workers incl. processWebhookJob); structure: complexity gate not tripped (5 files, 0 new classes/services), structure question skipped; accepted scope: R1 approved per D1 (library hook + custom curve replaces custom inline scheduler); pending remedies: R2, R3, R4, R5.\nScope Challenge result: scope reduced per recommendation (custom scheduler build removed; features unchanged).\n"
+ },
+ {
+ "at": "2026-10-04T05:17:03.002Z",
+ "old": "## Decision ledger\n",
+ "new": "## Working plan (current, with approved changes applied)\n\n### Architecture (R1 approved, D1)\nUse the existing job library's built-in retry hooks in all 5 workers. Supply one custom exponential-backoff delay function to the hook for full control of the curve. The custom inline scheduler is removed. First step: confirm the installed library exposes a custom delay callback. Curve details (jitter, cap, max attempts, dead-letter): R5 pending.\n\n### Code quality\nR2 pending (shared envelope vs 5 copies).\n\n### Tests\nUnit tests for the custom delay function: required proof of R1. R3 pending (at-most-once regression contract for `processWebhookJob()`).\n\n### Performance\nR4 pending (dependency-graph cache across retries).\n\n## Decision ledger\n"
+ },
+ {
+ "at": "2026-10-04T05:17:33.381Z",
+ "old": "Scope Challenge result: scope reduced per recommendation (custom scheduler build removed; features unchanged).\n",
+ "new": "Scope Challenge result: scope reduced per recommendation (custom scheduler build removed; features unchanged).\n\n### R6: Webhook delivery guarantee once retries are enabled on processWebhookJob\nFinding: A1, P1, confidence 8/10, PLAN.md:17-19, reviewer: plan-eng-review (native), Section 1 Architecture\nPlan baseline: original proposal — \"The existing `processWebhookJob()` flow gets rewritten as part of this change. No regression test for the prior at-most-once delivery guarantee is planned.\" Retries are added to this job; nothing in the plan preserves or replaces the at-most-once guarantee. R1 approved (D1): retries run through the library hook. No prior approval for R6.\nRuntime evidence: unknown. `processWebhookJob()` and its callers are not in this checkout; whether webhook receivers tolerate duplicate deliveries is unverified.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R6 webhook delivery guarantee | at-most-once today (plan text); proposed: retried with no dedupe, i.e. at-least-once with duplicates | at-least-once with an idempotency key: stable per-delivery id sent with every attempt and a persisted delivery record checked before each send | retries stay off for processWebhookJob; at-most-once preserved; the other 4 workers get retries | retries on with no idempotency key (as proposed); receivers may see duplicates |\n| R1 retry mechanism | approved (D1): library hook + custom curve | unchanged | unchanged | unchanged |\n| R2 shared envelope | pending | pending | pending | pending |\n| R3 regression contract for processWebhookJob | pending | pending (contract shape follows R6) | pending (contract shape follows R6) | pending (contract shape follows R6) |\n| R4 graph cache | pending | pending | pending | pending |\n| R5 curve details | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — When webhooks start retrying, what delivery guarantee do receivers get?\nProject/branch/task: main — plan \"Add background job retry framework\", PLAN.md:17-19.\nELI10: Today a webhook is sent at most once: if it fails, it is gone, but the receiver never sees a duplicate. Adding retries flips that. A send that timed out may have actually landed, so the retry delivers the same event twice. Receivers that charge a card or create a record on each webhook will do it twice. The standard fix is an idempotency key: a stable id per delivery that goes out with every attempt, plus a record on our side so a retry never re-sends something already confirmed.\nStakes if we pick wrong: duplicate webhooks cause double side effects at every customer endpoint, and nobody on our side sees an error.\nRecommendation: A because retries on webhooks are only safe when receivers can dedupe, and the key plus delivery record is a small addition that turns the silent-duplicate failure into a defined contract. [Layer 1: standard webhook practice]\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once with idempotency key + delivery record (recommended) (human: ~2 days / CC: ~30 min)\n ✅ Receivers can dedupe on a stable id; the contract becomes explicit and documentable\n ✅ A persisted delivery record stops a retry from re-sending an attempt that already succeeded\n ❌ Adds a small write per attempt and a schema change for the delivery record\nB) Keep retries off for processWebhookJob (human: ~1 h / CC: ~5 min)\n ✅ Preserves today's at-most-once guarantee with no receiver-facing change\n ✅ Smallest possible change to the webhook path\n ❌ Failed webhooks are still dropped silently, which is the pain this plan exists to fix\nC) Retries on, no idempotency key (as proposed) (human: ~0 / CC: ~0)\n ✅ No extra schema or header work\n ✅ Ships fastest\n ❌ Receivers get duplicate deliveries with no way to detect them; double side effects at customer endpoints\nNet: a small id plus one table row per delivery versus silently duplicating customer-visible side effects.\nHeader: Webhook guarantee\nOptions:\nA) At-least-once + idempotency key (recommended)\nEnable retries on processWebhookJob through the library hook (R1). Send a stable per-delivery idempotency id on every attempt, persist a delivery record and check it before each send so a confirmed attempt is never re-sent. Completeness 10/10. Effort human ~2 days / CC ~30 min. R2-R5 stay pending; R3's regression contract will target this guarantee.\nB) Retries off for webhooks only\nKeep processWebhookJob at-most-once with no retries; the other 4 workers get retries via R1. Failed webhooks remain dropped. Completeness 7/10. Effort human ~1 h / CC ~5 min. R2-R5 stay pending; R3's regression contract will target at-most-once.\nC) Retries on, no idempotency (as planned)\nEnable retries on processWebhookJob with no idempotency key or delivery record. Receivers may receive duplicates. Completeness 3/10. Effort none beyond R1. R2-R5 stay pending; R3's regression contract will have to document the duplicate-delivery behavior.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T05:18:32.600Z",
+ "old": "R2-R5 stay pending; R3's regression contract will have to document the duplicate-delivery behavior.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "R2-R5 stay pending; R3's regression contract will have to document the duplicate-delivery behavior.\n\nState: approved\nActual answer: A) At-least-once + idempotency key — D2 answer\nAccepted scope: processWebhookJob retries through the library hook (R1). Every attempt carries a stable per-delivery idempotency id (header). A persisted delivery record is written per delivery and checked before each send so a confirmed attempt is never re-sent. Schema change for the delivery record is in scope. R3's regression contract targets this at-least-once-with-idempotency guarantee. R2-R5 remain pending.\nHistory: none\n\n### R5a: Jitter on the backoff curve\nFinding: A2, P2, confidence 7/10, PLAN.md:7-9, reviewer: plan-eng-review (native), Section 1 Architecture\nPlan baseline: original proposal — \"exponential-backoff scheduler ... we want full control over the curve.\" Curve details are unspecified. R1 approved (D1): the curve is one custom delay function supplied to the library hook. No prior approval for jitter.\nRuntime evidence: unknown. No existing curve or worker code in this checkout.\nComparison grid:\n\n| Choice | Current | A | B |\n|---|---|---|---|\n| R5a jitter | unspecified | full jitter: delay = random(0, min(cap, base * 2^attempt)) | none: delay = base * 2^attempt (deterministic) |\n| R5b delay cap | unspecified, pending | pending | pending |\n| R5c exhaustion handling (max attempts, dead-letter) | unspecified, pending | pending | pending |\n| R1 retry mechanism | approved (D1) | unchanged | unchanged |\n| R6 webhook guarantee | approved (D2) | unchanged | unchanged |\n| R2, R3, R4 | pending | pending | pending |\n\nQuestion D3:\nD3 — Should the backoff curve add random jitter?\nProject/branch/task: main — plan \"Add background job retry framework\", PLAN.md:7-9.\nELI10: When a downstream service goes down, every job that touches it fails in the same second. With a pure exponential curve they all wake up again in the same second, hit the service together and knock it over again. Jitter spreads each retry randomly across its window so the retries trickle in instead of arriving as a wave. It is one line in the delay function.\nStakes if we pick wrong: a recovering dependency gets re-flattened by our own synchronized retry storm, and the outage lasts longer than it needed to.\nRecommendation: A because full jitter is the standard remedy for synchronized retries and costs one random call inside a function that is already being written. [Layer 1]\nCompleteness: A=10/10, B=7/10\nPros / cons:\nA) Full jitter (recommended) (human: ~30 min incl. test / CC: ~3 min)\n ✅ Spreads retries uniformly across the window, so a recovering dependency sees a trickle, not a wave\n ✅ One expression in the delay function; the unit test seeds the RNG and asserts the range\n ❌ Individual retry timing is no longer exactly predictable from the attempt number alone\nB) No jitter, deterministic exponential (human: ~0 / CC: ~0)\n ✅ Retry timing is exactly predictable, which makes log reading trivial\n ✅ Nothing extra to write or test\n ❌ Every job that failed together retries together, recreating the overload that caused the failure\nNet: predictable timestamps in logs versus not DDoSing your own dependencies after an outage.\nHeader: Jitter\nOptions:\nA) Full jitter (recommended)\nDelay = random(0, min(cap, base * 2^attempt)). Unit test seeds the RNG and asserts the delay stays within [0, bound]. Completeness 10/10. Effort human ~30 min / CC ~3 min. R5b (cap) and R5c (exhaustion) stay pending and are asked next.\nB) No jitter\nDelay = base * 2^attempt, deterministic. Completeness 7/10. Effort none. R5b and R5c stay pending and are asked next.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T05:18:39.019Z",
+ "old": "Use the existing job library's built-in retry hooks in all 5 workers. Supply one custom exponential-backoff delay function to the hook for full control of the curve. The custom inline scheduler is removed. First step: confirm the installed library exposes a custom delay callback. Curve details (jitter, cap, max attempts, dead-letter): R5 pending.\n\n### Code quality\nR2 pending (shared envelope vs 5 copies).\n\n### Tests\nUnit tests for the custom delay function: required proof of R1. R3 pending (at-most-once regression contract for `processWebhookJob()`).",
+ "new": "Use the existing job library's built-in retry hooks in all 5 workers. Supply one custom exponential-backoff delay function to the hook for full control of the curve. The custom inline scheduler is removed. First step: confirm the installed library exposes a custom delay callback. Curve details: R5a jitter, R5b cap, R5c exhaustion handling pending.\n\nWebhook delivery (R6 approved, D2): `processWebhookJob()` retries through the hook. Every attempt carries a stable per-delivery idempotency id; a persisted delivery record is checked before each send so a confirmed attempt is never re-sent. Delivery-record schema change is in scope. Guarantee becomes at-least-once with receiver-side dedupe on the id.\n\n### Code quality\nR2 pending (shared envelope vs 5 copies).\n\n### Tests\nUnit tests for the custom delay function: required proof of R1. Tests for the idempotency id and delivery-record check: required proof of R6. R3 pending (regression contract for `processWebhookJob()` under the new guarantee)."
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37182865432-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37182865432-t1-pass.json
new file mode 100644
index 000000000..79a63cbf0
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37182865432-t1-pass.json
@@ -0,0 +1,116 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "c620ebc29ce470c6",
+ "source": {
+ "run": "37182865432",
+ "sha": "44ef18cd0ee8bfb33b3812fbc7f012a2d52f6d9e",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37182865432-1-eval-slices-1/plan-eng-review-1791095412268-YjAH7t"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "d6fa6bb9-d929-41cb-84b0-fdd0c5dc3713",
+ "toolUseId": "toolu_01GyZu62twEwvYDuxP5KMtVM",
+ "answeredAt": "2026-10-04T06:33:26.049Z",
+ "answer": "Library hook + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Retry scheduling: library hooks or a custom inline scheduler?\nProject/branch/task: main branch, plan \"Add background job retry framework\" (PLAN.md), touching 5 worker files.\nELI10: Your job library already knows how to retry a failed job later. The plan says: ignore that and write our own timer logic inside each of the 5 workers, because we want to pick the exact wait-time curve. Almost every job library lets you plug in your own curve through a hook, so you get the control without rebuilding scheduling, persistence of attempt counts, and the failed-job UI. Building it ourselves means five copies of code that has to survive worker crashes and restarts.\nStakes if we pick wrong: a hand-rolled scheduler that loses in-flight retries on worker restart or double-fires them, invisible in the library's dashboard; or, if the hook truly cannot express the curve, a library-bound design that needs a hack later.\nRecommendation: A because the stated goal (a custom curve) is exactly what the backoff hook exists for [Layer 1], and the library already handles attempt persistence, dead-lettering and tooling.\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only, approves nothing)\nPros / cons:\nA) Library hook + custom curve (recommended) (human: ~0.5 day / CC: ~10 min)\n ✅ Attempt count, next-run time and failed state persist in the library's store, so retries survive worker restarts\n ✅ The curve lives in one small function; dashboard, dead-letter and metrics come from the library for free\n ❌ If the installed library's hook cannot express the curve (unverified: library not in this repo), we fall back to B after a short delay\nB) Custom inline scheduler (human: ~3 days / CC: ~45 min, plus ongoing maintenance)\n ✅ Total control over timing with no dependency on the library's hook semantics\n ✅ Works the same even if the library's retry feature is weak or absent\n ❌ Re-implements attempt persistence, restart safety and visibility; five copies to keep correct at 3am\nC) Spike the hook first (human: ~30 min / CC: ~5 min)\n ✅ Removes the one real unknown (hook capability) before committing either way\n ✅ Cheap, and produces a concrete snippet to review\n ❌ Approves nothing and delays every downstream choice that depends on the mechanism\nNet: trading a small chance of hook limitations against owning scheduling correctness in five places.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hook + custom curve (recommended)",
+ "description": "Use the job library's retry hook with one custom backoff function that produces the desired curve. Remove the inline scheduler from the plan; workers only mark which errors are retryable. Attempt count, next-run time and failed state persist in the library's store, so retries survive worker restarts. Dashboard, dead-letter and metrics come from the library for free. Con: if the installed library's hook cannot express the curve (unverified, library not in this repo), we fall back to B after a short delay. Effort human ~0.5 day / CC ~10 min. R2-R7 stay pending."
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Keep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers. Total control over timing, no dependency on the library's hook semantics, and works even if the library's retry feature is weak. Con: re-implements attempt persistence, restart safety and visibility, and leaves five copies to keep correct at 3am. Effort human ~3 days / CC ~45 min plus ongoing maintenance. R2-R7 stay pending."
+ },
+ {
+ "label": "Spike the hook first",
+ "description": "Decide nothing now. Run a bounded 30-minute spike that confirms whether the library's retry hook can express the desired curve, then re-ask this question with the snippet in hand. Plan text unchanged meanwhile. Cheap and removes the one real unknown, but approves nothing and delays every downstream choice that depends on the mechanism. Effort human ~30 min / CC ~5 min. R2-R7 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "d6fa6bb9-d929-41cb-84b0-fdd0c5dc3713",
+ "toolUseId": "toolu_01DcYAX5rQjxzNSR7VQpxKjq",
+ "answeredAt": "2026-10-04T06:34:45.901Z",
+ "answer": "At-least-once + idempotency key (recommended)",
+ "question": {
+ "question": "D2 — Webhook delivery: what guarantee do receivers get once we retry?\nProject/branch/task: main branch, plan \"Add background job retry framework\" (PLAN.md), the `processWebhookJob()` rewrite.\nELI10: Today a webhook is sent at most once: if the job dies, the receiver gets nothing, but never two copies. Once we retry, a job that sent the webhook and then crashed before recording success will send it again. Receivers now have to cope with duplicates. The standard fix is to stamp every send with the same idempotency key across attempts so receivers can drop the second copy. The plan is silent on this.\nStakes if we pick wrong: duplicate webhooks trigger double side effects at receivers (double emails, double charges, double syncs), and the first support ticket arrives before anyone knows the guarantee changed.\nRecommendation: A because at-least-once with a stable idempotency key is the standard contract for retried webhooks and costs one header plus a doc line.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once + idempotency key (recommended) (human: ~0.5 day / CC: ~10 min)\n ✅ Webhook failures are actually retried, and receivers have a documented way to drop duplicates\n ✅ Key derived from the job id, so it is stable across attempts with no new storage\n ❌ Receivers that ignore the header still see duplicates; the contract change must be communicated\nB) Keep webhook at-most-once (opt out) (human: ~1 hour / CC: ~5 min)\n ✅ Zero behavior change for receivers; no contract to communicate\n ✅ Smallest diff for the webhook path; other 4 workers still gain retries\n ❌ Transient webhook failures stay lost, which is the problem the plan set out to fix\nC) At-least-once, no key (as planned) (human: ~0 / CC: ~0)\n ✅ No extra work on the send path\n ✅ Receivers already deduping by payload are unaffected\n ❌ Silent contract change; receivers without dedup get duplicates and nothing tells them\nNet: trading one header and a doc line against either lost webhooks or silent duplicates.",
+ "header": "Webhook semantics",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "At-least-once + idempotency key (recommended)",
+ "description": "Retry `processWebhookJob()` and stamp every send with a stable idempotency key derived from the job id (same value on every attempt) in a documented header. Document the at-least-once contract for receivers. Webhook failures are actually retried and receivers have a standard way to drop duplicates; no new storage needed. Con: receivers that ignore the header still see duplicates, so the contract change must be communicated. Effort human ~0.5 day / CC ~10 min. R1 fixed (D1); R3-R8 stay pending."
+ },
+ {
+ "label": "Keep webhook at-most-once (opt out)",
+ "description": "Exclude `processWebhookJob()` from the retry framework so its at-most-once guarantee is preserved exactly; the other 4 workers gain retries. Zero behavior change for receivers and the smallest diff on the webhook path. Con: transient webhook failures stay lost, which is part of what the plan set out to fix. Effort human ~1 hour / CC ~5 min. R1 fixed (D1); R3-R8 stay pending."
+ },
+ {
+ "label": "At-least-once, no key (as planned)",
+ "description": "Retry `processWebhookJob()` with no idempotency key and no contract change communicated, as the plan currently reads. No extra work on the send path; receivers that already dedup by payload are unaffected. Con: silent contract change; receivers without dedup get duplicates and nothing tells them. Effort none. R1 fixed (D1); R3-R8 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "d6fa6bb9-d929-41cb-84b0-fdd0c5dc3713",
+ "toolUseId": "toolu_013V7Ujf6tnhVQizkCyNfoKw",
+ "answeredAt": "2026-10-04T06:35:41.544Z",
+ "answer": "Cap 5 attempts + dead-letter + alert (recommended)",
+ "question": {
+ "question": "D3 — How many times do we retry, and what happens when we give up?\nProject/branch/task: main branch, plan \"Add background job retry framework\" (PLAN.md), all 5 workers.\nELI10: Exponential backoff says how long to wait between tries, but not when to stop. Without a cap, a job whose dependency is permanently broken retries forever, and nobody is told. The usual answer is a maximum number of attempts, after which the job is parked in a failed set (dead-letter) and an alert fires so a human can look at it in the morning.\nStakes if we pick wrong: either zombie jobs that retry for days and quietly eat worker capacity, or jobs that silently vanish into a failed set nobody watches.\nRecommendation: A because a cap plus an exhaustion alert is the only option where a permanently failing job becomes a visible, bounded event.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) Cap 5 attempts + dead-letter + alert (recommended) (human: ~0.5 day / CC: ~10 min)\n ✅ Every job's retry cost is bounded and exhaustion becomes a visible metric per worker\n ✅ Dead-lettered jobs are inspectable and replayable from the library's failed set\n ❌ One more metric/alert to wire into the existing monitoring stack\nB) Cap 5 attempts, library default on exhaustion (human: ~1 hour / CC: ~5 min)\n ✅ Retry cost is bounded with almost no extra code beyond a config value\n ✅ Failed jobs still land in the library's failed set for manual inspection\n ❌ Nobody is told when jobs are exhausted; the failed set grows unwatched\nC) No cap, as planned (human: ~0 / CC: ~0)\n ✅ No config or monitoring work at all\n ✅ A job eventually succeeds if the dependency ever recovers\n ❌ Permanently broken jobs retry forever, consuming worker slots with no signal\nNet: trading one metric and a config value against unbounded or invisible failure.",
+ "header": "Retry bounds",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Cap 5 attempts + dead-letter + alert (recommended)",
+ "description": "Set a maximum attempt count per worker type, default 5, configurable. When a job exhausts its attempts it lands in the library's failed/dead-letter set and a `job_retries_exhausted` metric/alert fires carrying the worker name and job id. Every job's retry cost is bounded, exhaustion is visible per worker, and dead-lettered jobs are inspectable and replayable. Con: one more metric/alert to wire into the existing monitoring stack. Effort human ~0.5 day / CC ~10 min. R1, R2 fixed (D1, D2); R4-R8 stay pending."
+ },
+ {
+ "label": "Cap 5 attempts, library default on exhaustion",
+ "description": "Set the same maximum attempt count (default 5, configurable) and rely on the library's default behavior when attempts are exhausted: the job sits in the failed set with no metric or alert. Retry cost is bounded with almost no code beyond a config value, and failed jobs remain inspectable. Con: nobody is told when jobs are exhausted, so the failed set grows unwatched. Effort human ~1 hour / CC ~5 min. R1, R2 fixed (D1, D2); R4-R8 stay pending."
+ },
+ {
+ "label": "No cap, as planned",
+ "description": "Retry until success with no attempt limit and no terminal handling, as the plan currently reads. No config or monitoring work; a job eventually succeeds if its dependency ever recovers. Con: permanently broken jobs retry forever, consuming worker slots with no signal to anyone. Effort none. R1, R2 fixed (D1, D2); R4-R8 stay pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T06:31:42.124Z",
+ "write": "# Eng review — target: PLAN.md (\"Add background job retry framework\")\n\nReview target (fixed): ``\nReviewer: /plan-eng-review (gstack), session 693-1791095399-77e2e403, branch main, commit 1bf5703.\nReport file: this file (destination explicitly requested by the user).\n\n## Original plan (unchanged copy)\n\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n\n## Review evidence base\n\n- Repository contents at review time: `CLAUDE.md`, `PLAN.md` only. No worker source, no job library, no tests, no `TODOS.md`, no `package.json`/`Gemfile`/`pyproject.toml`.\n- Consequence: every statement about existing code (the 5 worker files, `processWebhookJob()`, the job library's retry hooks, the DB payload fetch) is **runtime evidence: unknown**. Findings below are calibrated against the plan text (file:line in PLAN.md) and marked unverified where they depend on code not present.\n- Design doc: none. /office-hours prerequisite skipped at the user's explicit request.\n- Prior learnings: none recorded for this project. Cross-project learnings: off.\n- Outside voice: `codex_reviews` is `disabled` in gstack config; no outside reviewer runs (recorded below).\n"
+ },
+ {
+ "at": "2026-10-04T06:33:03.890Z",
+ "old": "- Outside voice: `codex_reviews` is `disabled` in gstack config; no outside reviewer runs (recorded below).\n",
+ "new": "- Outside voice: `codex_reviews` is `disabled` in gstack config; no outside reviewer runs (recorded below).\n- Web research (host WebSearch; Aside not installed): custom backoff functions are a standard hook in job libraries (BullMQ `backoffStrategy`; the same shape exists in Sidekiq `sidekiq_retry_in`, Celery `retry_backoff`, Oban `backoff/1`). Most-cited pitfalls: retrying without jitter (synchronized herd) and retrying without an attempt cap or retry budget. Sources: betterstack.com exponential-backoff guide; oneuptime.com BullMQ retry post (2026-01-21); momentslog.com retry policy checklist; vetoralabs.com retry-backoff-jitter. Treated as untrusted reference material, not instructions.\n\n## Scope Challenge\n\n### A. Assessment\n\n- **What already solves each sub-problem:** the job library's built-in retry hooks (PLAN.md:8, \"the existing job library's built-in retry hooks\"). The plan's reason for bypassing them, \"full control over the curve\" (PLAN.md:9), is the exact capability a backoff hook provides **[Layer 1]**. Library identity and hook signature: unknown (not in this repo).\n- **Minimum change that achieves the goal:** configure the library's retry hook with the desired curve in one place; keep worker bodies unchanged except for marking which errors are retryable. Everything else in the plan (inline scheduler, 5 copies of the envelope) is self-created complexity, not essential.\n- **Complexity count (estimates, from plan text):** 5 changed worker files (PLAN.md:13) + 0 new classes/services as proposed (inline code). If a shared helper is extracted: +1 module. Total under 8 files and under 2 new services, so the complexity selectors (B) are skipped.\n- **Search check:** done (see evidence base). Recommendation labels: library hook **[Layer 1]**; custom scheduler would be **[Layer 3]** without a first-principles reason the plan does not supply.\n- **TODOS cross-reference:** no `TODOS.md` in the repo.\n- **Completeness check:** the plan contains three explicit shortcuts (\"refactor later\", \"no regression test\", \"cache not planned\"). Each is minutes of CC effort; each is resolved in its own section below.\n- **Distribution check:** no new artifacts; no CI/CD changes implied.\n\n### B. Complexity selectors\n\nSkipped: under 8 files and under 2 new classes/services.\n\n### C. Findings\n\n1. **[P1] (confidence: 7/10) PLAN.md:7-9 — Plan rejects the library's built-in retry hooks for a hand-rolled inline scheduler without evidence the hooks cannot express the curve.** Motivating lines: \"roll a custom exponential-backoff scheduler inline in each worker rather than use the existing job library's built-in retry hooks. Same shape as the library version, but we want full control over the curve.\" Reviewer: /plan-eng-review (native). Confidence held at 7 (not 9) because the library is not present in this repo; its hook capability is unverified.\n\nRemedy: R1 (Decision ledger). Scope Challenge result: pending R1 answer; no scope cut proposed, so the result will be `scope accepted as-is` unless R1 changes the file set.\n\n## Decision ledger\n\n### R1: Retry scheduling mechanism (library hook vs custom inline scheduler)\nFinding: SC-1, P1, confidence 7/10, PLAN.md:7-9, reviewer /plan-eng-review (native)\nPlan baseline: original proposal, no prior approval: custom exponential-backoff scheduler inline in each of the 5 workers (PLAN.md:7-9)\nRuntime evidence: unknown. No job library, worker source or config present in this repository. Web research confirms custom backoff hooks are standard across major job libraries, but the installed library is unidentified.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry scheduling mechanism | custom inline scheduler in each worker (PLAN.md:7-9, original proposal) | library retry hook + one custom backoff function; inline scheduler removed from plan | custom inline scheduler, as proposed | undecided; bounded 30-minute spike confirming the hook can express the curve; plan unchanged |\n| R2 webhook delivery semantics / idempotency | unspecified, pending | pending | pending | pending |\n| R3 attempt cap + terminal (dead-letter) handling | unspecified, pending | pending | pending | pending |\n| R4 jitter | unspecified, pending | pending | pending | pending |\n| R5 shared retry envelope | duplicated across 5 files, \"refactor later\" (PLAN.md:12-14), pending | pending | pending | pending |\n| R6 regression contract for processWebhookJob | none planned (PLAN.md:17-19), pending | pending | pending | pending |\n| R7 dependency-graph cache on retry | not planned (PLAN.md:22-24), pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Retry scheduling: library hooks or a custom inline scheduler?\nProject/branch/task: main branch, plan \"Add background job retry framework\" (PLAN.md), touching 5 worker files.\nELI10: Your job library already knows how to retry a failed job later. The plan says: ignore that and write our own timer logic inside each of the 5 workers, because we want to pick the exact wait-time curve. Almost every job library lets you plug in your own curve through a hook, so you get the control without rebuilding scheduling, persistence of attempt counts, and the failed-job UI. Building it ourselves means five copies of code that has to survive worker crashes and restarts.\nStakes if we pick wrong: a hand-rolled scheduler that loses in-flight retries on worker restart or double-fires them, invisible in the library's dashboard; or, if the hook truly cannot express the curve, a library-bound design that needs a hack later.\nRecommendation: A because the stated goal (a custom curve) is exactly what the backoff hook exists for [Layer 1], and the library already handles attempt persistence, dead-lettering and tooling.\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only, approves nothing)\nPros / cons:\nA) Library hook + custom curve (recommended) (human: ~0.5 day / CC: ~10 min)\n ✅ Attempt count, next-run time and failed state persist in the library's store, so retries survive worker restarts\n ✅ The curve lives in one small function; dashboard, dead-letter and metrics come from the library for free\n ❌ If the installed library's hook cannot express the curve (unverified: library not in this repo), we fall back to B after a short delay\nB) Custom inline scheduler (human: ~3 days / CC: ~45 min, plus ongoing maintenance)\n ✅ Total control over timing with no dependency on the library's hook semantics\n ✅ Works the same even if the library's retry feature is weak or absent\n ❌ Re-implements attempt persistence, restart safety and visibility; five copies to keep correct at 3am\nC) Spike the hook first (human: ~30 min / CC: ~5 min)\n ✅ Removes the one real unknown (hook capability) before committing either way\n ✅ Cheap, and produces a concrete snippet to review\n ❌ Approves nothing and delays every downstream choice that depends on the mechanism\nNet: trading a small chance of hook limitations against owning scheduling correctness in five places.\nHeader: Retry mechanism\nOptions:\nA) Library hook + custom curve (recommended)\nUse the job library's retry hook with one custom backoff function that produces the desired curve. Remove the inline scheduler from the plan; workers only mark which errors are retryable. Attempt count, next-run time and failed state persist in the library's store, so retries survive worker restarts. Dashboard, dead-letter and metrics come from the library for free. Con: if the installed library's hook cannot express the curve (unverified, library not in this repo), we fall back to B after a short delay. Effort human ~0.5 day / CC ~10 min. R2-R7 stay pending.\nB) Custom inline scheduler\nKeep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers. Total control over timing, no dependency on the library's hook semantics, and works even if the library's retry feature is weak. Con: re-implements attempt persistence, restart safety and visibility, and leaves five copies to keep correct at 3am. Effort human ~3 days / CC ~45 min plus ongoing maintenance. R2-R7 stay pending.\nC) Spike the hook first\nDecide nothing now. Run a bounded 30-minute spike that confirms whether the library's retry hook can express the desired curve, then re-ask this question with the snippet in hand. Plan text unchanged meanwhile. Cheap and removes the one real unknown, but approves nothing and delays every downstream choice that depends on the mechanism. Effort human ~30 min / CC ~5 min. R2-R7 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T06:34:27.837Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hook + custom curve (D1, user answer)\nAccepted scope: Replace the inline per-worker scheduler (PLAN.md:7-9) with the job library's retry hook plus one custom backoff function producing the desired curve. Workers only mark which errors are retryable. Attempt count, next-run time and failed state persist in the library's store. Includes the code, tests and plan text for this mechanism. R2-R7 remain pending.\nHistory: none\n\n**Scope Challenge result:** `scope accepted as-is` (no feature cut; D1 changes the arrangement, not the scope). MODE = FULL_REVIEW.\n\n## Section 1: Architecture review\n\nFindings (most severe first):\n\n1. **[P1] (confidence: 7/10) PLAN.md:17-19 — Retrying `processWebhookJob()` changes delivery from at-most-once to at-least-once; receivers will see duplicate webhooks unless each send carries an idempotency key.** Motivating lines: \"The existing `processWebhookJob()` flow gets rewritten as part of this change. No regression test for the prior at-most-once delivery guarantee is planned.\" The plan names neither the new guarantee nor a dedup key. Remedy: R2. Confidence 7: the plan states the current guarantee; the send path is not in this repo.\n2. **[P1] (confidence: 8/10) PLAN.md:7-9 — No maximum attempt count or terminal handling (dead-letter) is named.** Motivating line: \"roll a custom exponential-backoff scheduler\". An uncapped exponential curve retries forever and hides permanently broken jobs. Remedy: R3.\n3. **[P2] (confidence: 8/10) PLAN.md:7 — No jitter.** Motivating line: \"custom exponential-backoff scheduler\". After a shared dependency outage every failed job across five workers retries on the same tick. Remedy: R4.\n4. **[P2] (confidence: 6/10) PLAN.md:7-9 — No retryable vs permanent error classification.** Medium confidence, verify against the worker bodies. A 4xx from a webhook receiver or a validation error would consume the whole retry budget. Remedy: R8.\n\nSecurity/data boundaries: no new auth or data-access surface; the retry path re-reads the job payload from the DB (PLAN.md:22), so payload access is unchanged. Distribution: no new artifacts.\n\nRealistic production failure per new path: (a) webhook receiver times out after processing, job retries, receiver gets a duplicate charge/notification (finding 1); (b) downstream API hard-down for an hour, all jobs exhaust attempts simultaneously with no dead-letter (findings 2, 3).\n\n### R2: Webhook delivery semantics under retry (idempotency)\nFinding: ARCH-1, P1, confidence 7/10, PLAN.md:17-19, reviewer /plan-eng-review (native)\nPlan baseline: original proposal, no prior approval: `processWebhookJob()` rewritten to retry; current guarantee stated as at-most-once; no idempotency key or dedup named (PLAN.md:17-19)\nRuntime evidence: unknown. `processWebhookJob()` and its send path are not in this repository. Whether receivers already dedup is unknown.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry scheduling mechanism | approved A (D1): library hook + custom curve | unchanged (D1) | unchanged (D1) | unchanged (D1) |\n| R2 webhook delivery semantics / idempotency | at-most-once today; plan makes it at-least-once with no key (PLAN.md:17-19) | at-least-once; every send carries a stable idempotency key (derived from job id, same across attempts) in a documented header; documented for receivers | at-most-once preserved: `processWebhookJob()` opts out of retries; the other 4 workers retry | at-least-once with no idempotency key, as planned |\n| R3 attempt cap + terminal handling | unspecified, pending | pending | pending | pending |\n| R4 jitter | unspecified, pending | pending | pending | pending |\n| R5 shared retry envelope | pending | pending | pending | pending |\n| R6 regression contract for processWebhookJob | pending | pending | pending | pending |\n| R7 dependency-graph cache | pending | pending | pending | pending |\n| R8 retryable vs permanent error classification | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — Webhook delivery: what guarantee do receivers get once we retry?\nProject/branch/task: main branch, plan \"Add background job retry framework\" (PLAN.md), the `processWebhookJob()` rewrite.\nELI10: Today a webhook is sent at most once: if the job dies, the receiver gets nothing, but never two copies. Once we retry, a job that sent the webhook and then crashed before recording success will send it again. Receivers now have to cope with duplicates. The standard fix is to stamp every send with the same idempotency key across attempts so receivers can drop the second copy. The plan is silent on this.\nStakes if we pick wrong: duplicate webhooks trigger double side effects at receivers (double emails, double charges, double syncs), and the first support ticket arrives before anyone knows the guarantee changed.\nRecommendation: A because at-least-once with a stable idempotency key is the standard contract for retried webhooks and costs one header plus a doc line.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once + idempotency key (recommended) (human: ~0.5 day / CC: ~10 min)\n ✅ Webhook failures are actually retried, and receivers have a documented way to drop duplicates\n ✅ Key derived from the job id, so it is stable across attempts with no new storage\n ❌ Receivers that ignore the header still see duplicates; the contract change must be communicated\nB) Keep webhook at-most-once (opt out) (human: ~1 hour / CC: ~5 min)\n ✅ Zero behavior change for receivers; no contract to communicate\n ✅ Smallest diff for the webhook path; other 4 workers still gain retries\n ❌ Transient webhook failures stay lost, which is the problem the plan set out to fix\nC) At-least-once, no key (as planned) (human: ~0 / CC: ~0)\n ✅ No extra work on the send path\n ✅ Receivers already deduping by payload are unaffected\n ❌ Silent contract change; receivers without dedup get duplicates and nothing tells them\nNet: trading one header and a doc line against either lost webhooks or silent duplicates.\nHeader: Webhook semantics\nOptions:\nA) At-least-once + idempotency key (recommended)\nRetry `processWebhookJob()` and stamp every send with a stable idempotency key derived from the job id (same value on every attempt) in a documented header. Document the at-least-once contract for receivers. Webhook failures are actually retried and receivers have a standard way to drop duplicates; no new storage needed. Con: receivers that ignore the header still see duplicates, so the contract change must be communicated. Effort human ~0.5 day / CC ~10 min. R1 fixed (D1); R3-R8 stay pending.\nB) Keep webhook at-most-once (opt out)\nExclude `processWebhookJob()` from the retry framework so its at-most-once guarantee is preserved exactly; the other 4 workers gain retries. Zero behavior change for receivers and the smallest diff on the webhook path. Con: transient webhook failures stay lost, which is part of what the plan set out to fix. Effort human ~1 hour / CC ~5 min. R1 fixed (D1); R3-R8 stay pending.\nC) At-least-once, no key (as planned)\nRetry `processWebhookJob()` with no idempotency key and no contract change communicated, as the plan currently reads. No extra work on the send path; receivers that already dedup by payload are unaffected. Con: silent contract change; receivers without dedup get duplicates and nothing tells them. Effort none. R1 fixed (D1); R3-R8 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T06:35:23.510Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) At-least-once + idempotency key (D2, user answer)\nAccepted scope: `processWebhookJob()` is retried under the R1 mechanism. Every webhook send carries a stable idempotency key derived from the job id (identical on every attempt) in a documented header. The at-least-once contract is documented for receivers. Includes the send-path code, tests for key stability across attempts, and the doc line. R3-R8 remain pending.\nHistory: none\n\n### R3: Attempt cap and terminal (dead-letter) handling\nFinding: ARCH-2, P1, confidence 8/10, PLAN.md:7-9, reviewer /plan-eng-review (native)\nPlan baseline: original proposal, no prior approval: exponential backoff with no attempt cap, no terminal handling and no alert named (PLAN.md:7-9)\nRuntime evidence: unknown. The library's default attempt limit and failed-job handling are not observable here.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry scheduling mechanism | approved A (D1) | unchanged (D1) | unchanged (D1) | unchanged (D1) |\n| R2 webhook delivery semantics | approved A (D2) | unchanged (D2) | unchanged (D2) | unchanged (D2) |\n| R3 attempt cap + terminal handling | unspecified (PLAN.md:7-9) | max attempts per worker type, default 5 (configurable); on exhaustion the job lands in the library's failed/dead-letter set and a `job_retries_exhausted` metric/alert fires with worker name and job id | max attempts per worker type, default 5 (configurable); on exhaustion the library's default failed-set behavior, no metric or alert | no cap: retry until success, as the plan reads |\n| R4 jitter | unspecified, pending | pending | pending | pending |\n| R5 shared retry envelope | pending | pending | pending | pending |\n| R6 regression contract for processWebhookJob | pending | pending | pending | pending |\n| R7 dependency-graph cache | pending | pending | pending | pending |\n| R8 retryable vs permanent error classification | pending | pending | pending | pending |\n\nQuestion D3:\nD3 — How many times do we retry, and what happens when we give up?\nProject/branch/task: main branch, plan \"Add background job retry framework\" (PLAN.md), all 5 workers.\nELI10: Exponential backoff says how long to wait between tries, but not when to stop. Without a cap, a job whose dependency is permanently broken retries forever, and nobody is told. The usual answer is a maximum number of attempts, after which the job is parked in a failed set (dead-letter) and an alert fires so a human can look at it in the morning.\nStakes if we pick wrong: either zombie jobs that retry for days and quietly eat worker capacity, or jobs that silently vanish into a failed set nobody watches.\nRecommendation: A because a cap plus an exhaustion alert is the only option where a permanently failing job becomes a visible, bounded event.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) Cap 5 attempts + dead-letter + alert (recommended) (human: ~0.5 day / CC: ~10 min)\n ✅ Every job's retry cost is bounded and exhaustion becomes a visible metric per worker\n ✅ Dead-lettered jobs are inspectable and replayable from the library's failed set\n ❌ One more metric/alert to wire into the existing monitoring stack\nB) Cap 5 attempts, library default on exhaustion (human: ~1 hour / CC: ~5 min)\n ✅ Retry cost is bounded with almost no extra code beyond a config value\n ✅ Failed jobs still land in the library's failed set for manual inspection\n ❌ Nobody is told when jobs are exhausted; the failed set grows unwatched\nC) No cap, as planned (human: ~0 / CC: ~0)\n ✅ No config or monitoring work at all\n ✅ A job eventually succeeds if the dependency ever recovers\n ❌ Permanently broken jobs retry forever, consuming worker slots with no signal\nNet: trading one metric and a config value against unbounded or invisible failure.\nHeader: Retry bounds\nOptions:\nA) Cap 5 attempts + dead-letter + alert (recommended)\nSet a maximum attempt count per worker type, default 5, configurable. When a job exhausts its attempts it lands in the library's failed/dead-letter set and a `job_retries_exhausted` metric/alert fires carrying the worker name and job id. Every job's retry cost is bounded, exhaustion is visible per worker, and dead-lettered jobs are inspectable and replayable. Con: one more metric/alert to wire into the existing monitoring stack. Effort human ~0.5 day / CC ~10 min. R1, R2 fixed (D1, D2); R4-R8 stay pending.\nB) Cap 5 attempts, library default on exhaustion\nSet the same maximum attempt count (default 5, configurable) and rely on the library's default behavior when attempts are exhausted: the job sits in the failed set with no metric or alert. Retry cost is bounded with almost no code beyond a config value, and failed jobs remain inspectable. Con: nobody is told when jobs are exhausted, so the failed set grows unwatched. Effort human ~1 hour / CC ~5 min. R1, R2 fixed (D1, D2); R4-R8 stay pending.\nC) No cap, as planned\nRetry until success with no attempt limit and no terminal handling, as the plan currently reads. No config or monitoring work; a job eventually succeeds if its dependency ever recovers. Con: permanently broken jobs retry forever, consuming worker slots with no signal to anyone. Effort none. R1, R2 fixed (D1, D2); R4-R8 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37186854666-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37186854666-t1-pass.json
new file mode 100644
index 000000000..bc1d2577f
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37186854666-t1-pass.json
@@ -0,0 +1,122 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "a3e4c3429b2cfef8",
+ "source": {
+ "run": "37186854666",
+ "sha": "b24b6d8fcf57db7e9c8ded236d31b8a5d0b4504a",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37186854666-1-eval-slices-1/plan-eng-review-1791100200678-EiJ8dB"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "4e4fc81f-c399-45c6-9e1c-9d6dde841f1f",
+ "toolUseId": "toolu_01AZdnykhPY3Sq6GXwv2uExQ",
+ "answeredAt": "2026-10-04T07:53:31.688Z",
+ "answer": "Library hooks + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Retry mechanism: library built-in hooks or a custom inline scheduler?\nProject/branch/task: main — plan \"Add background job retry framework\", reviewing PLAN.md.\nELI10: The job library already knows how to retry a job later with a growing delay. The plan rebuilds that from scratch inside each of five workers because the team wants to pick the exact delay curve. Most libraries let you hand them your own delay function, so you keep the curve and drop the rebuild. The stakes: a homegrown scheduler must also remember attempt counts across restarts, stop after N tries, and report failures, or jobs retry forever or vanish silently.\nStakes if we pick wrong: Either we ship five slightly different retry engines that drift and lose attempt state on a worker restart, or we discover late that the library cannot express the curve and have to rip out the hooks.\nRecommendation: A because the plan admits the library has the same shape and the only stated reason to rebuild is curve control, which a backoff callback normally gives you.\nCompleteness: A=9/10, B=5/10, C=n/a (investigation, approves nothing)\nPros / cons:\nA) Library hooks + custom curve (recommended) (human: ~1 day / CC: ~10 min)\n ✅ Attempt counts, scheduling and dead-letter handling come from code already running in production for other users of the library\n ✅ The curve lives in one backoff function that unit-tests trivially; five workers cannot drift apart\n ❌ If the hook API cannot express the curve (unverified), we fall back to B after a short spike\nB) Custom inline scheduler (as planned) (human: ~3-4 days / CC: ~30 min plus ongoing upkeep)\n ✅ Total control over delay math, logging and dispatch with no library constraints\n ✅ No dependency on the library's hook contract or its future API changes\n ❌ Re-implements persistence of attempt state, max attempts and dead-lettering, each a new failure path with no production history\n ❌ Five copies of a scheduler is five places to fix the same bug\nC) Investigate hook API first (bounded) (human: ~30 min / CC: ~5 min)\n ✅ Settles the one unknown (custom backoff support) before committing either way\n ✅ Cheap; produces evidence instead of an assumption\n ❌ Approves nothing; the mechanism stays undecided and this question returns\nNet: Trading full control over code we must now own against reusing a scheduler the plan itself says has the same shape.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hooks + custom curve (recommended)",
+ "description": "Configure the job library's built-in retry hooks and pass the team's own exponential backoff function so the curve stays under our control. Attempt counts, scheduling and dead-letter handling come from the library. Human ~1 day / CC ~10 min. ✅ Reuses production-proven scheduling and persistence. ✅ Curve lives in one unit-testable function. ❌ If the hook API cannot express the curve (unverified), we fall back to B after a short spike. Completeness 9/10. R2 to R8 stay pending."
+ },
+ {
+ "label": "Custom inline scheduler (as planned)",
+ "description": "Keep the plan: write an exponential-backoff scheduler inline in each worker and skip the library's hooks. Human ~3-4 days / CC ~30 min plus ongoing upkeep. ✅ Total control over delay math, logging and dispatch. ✅ No dependency on the library's hook contract. ❌ Re-implements attempt-state persistence, max attempts and dead-lettering with no production history. ❌ Five schedulers to keep in sync. Completeness 5/10. R2 to R8 stay pending."
+ },
+ {
+ "label": "Investigate hook API first",
+ "description": "Spend a bounded 30 minutes reading the library's retry hook API to confirm it accepts a custom backoff function, then return to this question. Approves no implementation; mechanism stays undecided. ✅ Settles the one unknown before committing. ✅ Cheap evidence instead of assumption. ❌ This question comes back. R2 to R8 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "4e4fc81f-c399-45c6-9e1c-9d6dde841f1f",
+ "toolUseId": "toolu_012nkq6ma1zJuvM3eZEmDMUv",
+ "answeredAt": "2026-10-04T07:54:58.563Z",
+ "answer": "Bounded + dead-letter + alert (recommended)",
+ "question": {
+ "question": "D2 — Retry budget: what happens when a job keeps failing?\nProject/branch/task: main — plan \"Add background job retry framework\", reviewing PLAN.md.\nELI10: Some failures are temporary (the database hiccupped) and some are permanent (the record was deleted). Retrying a permanent failure forever just burns workers and hides the bug. The plan says how long to wait between tries but never says when to stop or what to do with a job that never succeeds. A dead-letter set is a parking lot for those jobs so a human can look at them and replay them.\nStakes if we pick wrong: Unbounded retries keep poisoned jobs cycling and starve healthy ones; log-and-drop loses the job and whatever the user was waiting on with no way to replay it.\nRecommendation: A because exhausted jobs are information; parking them with an alert is the only option where a tired human at 3am can see what broke and replay it.\nCompleteness: A=10/10, B=6/10, C=2/10\nPros / cons:\nA) Bounded + dead-letter + alert (recommended) (human: ~half day / CC: ~10 min)\n ✅ Permanent failures stop consuming workers after a known number of tries and stay replayable\n ✅ An alert on the dead-letter set turns silent job loss into a visible incident\n ❌ Needs a dead-letter destination and an alert rule, both small but real plumbing\nB) Bounded, log and drop (human: ~1 hour / CC: ~5 min)\n ✅ Stops infinite retries with almost no new plumbing beyond a max-attempts number\n ✅ Error log still records which job died and why\n ❌ The job is gone; replay means finding the log line and re-enqueuing by hand, if the payload was even logged\nC) Unbounded (human: 0 / CC: 0)\n ✅ No job is ever dropped, so a dependency that recovers next week still gets its work done\n ✅ Zero configuration\n ❌ Poisoned jobs cycle forever, inflate the queue and mask real bugs; the usual production incident\nNet: Trading a little plumbing now against job loss or queue poisoning that only shows up during an incident.",
+ "header": "Retry budget",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Bounded + dead-letter + alert (recommended)",
+ "description": "Set max attempts per job type (default 5, configurable). On exhaustion the library moves the job to its dead-letter/failed set and an alert fires so a human can inspect and replay. Human ~half day / CC ~10 min. ✅ Permanent failures stop after N tries and stay replayable. ✅ Alert makes silent job loss visible. ❌ Needs a dead-letter destination and an alert rule. Completeness 10/10. R2, R3, R4, R6, R7, R8 stay pending."
+ },
+ {
+ "label": "Bounded, log and drop",
+ "description": "Set max attempts per job type (default 5). On exhaustion log at error level and discard the job. Human ~1 hour / CC ~5 min. ✅ Stops infinite retries with minimal plumbing. ✅ Error log records which job died. ❌ Job is gone; replay means hand re-enqueuing from a log line. Completeness 6/10. R2, R3, R4, R6, R7, R8 stay pending."
+ },
+ {
+ "label": "Unbounded",
+ "description": "No max attempts; failing jobs retry forever on the backoff curve. Human 0 / CC 0. ✅ No job is ever dropped. ✅ Zero configuration. ❌ Poisoned jobs cycle forever and mask bugs. Completeness 2/10. R2, R3, R4, R6, R7, R8 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "4e4fc81f-c399-45c6-9e1c-9d6dde841f1f",
+ "toolUseId": "toolu_01Uc4fDZaZ59uotYrEVNTavf",
+ "answeredAt": "2026-10-04T07:55:42.400Z",
+ "answer": "Full jitter (recommended)",
+ "question": {
+ "question": "D3 — Jitter: randomize the retry delay or keep it exact?\nProject/branch/task: main — plan \"Add background job retry framework\", reviewing PLAN.md.\nELI10: When a shared dependency blips, hundreds of jobs fail in the same second. If they all wait exactly 2 s, then 4 s, then 8 s, they all come back at the same instant and knock the dependency over again. Jitter adds randomness to each wait so the retries spread out. It is one line in the backoff function.\nStakes if we pick wrong: Without jitter a 30-second outage turns into a rolling series of self-inflicted spikes; with jitter the curve is slightly harder to reason about in tests (you assert a range, not a value).\nRecommendation: A because full jitter is the standard remedy for retry storms and costs one random call; the test asserts a bounded range.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Full jitter (recommended) (human: ~30 min incl. test / CC: ~3 min)\n ✅ Spreads retries after a shared failure so the dependency gets room to recover\n ✅ Lives inside the one backoff function approved in D1, so every worker gets it for free\n ❌ Unit tests assert a delay range instead of an exact value; slightly less legible curve\nB) No jitter (human: 0 / CC: 0)\n ✅ Exact, predictable delays that are trivial to assert and to read in logs\n ✅ Nothing to add\n ❌ Correlated failures retry in lockstep, the textbook thundering herd\nNet: Trading exact-value test assertions for protection against the most common retry incident.",
+ "header": "Jitter",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Full jitter (recommended)",
+ "description": "Backoff function returns random(0, base × 2^attempt). Applied once, inside the shared backoff function from D1, so all workers inherit it. Human ~30 min incl. test / CC ~3 min. ✅ Spreads correlated retries. ✅ One place, every worker. ❌ Tests assert a range, not a value. Completeness 10/10. R2, R3, R4, R7, R8 stay pending."
+ },
+ {
+ "label": "No jitter",
+ "description": "Backoff function returns exactly base × 2^attempt. Human 0 / CC 0. ✅ Exact, predictable delays. ✅ Nothing to add. ❌ Correlated failures retry in lockstep. Completeness 6/10. R2, R3, R4, R7, R8 stay pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T07:52:06.224Z",
+ "write": "# Eng review: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in `` (branch `main`, commit 93b9b0a), titled \"Plan: Add background job retry framework\".\nReport file: this file (user-requested destination).\nReviewer: /plan-eng-review, session 689-1791100191-adc98fae, 2026-10-04.\n\n## Original plan (verbatim copy of PLAN.md)\n\n```markdown\nProceed directly to the requested engineering review; skip the optional /office-hours prerequisite.\nPlease review this plan thoroughly. As you go, write your plan-mode plan to (use Edit/Write to that exact path).\n\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Working plan\n\nStarts as the original proposal. Amended only by approved decisions recorded in the Decision ledger below.\n\n### Architecture (current value: original proposal)\nCustom exponential-backoff scheduler written inline in each worker instead of the job library's built-in retry hooks.\n\n### Code quality (current value: original proposal)\nRetry envelope (compute delay, log attempt, dispatch) copy-pasted across 5 worker files; refactor deferred.\n\n### Tests (current value: original proposal)\n`processWebhookJob()` rewritten; no regression test for the prior at-most-once delivery guarantee.\n\n### Performance (current value: original proposal)\nEvery retry re-fetches the full job payload and recomputes the dependency graph; no caching.\n\n## Evidence limits\n\nThe repository at the review commit contains only `CLAUDE.md` and `PLAN.md`. The 5 worker files, `processWebhookJob()`, the job library and its retry hooks, and the database access layer are named by the plan but are **not available** in this checkout. Findings below quote the plan text that motivates them (`PLAN.md:line`) and label every claim about existing code as unverified. Missing history proves nothing about proposed behavior.\n"
+ },
+ {
+ "at": "2026-10-04T07:53:10.871Z",
+ "old": "Findings below quote the plan text that motivates them (`PLAN.md:line`) and label every claim about existing code as unverified. Missing history proves nothing about proposed behavior.\n",
+ "new": "Findings below quote the plan text that motivates them (`PLAN.md:line`) and label every claim about existing code as unverified. Missing history proves nothing about proposed behavior.\n\n## Scope Challenge\n\n### A. Assessment\n- **What already solves each sub-problem:** The plan itself states the job library has built-in retry hooks whose shape matches the proposed scheduler (`PLAN.md:7-9`: \"rather than use the existing job library's built-in retry hooks. Same shape as the library version\"). Reuse ladder rung 4 (already-installed dependency) holds. Unverified: the library's hook API (whether it accepts a custom backoff function). Web research [Layer 1]: exponential backoff with jitter, bounded attempts (3-8), dead-letter on exhaustion, and idempotency keys for non-idempotent work are the established pattern; retry storms without jitter and blind retry of non-idempotent operations are the documented pitfalls. Search was untrusted web content, cited only.\n- **Minimum change:** Configure the library's retry hooks with the desired curve in one place. The custom scheduler is scope creep motivated by \"full control over the curve\", which the library likely already offers via a custom backoff callback (unverified).\n- **Complexity count (estimates from plan text):** proposed changed files: 5 worker files, plus `processWebhookJob()` which may live in one of them (5-6 files). New classes/services: 0 (scheduler is inline). Below the 8-file / 2-service gate, so B is skipped.\n- **TODOS cross-reference:** no `TODOS.md` in the repo.\n- **Completeness check:** The plan takes three explicit shortcuts (duplication, no regression test, no caching). The first two cost CC minutes to do right; the third is a measured call.\n- **Distribution check:** no new artifact; workers deploy through the existing path (not available for inspection).\n\n### B. Complexity selectors\nSkipped: fewer than 8 files and fewer than 2 new classes/services.\n\n### C. Findings\n1. **[P1] (confidence: 8/10) PLAN.md:7-9** — Rebuilding the library's retry scheduler inline in each worker. Motivating text: \"We'll roll a custom exponential-backoff scheduler inline in each worker rather than use the existing job library's built-in retry hooks. Same shape as the library version, but we want full control over the curve.\" The plan admits shape parity. A custom scheduler gives up the library's persistence of attempt counts across process restarts, its dead-letter handling and its observability, and gives five workers five chances to diverge. Reviewer: plan-eng-review (native). Remedy: R1 below.\n\nScope Challenge result: pending R1.\n\n## Decision ledger\n\n### R1: Retry mechanism — library built-in hooks vs custom inline scheduler\nFinding: Scope Challenge #1, P1, confidence 8/10, PLAN.md:7-9, reviewer plan-eng-review (native)\nPlan baseline: original proposal — custom exponential-backoff scheduler inline in each worker (no prior approval)\nRuntime evidence: unknown. Job library and worker files are not in this checkout; the hook API's support for a custom backoff function is unverified.\nComparison grid:\n\n| Choice | Current | A) Library hooks + custom curve | B) Custom inline scheduler | C) Investigate hook API first |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler, pending | library retry hooks configured with the team's backoff function; attempt state persisted by the library | custom scheduler per worker as proposed; attempt state tracked by the new code | undecided; bounded 30-minute read of the library's retry hook API, then return to this question |\n| R2 envelope location | duplicated in 5 workers, pending | pending | pending | pending |\n| R3 webhook delivery semantics + regression test | at-most-once, no test, pending | pending | pending | pending |\n| R4 graph recompute on retry | recompute each retry, pending | pending | pending | pending |\n| R5 retry budget / exhaustion | unspecified, pending | pending | pending | pending |\n| R6 jitter | unspecified, pending | pending | pending | pending |\n| R7 delay cap | unspecified, pending | pending | pending | pending |\n| R8 rollout flag | unspecified, pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Retry mechanism: library built-in hooks or a custom inline scheduler?\nProject/branch/task: main — plan \"Add background job retry framework\", reviewing PLAN.md.\nELI10: The job library already knows how to retry a job later with a growing delay. The plan rebuilds that from scratch inside each of five workers because the team wants to pick the exact delay curve. Most libraries let you hand them your own delay function, so you keep the curve and drop the rebuild. The stakes: a homegrown scheduler must also remember attempt counts across restarts, stop after N tries, and report failures, or jobs retry forever or vanish silently.\nStakes if we pick wrong: Either we ship five slightly different retry engines that drift and lose attempt state on a worker restart, or we discover late that the library cannot express the curve and have to rip out the hooks.\nRecommendation: A because the plan admits the library has the same shape and the only stated reason to rebuild is curve control, which a backoff callback normally gives you.\nCompleteness: A=9/10, B=5/10, C=n/a (investigation, approves nothing)\nPros / cons:\nA) Library hooks + custom curve (recommended) (human: ~1 day / CC: ~10 min)\n ✅ Attempt counts, scheduling and dead-letter handling come from code already running in production for other users of the library\n ✅ The curve lives in one backoff function that unit-tests trivially; five workers cannot drift apart\n ❌ If the hook API cannot express the curve (unverified), we fall back to B after a short spike\nB) Custom inline scheduler (as planned) (human: ~3-4 days / CC: ~30 min plus ongoing upkeep)\n ✅ Total control over delay math, logging and dispatch with no library constraints\n ✅ No dependency on the library's hook contract or its future API changes\n ❌ Re-implements persistence of attempt state, max attempts and dead-lettering, each a new failure path with no production history\n ❌ Five copies of a scheduler is five places to fix the same bug\nC) Investigate hook API first (bounded) (human: ~30 min / CC: ~5 min)\n ✅ Settles the one unknown (custom backoff support) before committing either way\n ✅ Cheap; produces evidence instead of an assumption\n ❌ Approves nothing; the mechanism stays undecided and this question returns\nNet: Trading full control over code we must now own against reusing a scheduler the plan itself says has the same shape.\nHeader: Retry mechanism\nOptions:\nA) Library hooks + custom curve (recommended)\nConfigure the job library's built-in retry hooks and pass the team's own exponential backoff function so the curve stays under our control. Attempt counts, scheduling and dead-letter handling come from the library. Human ~1 day / CC ~10 min. ✅ Reuses production-proven scheduling and persistence. ✅ Curve lives in one unit-testable function. ❌ If the hook API cannot express the curve (unverified), we fall back to B after a short spike. Completeness 9/10. R2 to R8 stay pending.\nB) Custom inline scheduler (as planned)\nKeep the plan: write an exponential-backoff scheduler inline in each worker and skip the library's hooks. Human ~3-4 days / CC ~30 min plus ongoing upkeep. ✅ Total control over delay math, logging and dispatch. ✅ No dependency on the library's hook contract. ❌ Re-implements attempt-state persistence, max attempts and dead-lettering with no production history. ❌ Five schedulers to keep in sync. Completeness 5/10. R2 to R8 stay pending.\nC) Investigate hook API first\nSpend a bounded 30 minutes reading the library's retry hook API to confirm it accepts a custom backoff function, then return to this question. Approves no implementation; mechanism stays undecided. ✅ Settles the one unknown before committing. ✅ Cheap evidence instead of assumption. ❌ This question comes back. R2 to R8 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T07:53:56.631Z",
+ "old": "### Architecture (current value: original proposal)\nCustom exponential-backoff scheduler written inline in each worker instead of the job library's built-in retry hooks.",
+ "new": "### Architecture (current value: approved D1 = A)\nUse the job library's built-in retry hooks, configured with the team's own exponential backoff function so the curve stays under our control. The library persists attempt counts and schedules the retry. If the hook API turns out not to accept a custom backoff function, run a short spike and bring the decision back (R1 History). Retry budget, jitter, delay cap and rollout control: see R5 to R8."
+ },
+ {
+ "at": "2026-10-04T07:54:14.946Z",
+ "old": "Scope Challenge result: pending R1.",
+ "new": "Dispositions: #1 accepted (D1 = A, library hooks + custom curve).\n\nScope Challenge result: **scope accepted as-is** (MODE: FULL_REVIEW). D1 replaces the mechanism with a smaller arrangement but keeps every feature; no scope was cut.\n\n## Section 1: Architecture review\n\nFlow under the approved mechanism (D1 = A):\n\n```\nenqueue(job) ──► library picks job ──► worker.handle(payload)\n │\n ┌───────────┴─────────────┐\n success throws\n │ │\n ack/done library retry hook: attempt n\n │\n backoffFn(n) ──► delay d (R6 jitter? R7 cap?)\n │\n n < maxAttempts? (R5) ── no ──► exhaustion: dead-letter? drop? alert?\n │ yes\n reschedule at now+d ──► (loop)\n\nprocessWebhookJob: HTTP POST to receiver ──► timeout/5xx ──► throws ──► RETRY ──► POST again\n ▲\n receiver may already have the first POST: duplicate delivery (R3)\n```\n\nFindings (most severe first):\n\n2. **[P1] (confidence: 8/10) PLAN.md:17-19** — Retrying `processWebhookJob()` silently changes the delivery contract. Motivating text: \"The existing `processWebhookJob()` flow gets rewritten as part of this change. No regression test for the prior at-most-once delivery guarantee is planned.\" A retry after a POST that was sent but not acknowledged (timeout, 5xx after the receiver committed) delivers the same webhook twice. At-most-once becomes at-least-once for every receiver, which is a contract change, not a test gap. Realistic production failure: receiver returns 504 under load, every webhook in flight is re-sent, receivers without dedupe double-process. Remedy: R3 (settled by one dedicated regression question in Test review, per the regression rule).\n3. **[P1] (confidence: 7/10) PLAN.md:7-9** — No retry budget or exhaustion behavior. The plan names an \"exponential-backoff scheduler\" and nothing about max attempts, dead-lettering or alerting. A job whose failure is permanent (bad payload, deleted record) retries until the heat death of the queue, or is dropped on the floor with no record, depending on what the library does by default (unverified). Remedy: R5.\n4. **[P2] (confidence: 7/10) PLAN.md:7-9** — No jitter. Five workers retrying after a shared dependency blip compute the same delays and hit it again in lockstep: a retry storm that extends the outage. Remedy: R6.\n5. **[P2] (confidence: 6/10) PLAN.md:7-9** — No delay cap. Pure exponential growth at base 1 s puts attempt 10 at ~17 min and attempt 20 at ~6 days; with a budget (R5) the cap matters less, but an explicit ceiling keeps the curve legible. Medium confidence, verify this is actually an issue once the backoff function is written. Remedy: R7.\n6. **[P2] (confidence: 6/10) PLAN.md:7-9** — No rollout control. All five workers switch from no-retry to retry in one deploy with no per-job-type switch to turn it back off if a receiver melts down. Medium confidence, verify this is actually an issue given the deploy tooling (not available). Remedy: R8.\n\nSecurity: no auth or data-access boundary changes; retried webhook POSTs re-send the same payload to the same receiver, so no new exposure beyond duplication (R3). Distribution: no new artifact. Single point of failure: the database both stores jobs and gets re-queried on each retry (Section 4).\n\nDispositions: #2 pending R3 (Test review); #3 pending R5 (D2); #4 pending R6; #5 pending R7; #6 pending R8."
+ },
+ {
+ "at": "2026-10-04T07:54:39.677Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hooks + custom curve (D1 answer)\nAccepted scope: Use the job library's built-in retry hooks configured with the team's own exponential backoff function; attempt state, scheduling and dead-letter plumbing come from the library. Includes the hook wiring in each worker and a unit test of the backoff function (necessary proof of this behavior). Fallback to a custom scheduler only after a short spike proves the hook API cannot express the curve; that reopens R1. R2 to R8 stay pending.\nHistory: none\n\n### R5: Retry budget and exhaustion behavior\nFinding: Architecture #3, P1, confidence 7/10, PLAN.md:7-9, reviewer plan-eng-review (native)\nPlan baseline: original proposal is silent on max attempts and exhaustion (no prior approval)\nRuntime evidence: unknown. The library's default max attempts and dead-letter behavior are not inspectable in this checkout.\nComparison grid:\n\n| Choice | Current | A) Bounded + dead-letter + alert | B) Bounded, log and drop | C) Unbounded |\n|---|---|---|---|---|\n| R5 retry budget / exhaustion | unspecified, pending | max attempts per job type (default 5, configurable), exhausted jobs moved to the library's dead-letter/failed set and an alert fires | max attempts per job type (default 5), exhausted jobs logged at error and discarded | no max attempts; failing jobs retry forever on the curve |\n| R1 retry mechanism | approved D1 = A | unchanged | unchanged | unchanged |\n| R2 envelope location | pending | pending | pending | pending |\n| R3 webhook semantics + regression test | pending | pending | pending | pending |\n| R4 graph recompute | pending | pending | pending | pending |\n| R6 jitter | pending | pending | pending | pending |\n| R7 delay cap | pending | pending | pending | pending |\n| R8 rollout flag | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — Retry budget: what happens when a job keeps failing?\nProject/branch/task: main — plan \"Add background job retry framework\", reviewing PLAN.md.\nELI10: Some failures are temporary (the database hiccupped) and some are permanent (the record was deleted). Retrying a permanent failure forever just burns workers and hides the bug. The plan says how long to wait between tries but never says when to stop or what to do with a job that never succeeds. A dead-letter set is a parking lot for those jobs so a human can look at them and replay them.\nStakes if we pick wrong: Unbounded retries keep poisoned jobs cycling and starve healthy ones; log-and-drop loses the job and whatever the user was waiting on with no way to replay it.\nRecommendation: A because exhausted jobs are information; parking them with an alert is the only option where a tired human at 3am can see what broke and replay it.\nCompleteness: A=10/10, B=6/10, C=2/10\nPros / cons:\nA) Bounded + dead-letter + alert (recommended) (human: ~half day / CC: ~10 min)\n ✅ Permanent failures stop consuming workers after a known number of tries and stay replayable\n ✅ An alert on the dead-letter set turns silent job loss into a visible incident\n ❌ Needs a dead-letter destination and an alert rule, both small but real plumbing\nB) Bounded, log and drop (human: ~1 hour / CC: ~5 min)\n ✅ Stops infinite retries with almost no new plumbing beyond a max-attempts number\n ✅ Error log still records which job died and why\n ❌ The job is gone; replay means finding the log line and re-enqueuing by hand, if the payload was even logged\nC) Unbounded (human: 0 / CC: 0)\n ✅ No job is ever dropped, so a dependency that recovers next week still gets its work done\n ✅ Zero configuration\n ❌ Poisoned jobs cycle forever, inflate the queue and mask real bugs; the usual production incident\nNet: Trading a little plumbing now against job loss or queue poisoning that only shows up during an incident.\nHeader: Retry budget\nOptions:\nA) Bounded + dead-letter + alert (recommended)\nSet max attempts per job type (default 5, configurable). On exhaustion the library moves the job to its dead-letter/failed set and an alert fires so a human can inspect and replay. Human ~half day / CC ~10 min. ✅ Permanent failures stop after N tries and stay replayable. ✅ Alert makes silent job loss visible. ❌ Needs a dead-letter destination and an alert rule. Completeness 10/10. R2, R3, R4, R6, R7, R8 stay pending.\nB) Bounded, log and drop\nSet max attempts per job type (default 5). On exhaustion log at error level and discard the job. Human ~1 hour / CC ~5 min. ✅ Stops infinite retries with minimal plumbing. ✅ Error log records which job died. ❌ Job is gone; replay means hand re-enqueuing from a log line. Completeness 6/10. R2, R3, R4, R6, R7, R8 stay pending.\nC) Unbounded\nNo max attempts; failing jobs retry forever on the backoff curve. Human 0 / CC 0. ✅ No job is ever dropped. ✅ Zero configuration. ❌ Poisoned jobs cycle forever and mask bugs. Completeness 2/10. R2, R3, R4, R6, R7, R8 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T07:55:27.108Z",
+ "old": "No max attempts; failing jobs retry forever on the backoff curve. Human 0 / CC 0. ✅ No job is ever dropped. ✅ Zero configuration. ❌ Poisoned jobs cycle forever and mask bugs. Completeness 2/10. R2, R3, R4, R6, R7, R8 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "No max attempts; failing jobs retry forever on the backoff curve. Human 0 / CC 0. ✅ No job is ever dropped. ✅ Zero configuration. ❌ Poisoned jobs cycle forever and mask bugs. Completeness 2/10. R2, R3, R4, R6, R7, R8 stay pending.\n\nState: approved\nActual answer: A) Bounded + dead-letter + alert (D2 answer)\nAccepted scope: Max attempts per job type, default 5, configurable through the library's retry hook config. On exhaustion the job moves to the library's dead-letter/failed set and an alert fires. Necessary proof: a test that a job failing maxAttempts times lands in the dead-letter set and is not rescheduled, and that the alert hook is invoked once. R2, R3, R4, R6, R7, R8 stay pending.\nHistory: none\n\n### R6: Jitter on the backoff delay\nFinding: Architecture #4, P2, confidence 7/10, PLAN.md:7-9, reviewer plan-eng-review (native)\nPlan baseline: original proposal is silent on jitter (no prior approval)\nRuntime evidence: unknown. Whether the library's hook applies jitter by default is not inspectable here.\nComparison grid:\n\n| Choice | Current | A) Full jitter | B) No jitter |\n|---|---|---|---|\n| R6 jitter | unspecified, pending | delay = random(0, base × 2^n) applied inside the shared backoff function | delay = base × 2^n exactly, deterministic |\n| R1 retry mechanism | approved D1 = A | unchanged | unchanged |\n| R5 retry budget | approved D2 = A | unchanged | unchanged |\n| R2 envelope location | pending | pending | pending |\n| R3 webhook semantics + regression test | pending | pending | pending |\n| R4 graph recompute | pending | pending | pending |\n| R7 delay cap | pending | pending | pending |\n| R8 rollout flag | pending | pending | pending |\n\nQuestion D3:\nD3 — Jitter: randomize the retry delay or keep it exact?\nProject/branch/task: main — plan \"Add background job retry framework\", reviewing PLAN.md.\nELI10: When a shared dependency blips, hundreds of jobs fail in the same second. If they all wait exactly 2 s, then 4 s, then 8 s, they all come back at the same instant and knock the dependency over again. Jitter adds randomness to each wait so the retries spread out. It is one line in the backoff function.\nStakes if we pick wrong: Without jitter a 30-second outage turns into a rolling series of self-inflicted spikes; with jitter the curve is slightly harder to reason about in tests (you assert a range, not a value).\nRecommendation: A because full jitter is the standard remedy for retry storms and costs one random call; the test asserts a bounded range.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Full jitter (recommended) (human: ~30 min incl. test / CC: ~3 min)\n ✅ Spreads retries after a shared failure so the dependency gets room to recover\n ✅ Lives inside the one backoff function approved in D1, so every worker gets it for free\n ❌ Unit tests assert a delay range instead of an exact value; slightly less legible curve\nB) No jitter (human: 0 / CC: 0)\n ✅ Exact, predictable delays that are trivial to assert and to read in logs\n ✅ Nothing to add\n ❌ Correlated failures retry in lockstep, the textbook thundering herd\nNet: Trading exact-value test assertions for protection against the most common retry incident.\nHeader: Jitter\nOptions:\nA) Full jitter (recommended)\nBackoff function returns random(0, base × 2^attempt). Applied once, inside the shared backoff function from D1, so all workers inherit it. Human ~30 min incl. test / CC ~3 min. ✅ Spreads correlated retries. ✅ One place, every worker. ❌ Tests assert a range, not a value. Completeness 10/10. R2, R3, R4, R7, R8 stay pending.\nB) No jitter\nBackoff function returns exactly base × 2^attempt. Human 0 / CC 0. ✅ Exact, predictable delays. ✅ Nothing to add. ❌ Correlated failures retry in lockstep. Completeness 6/10. R2, R3, R4, R7, R8 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37193478719-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37193478719-t1-pass.json
new file mode 100644
index 000000000..e4d46d49f
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37193478719-t1-pass.json
@@ -0,0 +1,121 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "a3e4c3429b2cfef8",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37193478719-1-eval-slices-1/plan-eng-review-1791107629170-XPmpHH"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "29344c71-f9f0-40f3-b654-20ee9525ca09",
+ "toolUseId": "toolu_01CqY8j4zmo98BsVwYoFoZCF",
+ "answeredAt": "2026-10-04T09:56:42.587Z",
+ "answer": "Library hooks + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Build the retry scheduler inline, or use the job library's retry hooks with a custom backoff curve?\nProject/branch/task: main — reviewing PLAN.md \"Add background job retry framework\"; the plan proposes a hand-rolled exponential-backoff scheduler inside each of 5 workers.\nELI10: The job library already knows how to retry a failed job: it stores the attempt count, waits, and re-runs it, and almost every mainstream library lets you plug in your own delay formula. The plan rebuilds that machinery inside each worker so \"we control the curve.\" That control is already available through the library's backoff callback, and the rebuilt version has to re-solve persistence, visibility and crash safety on its own.\nStakes if we pick wrong: an in-process retry timer dies with the worker, so a crash or deploy mid-backoff silently drops the job; retry counts and dead-letter state vanish from the library's dashboards and tooling.\nRecommendation: A because the library's hook gives the same curve control with persistence and tooling for free [Layer 1]; rebuilding it is accidental complexity in 5 places.\nCompleteness: A=9/10, B=4/10, C=n/a (bounded investigation, decides nothing)\nPros / cons:\nA) Library retry hooks + custom backoff callback (recommended)\n ✅ Retry state (attempt count, next-run time, dead-letter) persists in the queue store and survives worker crashes and deploys\n ✅ Existing library tooling (dashboards, admin retry, metrics) keeps working; one backoff function instead of five schedulers (human: ~1 day / CC: ~20 min)\n ❌ Curve is bounded by the hook's inputs (often attempt number only), so error-specific delays like Retry-After need a small wrapper\nB) Custom inline scheduler in each worker (plan as written)\n ✅ Unlimited control over delay math and per-error behavior without touching library config\n ✅ No dependency on the library's backoff API shape or version\n ❌ Retries live in worker memory unless you also build persistence; a crash during backoff loses the job silently and bypasses the library's retry visibility (human: ~1 week / CC: ~1 hr, plus 5 copies to maintain)\nC) Investigate first: bounded probe of the library's backoff hook\n ✅ Confirms whether the installed library's hook supports the exact curve before committing either way\n ✅ Cheap: read the library docs and one worker's current retry config (human: ~1 hr / CC: ~5 min)\n ❌ Decides nothing; R1 stays pending and Sections 1–4 proceed against the plan as written\nNet: trading a bounded callback signature for crash-safe, observable retries you don't have to write five times.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hooks + custom curve (recommended)",
+ "description": "Use the job library's built-in retry hooks and supply one custom backoff callback for the curve. ✅ Retry state (attempt count, next-run time, dead-letter) persists in the queue store and survives worker crashes and deploys. ✅ Existing library tooling (dashboards, admin retry, metrics) keeps working; one backoff function instead of five schedulers (human: ~1 day / CC: ~20 min). ❌ Curve is bounded by the hook's inputs (often attempt number only), so error-specific delays like Retry-After need a small wrapper. Completeness 9/10. R2–R5 stay pending."
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Keep the plan as written: a hand-rolled exponential-backoff scheduler inside each of the 5 workers. ✅ Unlimited control over delay math and per-error behavior without touching library config. ✅ No dependency on the library's backoff API shape or version. ❌ Retries live in worker memory unless you also build persistence; a crash during backoff loses the job silently and bypasses the library's retry visibility (human: ~1 week / CC: ~1 hr, plus 5 copies to maintain). Completeness 4/10. R2–R5 stay pending."
+ },
+ {
+ "label": "Investigate library hook first",
+ "description": "Bounded probe only: read the installed library's backoff hook docs and one worker's current retry config, then return to this question. ✅ Confirms whether the hook supports the exact curve before committing either way. ✅ Cheap (human: ~1 hr / CC: ~5 min). ❌ Decides nothing; R1 stays pending and Sections 1–4 proceed against the plan as written. No completeness score (investigation, not coverage). R2–R5 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "29344c71-f9f0-40f3-b654-20ee9525ca09",
+ "toolUseId": "toolu_016iX2cRJrKmjtB3yJQCScmd",
+ "answeredAt": "2026-10-04T09:59:00.483Z",
+ "answer": "Idempotent retried delivery (recommended)",
+ "question": {
+ "question": "D2 — What delivery guarantee does the retried processWebhookJob keep, and what regression test proves it?\nProject/branch/task: main — reviewing PLAN.md \"Add background job retry framework\"; the webhook job is rewritten under the new retry path and currently guarantees at-most-once delivery.\nELI10: Today a webhook is sent once; if it fails, it is gone. Adding retries means the same event can be sent again. Sometimes the first send actually arrived but the response got lost, so the retry delivers a duplicate. Receivers that bill, email or create records on each webhook will double-act unless we give them a way to recognise a repeat.\nStakes if we pick wrong: downstream systems process duplicate events (double charges, double notifications) with no test telling us the guarantee changed, or webhook failures stay unrecovered forever.\nRecommendation: A because it recovers failed sends and keeps receivers safe from duplicates, and the four assertions make the guarantee executable rather than implied.\nCompleteness: A=9/10, B=5/10, C=6/10\nPros / cons:\nA) Idempotent retried delivery (recommended)\n ✅ Failed webhooks are retried, and a stable per-job idempotency-key header plus a persisted delivered marker let receivers and the worker dedup repeats\n ✅ Four concrete assertions cover first-try success, 5xx-then-2xx, lost response and lost ack, so the regression test fails on any semantic slip (human: ~2 days / CC: ~30 min)\n ❌ Receivers must honor the idempotency header to be fully safe; the marker adds one persisted field per job\nB) Explicit at-least-once\n ✅ Simplest code: library retry with no key or marker; one changelog note documents the new semantics\n ✅ Still recovers failed webhooks that were previously dropped\n ❌ Duplicates reach receivers on every ambiguous outcome, and the only test is \"a 5xx schedules a retry\"\nC) Exclude webhooks from retry\n ✅ Preserves the exact current at-most-once guarantee with a one-assertion regression test\n ✅ Zero risk of duplicate side effects downstream\n ❌ Webhook failures stay unrecovered, which is the problem this plan set out to fix; the webhook rewrite then has no purpose\nNet: trading one header and one persisted flag for retries that cannot double-act on receivers.",
+ "header": "Webhook semantics",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Idempotent retried delivery (recommended)",
+ "description": "Retry failed webhook POSTs via the library hooks (D1), send a stable per-job idempotency-key header on every attempt, and persist a delivered marker on 2xx that is checked before each attempt. Regression contract: (1) 2xx on attempt 1 → exactly one POST, no retry; (2) 5xx then 2xx → two POSTs with identical key; (3) send succeeds, response times out → retry carries the same key; (4) marker set but ack lost → no second POST. ✅ Recovers failed sends and dedups repeats. ✅ Guarantee is executable, not implied (human: ~2 days / CC: ~30 min). ❌ Receivers must honor the header; one persisted field per job. Completeness 9/10. R2, R4, R6–R9 stay pending."
+ },
+ {
+ "label": "Explicit at-least-once",
+ "description": "Retry failed webhook POSTs with no key or marker; document at-least-once in the changelog. Regression contract: (1) 5xx → retry scheduled; (2) duplicates on ambiguous outcomes documented. ✅ Simplest code. ✅ Recovers failed webhooks. ❌ Duplicates reach receivers on every ambiguous outcome. Completeness 5/10. R2, R4, R6–R9 stay pending."
+ },
+ {
+ "label": "Exclude webhooks from retry",
+ "description": "processWebhookJob opts out of the retry hooks and keeps exact at-most-once. Regression contract: (1) 5xx → job lands in failed state once, no retry; (2) request shape unchanged. ✅ Exact current guarantee preserved. ✅ Zero duplicate risk. ❌ Webhook failures stay unrecovered and the rewrite loses its purpose. Completeness 6/10. R2, R4, R6–R9 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "29344c71-f9f0-40f3-b654-20ee9525ca09",
+ "toolUseId": "toolu_01C1fNQhCuZ3ogymwmdD4fgu",
+ "answeredAt": "2026-10-04T09:59:57.788Z",
+ "answer": "Full jitter (recommended)",
+ "question": {
+ "question": "D3 — Should retry delays be randomized (jitter), and how?\nProject/branch/task: main — reviewing PLAN.md \"Add background job retry framework\"; the backoff callback approved in D1 needs its curve defined, starting with jitter.\nELI10: When a downstream service goes down, every job that failed at the same moment computes the same retry delay and comes back at the same moment, so the recovering service gets hit by a wave and falls over again. Jitter adds randomness to each delay so retries spread out. Full jitter picks any delay from zero up to the computed value; equal jitter keeps half the delay fixed and randomizes the other half.\nStakes if we pick wrong: synchronized retry storms after every outage, or (with equal jitter) a longer mandatory floor than needed.\nRecommendation: A because full jitter spreads load best for the same code size [Layer 1]; the only cost is that a retry can occasionally fire almost immediately.\nCompleteness: A=9/10, B=8/10, C=4/10\nPros / cons:\nA) Full jitter (recommended)\n ✅ Spreads retries across the whole window, so a recovering service sees a trickle instead of a wave (human: ~2 hr / CC: ~5 min incl. seeded test)\n ✅ Same one-line callback as the plain curve; well documented standard practice\n ❌ A retry may fire almost immediately after failure, which wastes one attempt when the outage is still ongoing\nB) Equal jitter\n ✅ Guarantees a minimum wait of half the computed delay, so no near-instant retry\n ✅ Still breaks synchronization across jobs\n ❌ Slightly more contention than full jitter and a larger mandatory floor at high attempt counts\nC) No jitter\n ✅ Fully deterministic delays are easy to reason about and test exactly\n ✅ Zero extra code\n ❌ Every job that failed together retries together; the thundering-herd failure mode is left open\nNet: trading exact predictability of one delay for a recovering dependency that is not re-flattened by our own retries.",
+ "header": "Backoff jitter",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Full jitter (recommended)",
+ "description": "Backoff callback returns random(0, base × 2^attempt). Unit test with seeded RNG: delay within [0, formula]; two jobs at the same attempt get different delays. ✅ Spreads retries across the whole window (human: ~2 hr / CC: ~5 min). ✅ One-line callback, standard practice. ❌ A retry may fire almost immediately. Completeness 9/10. R7, R8 pending (cap applies pre-jitter when decided); R2, R4, R9 pending."
+ },
+ {
+ "label": "Equal jitter",
+ "description": "Callback returns half the formula plus random(0, half). Unit test: delay within [half, full]; two jobs differ. ✅ Guaranteed minimum wait. ✅ Still desynchronizes. ❌ More contention than full jitter and a larger floor at high attempts. Completeness 8/10. R7, R8, R2, R4, R9 pending."
+ },
+ {
+ "label": "No jitter",
+ "description": "Callback returns base × 2^attempt exactly. Unit test: exact values per attempt. ✅ Deterministic and exact. ✅ Zero extra code. ❌ Thundering herd after outages stays open. Completeness 4/10. R7, R8, R2, R4, R9 pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T09:55:09.093Z",
+ "write": "# Eng review working plan — Add background job retry framework\n\nReview target (fixed): `PLAN.md` in (\"Plan: Add background job retry framework\"), branch `main`, commit d539bb1.\nReviewer: /plan-eng-review (claude host), session 692-1791107614-d3e1e871.\nEvidence limits: the repository contains only CLAUDE.md and PLAN.md. No worker source, job library manifest or tests are present, so every code-level claim below is marked unknown unless the plan text itself states it.\n\n## Original plan (unchanged copy)\n\n```markdown\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n"
+ },
+ {
+ "at": "2026-10-04T09:56:20.513Z",
+ "old": "iterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n",
+ "new": "iterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Scope Challenge\n\nComplexity gate: ~5 worker files + webhook job flow, 0–1 new component. Under 8 files and under 2 new classes, so B skipped. Search check (WebSearch, Aside absent): BullMQ exposes a custom `backoffStrategy` hook plus built-in fixed/exponential with jitter; documented pitfalls are hook inputs (attempt number only) and silent fallthrough on unknown strategy names.\n\nFindings:\n1. [P1] (confidence: 8/10) PLAN.md:7-9 — custom inline exponential-backoff scheduler per worker instead of the library's retry hooks. Reviewer: plan-eng-review (claude).\n\n## Decision ledger\n\n### R1: Retry mechanism — library retry hooks with custom backoff vs custom inline scheduler\nFinding: #1, P1, confidence 8/10, PLAN.md:7-9 (\"We'll roll a custom exponential-backoff scheduler inline in each worker rather than use the existing job library's built-in retry hooks. Same shape as the library version, but we want full control over the curve.\"), reviewer plan-eng-review (claude)\nPlan baseline: original proposal — custom inline scheduler in each of 5 workers; nothing approved yet\nRuntime evidence: unknown — no worker source or library manifest in this repo. External: BullMQ docs show a custom backoffStrategy hook and built-in exponential + jitter (https://docs.bullmq.io/guide/retrying-failing-jobs); other mainstream libraries (Sidekiq, Celery, Oban) expose equivalent per-job backoff callbacks (in-distribution knowledge, unverified against the installed library)\nComparison grid:\n\n| Choice | Current (plan) | A) Library hooks + custom curve | B) Custom inline scheduler | C) Investigate library hook first |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker (unapproved proposal) | library retry hooks + one custom backoff callback; retry state persisted by the queue store | custom inline scheduler per worker (original) | undecided; bounded probe: read installed library's backoff hook docs + one worker's current retry config; approves nothing |\n| R2 retry-envelope duplication (5 copies) | copy-pasted, \"refactor later\" | pending (A shrinks its surface; still its own answer) | pending | pending |\n| R3 at-most-once regression coverage for processWebhookJob | none planned | pending | pending | pending |\n| R4 payload re-fetch / dependency-graph caching | re-fetch every retry, not planned | pending | pending | pending |\n| R5 delivery semantics (at-most-once → retried) | implicit, unstated | pending | pending | pending |\n\nQuestion D1:\nD1 — Build the retry scheduler inline, or use the job library's retry hooks with a custom backoff curve?\nProject/branch/task: main — reviewing PLAN.md \"Add background job retry framework\"; the plan proposes a hand-rolled exponential-backoff scheduler inside each of 5 workers.\nELI10: The job library already knows how to retry a failed job: it stores the attempt count, waits, and re-runs it, and almost every mainstream library lets you plug in your own delay formula. The plan rebuilds that machinery inside each worker so \"we control the curve.\" That control is already available through the library's backoff callback, and the rebuilt version has to re-solve persistence, visibility and crash safety on its own.\nStakes if we pick wrong: an in-process retry timer dies with the worker, so a crash or deploy mid-backoff silently drops the job; retry counts and dead-letter state vanish from the library's dashboards and tooling.\nRecommendation: A because the library's hook gives the same curve control with persistence and tooling for free [Layer 1]; rebuilding it is accidental complexity in 5 places.\nCompleteness: A=9/10, B=4/10, C=n/a (bounded investigation, decides nothing)\nPros / cons:\nA) Library retry hooks + custom backoff callback (recommended)\n ✅ Retry state (attempt count, next-run time, dead-letter) persists in the queue store and survives worker crashes and deploys\n ✅ Existing library tooling (dashboards, admin retry, metrics) keeps working; one backoff function instead of five schedulers (human: ~1 day / CC: ~20 min)\n ❌ Curve is bounded by the hook's inputs (often attempt number only), so error-specific delays like Retry-After need a small wrapper\nB) Custom inline scheduler in each worker (plan as written)\n ✅ Unlimited control over delay math and per-error behavior without touching library config\n ✅ No dependency on the library's backoff API shape or version\n ❌ Retries live in worker memory unless you also build persistence; a crash during backoff loses the job silently and bypasses the library's retry visibility (human: ~1 week / CC: ~1 hr, plus 5 copies to maintain)\nC) Investigate first: bounded probe of the library's backoff hook\n ✅ Confirms whether the installed library's hook supports the exact curve before committing either way\n ✅ Cheap: read the library docs and one worker's current retry config (human: ~1 hr / CC: ~5 min)\n ❌ Decides nothing; R1 stays pending and Sections 1–4 proceed against the plan as written\nNet: trading a bounded callback signature for crash-safe, observable retries you don't have to write five times.\nHeader: Retry mechanism\nOptions:\nA) Library hooks + custom curve (recommended)\nUse the job library's built-in retry hooks and supply one custom backoff callback for the curve. ✅ Retry state (attempt count, next-run time, dead-letter) persists in the queue store and survives worker crashes and deploys. ✅ Existing library tooling (dashboards, admin retry, metrics) keeps working; one backoff function instead of five schedulers (human: ~1 day / CC: ~20 min). ❌ Curve is bounded by the hook's inputs (often attempt number only), so error-specific delays like Retry-After need a small wrapper. Completeness 9/10. R2–R5 stay pending.\nB) Custom inline scheduler\nKeep the plan as written: a hand-rolled exponential-backoff scheduler inside each of the 5 workers. ✅ Unlimited control over delay math and per-error behavior without touching library config. ✅ No dependency on the library's backoff API shape or version. ❌ Retries live in worker memory unless you also build persistence; a crash during backoff loses the job silently and bypasses the library's retry visibility (human: ~1 week / CC: ~1 hr, plus 5 copies to maintain). Completeness 4/10. R2–R5 stay pending.\nC) Investigate library hook first\nBounded probe only: read the installed library's backoff hook docs and one worker's current retry config, then return to this question. ✅ Confirms whether the hook supports the exact curve before committing either way. ✅ Cheap (human: ~1 hr / CC: ~5 min). ❌ Decides nothing; R1 stays pending and Sections 1–4 proceed against the plan as written. No completeness score (investigation, not coverage). R2–R5 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T09:56:52.825Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hooks + custom curve (D1 answer, AskUserQuestion)\nAccepted scope: Replace the inline per-worker exponential-backoff scheduler with the job library's built-in retry hooks plus one custom backoff callback that implements the desired curve; retry state (attempt count, next-run time, dead-letter) is persisted by the queue store. Includes the callback's unit tests and the plan's Architecture section rewrite. R2–R5 remain pending.\nHistory: none\n\nScope Challenge result: scope accepted as-is (R1 changes the mechanism, not the feature scope; B skipped).\n"
+ },
+ {
+ "at": "2026-10-04T09:58:39.713Z",
+ "old": "Scope Challenge result: scope accepted as-is (R1 changes the mechanism, not the feature scope; B skipped).\n",
+ "new": "Scope Challenge result: scope accepted as-is (R1 changes the mechanism, not the feature scope; B skipped).\n\n## Section 1: Architecture review — findings\n\n1. [P1] (confidence: 8/10) PLAN.md:17-19 — retrying `processWebhookJob()` converts at-most-once delivery into at-least-once; a POST that lands but whose response times out is retried and the receiver gets a duplicate. No idempotency key or delivered marker planned. → R5\n2. [P2] (confidence: 7/10) PLAN.md:7-9 — no retry bounds: no max attempts, delay cap or jitter; lockstep retry storms after an outage and unbounded delay growth. → R6 (jitter), R7 (max attempts), R8 (delay cap)\n3. [P2] (confidence: 7/10) PLAN.md:6-9 — no retry state diagram; factual addition, see Diagrams. No decision.\n4. Worker crash during in-memory backoff loses the retry — resolved by D1 (library persists retry state). Accepted via D1.\n\n### R5: processWebhookJob delivery semantics and regression contract\nFinding: #1 (Architecture), P1, confidence 8/10, PLAN.md:17-19 (\"The existing `processWebhookJob()` flow gets rewritten as part of this change. No regression test for the prior at-most-once delivery guarantee is planned.\"), reviewer plan-eng-review (claude)\nPlan baseline: original proposal — rewrite the flow under the retry framework with no stated semantics and no regression test; nothing approved\nRuntime evidence: unknown — processWebhookJob source not in this repo; the plan itself asserts the current guarantee is at-most-once\nComparison grid:\n\n| Choice | Current (plan) | A) Idempotent retried delivery | B) Explicit at-least-once | C) Exclude webhooks from retry |\n|---|---|---|---|---|\n| R5 behavior preserved | implicit, unstated | receiver never gets the same event twice on a successful path (dedup via stable key + delivered marker) | none; duplicates allowed | exact at-most-once: a failed POST is never re-sent |\n| R5 intentional change | implicit, unstated | failed POST (connect error, 5xx, timeout) is retried via library hooks | failed POST retried; duplicates possible on ambiguous outcomes | none for webhooks; other 4 workers still get retries |\n| R5 acceptance assertions (regression contract) | none planned | (1) 2xx on attempt 1 → exactly one POST, no retry scheduled; (2) 5xx then 2xx → two POSTs with identical idempotency-key header; (3) send succeeds, response times out → retry carries the same key; (4) delivered marker set but ack lost → no second POST | (1) 5xx → retry scheduled; (2) key header absent, duplicate on ambiguous outcome documented in changelog | (1) 5xx → job lands in failed state once, no retry; (2) unchanged request shape |\n| R1 retry mechanism | approved A (D1) | approved A, unchanged | approved A, unchanged | approved A, unchanged (webhook opts out) |\n| R2, R4, R6, R7, R8, R9 | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — What delivery guarantee does the retried processWebhookJob keep, and what regression test proves it?\nProject/branch/task: main — reviewing PLAN.md \"Add background job retry framework\"; the webhook job is rewritten under the new retry path and currently guarantees at-most-once delivery.\nELI10: Today a webhook is sent once; if it fails, it is gone. Adding retries means the same event can be sent again. Sometimes the first send actually arrived but the response got lost, so the retry delivers a duplicate. Receivers that bill, email or create records on each webhook will double-act unless we give them a way to recognise a repeat.\nStakes if we pick wrong: downstream systems process duplicate events (double charges, double notifications) with no test telling us the guarantee changed, or webhook failures stay unrecovered forever.\nRecommendation: A because it recovers failed sends and keeps receivers safe from duplicates, and the four assertions make the guarantee executable rather than implied.\nCompleteness: A=9/10, B=5/10, C=6/10\nPros / cons:\nA) Idempotent retried delivery (recommended)\n ✅ Failed webhooks are retried, and a stable per-job idempotency-key header plus a persisted delivered marker let receivers and the worker dedup repeats\n ✅ Four concrete assertions cover first-try success, 5xx-then-2xx, lost response and lost ack, so the regression test fails on any semantic slip (human: ~2 days / CC: ~30 min)\n ❌ Receivers must honor the idempotency header to be fully safe; the marker adds one persisted field per job\nB) Explicit at-least-once\n ✅ Simplest code: library retry with no key or marker; one changelog note documents the new semantics\n ✅ Still recovers failed webhooks that were previously dropped\n ❌ Duplicates reach receivers on every ambiguous outcome, and the only test is \"a 5xx schedules a retry\"\nC) Exclude webhooks from retry\n ✅ Preserves the exact current at-most-once guarantee with a one-assertion regression test\n ✅ Zero risk of duplicate side effects downstream\n ❌ Webhook failures stay unrecovered, which is the problem this plan set out to fix; the webhook rewrite then has no purpose\nNet: trading one header and one persisted flag for retries that cannot double-act on receivers.\nHeader: Webhook semantics\nOptions:\nA) Idempotent retried delivery (recommended)\nRetry failed webhook POSTs via the library hooks (D1), send a stable per-job idempotency-key header on every attempt, and persist a delivered marker on 2xx that is checked before each attempt. Regression contract: (1) 2xx on attempt 1 → exactly one POST, no retry; (2) 5xx then 2xx → two POSTs with identical key; (3) send succeeds, response times out → retry carries the same key; (4) marker set but ack lost → no second POST. ✅ Recovers failed sends and dedups repeats. ✅ Guarantee is executable, not implied (human: ~2 days / CC: ~30 min). ❌ Receivers must honor the header; one persisted field per job. Completeness 9/10. R2, R4, R6–R9 stay pending.\nB) Explicit at-least-once\nRetry failed webhook POSTs with no key or marker; document at-least-once in the changelog. Regression contract: (1) 5xx → retry scheduled; (2) duplicates on ambiguous outcomes documented. ✅ Simplest code. ✅ Recovers failed webhooks. ❌ Duplicates reach receivers on every ambiguous outcome. Completeness 5/10. R2, R4, R6–R9 stay pending.\nC) Exclude webhooks from retry\nprocessWebhookJob opts out of the retry hooks and keeps exact at-most-once. Regression contract: (1) 5xx → job lands in failed state once, no retry; (2) request shape unchanged. ✅ Exact current guarantee preserved. ✅ Zero duplicate risk. ❌ Webhook failures stay unrecovered and the rewrite loses its purpose. Completeness 6/10. R2, R4, R6–R9 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T09:59:40.286Z",
+ "old": "Completeness 6/10. R2, R4, R6–R9 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Completeness 6/10. R2, R4, R6–R9 stay pending.\n\nState: approved\nActual answer: A) Idempotent retried delivery (D2 answer, AskUserQuestion)\nAccepted scope: processWebhookJob retries failed POSTs (connect error, 5xx, timeout) via the library hooks (D1); every attempt sends a stable per-job idempotency-key header; a delivered marker is persisted on 2xx and checked before each attempt. Regression contract (CRITICAL test, carried into Test review as approved): (1) 2xx on attempt 1 → exactly one POST, no retry scheduled; (2) 5xx then 2xx → two POSTs with identical idempotency key; (3) send succeeds, response times out → retry carries the same key; (4) marker set but ack lost → no second POST. Changelog note documenting the new header. R2, R4, R6–R9 remain pending.\nHistory: none\n\n### R6: Backoff jitter\nFinding: #2 (Architecture), P2, confidence 7/10, PLAN.md:7-9 (\"custom exponential-backoff scheduler ... full control over the curve\"), reviewer plan-eng-review (claude). The curve names exponential growth but no jitter.\nPlan baseline: original proposal — exponential curve, jitter unspecified; nothing approved\nRuntime evidence: unknown — no scheduler source in this repo. External: BullMQ built-in exponential accepts a 0–1 jitter factor (https://docs.bullmq.io/guide/retrying-failing-jobs); AWS Architecture Blog \"Exponential Backoff and Jitter\" shows full jitter minimizes contention and total work (in-distribution knowledge)\nComparison grid:\n\n| Choice | Current (plan) | A) Full jitter | B) Equal jitter | C) No jitter |\n|---|---|---|---|---|\n| R6 jitter | unspecified, pending | delay = random(0, base × 2^attempt) | delay = half of base × 2^attempt + random(0, that half) | delay = base × 2^attempt exactly |\n| R6 proof (unit test, seeded RNG) | none | delay ∈ [0, base × 2^attempt]; two jobs with same attempt get different delays | delay ∈ [half, full]; two jobs differ | delay equals formula exactly |\n| R1 retry mechanism | approved A (D1) | unchanged | unchanged | unchanged |\n| R5 webhook semantics | approved A (D2) | unchanged | unchanged | unchanged |\n| R7 max attempts, R8 delay cap | pending | pending (cap applies to the pre-jitter value when decided) | pending | pending |\n| R2, R4, R9 | pending | pending | pending | pending |\n\nQuestion D3:\nD3 — Should retry delays be randomized (jitter), and how?\nProject/branch/task: main — reviewing PLAN.md \"Add background job retry framework\"; the backoff callback approved in D1 needs its curve defined, starting with jitter.\nELI10: When a downstream service goes down, every job that failed at the same moment computes the same retry delay and comes back at the same moment, so the recovering service gets hit by a wave and falls over again. Jitter adds randomness to each delay so retries spread out. Full jitter picks any delay from zero up to the computed value; equal jitter keeps half the delay fixed and randomizes the other half.\nStakes if we pick wrong: synchronized retry storms after every outage, or (with equal jitter) a longer mandatory floor than needed.\nRecommendation: A because full jitter spreads load best for the same code size [Layer 1]; the only cost is that a retry can occasionally fire almost immediately.\nCompleteness: A=9/10, B=8/10, C=4/10\nPros / cons:\nA) Full jitter (recommended)\n ✅ Spreads retries across the whole window, so a recovering service sees a trickle instead of a wave (human: ~2 hr / CC: ~5 min incl. seeded test)\n ✅ Same one-line callback as the plain curve; well documented standard practice\n ❌ A retry may fire almost immediately after failure, which wastes one attempt when the outage is still ongoing\nB) Equal jitter\n ✅ Guarantees a minimum wait of half the computed delay, so no near-instant retry\n ✅ Still breaks synchronization across jobs\n ❌ Slightly more contention than full jitter and a larger mandatory floor at high attempt counts\nC) No jitter\n ✅ Fully deterministic delays are easy to reason about and test exactly\n ✅ Zero extra code\n ❌ Every job that failed together retries together; the thundering-herd failure mode is left open\nNet: trading exact predictability of one delay for a recovering dependency that is not re-flattened by our own retries.\nHeader: Backoff jitter\nOptions:\nA) Full jitter (recommended)\nBackoff callback returns random(0, base × 2^attempt). Unit test with seeded RNG: delay within [0, formula]; two jobs at the same attempt get different delays. ✅ Spreads retries across the whole window (human: ~2 hr / CC: ~5 min). ✅ One-line callback, standard practice. ❌ A retry may fire almost immediately. Completeness 9/10. R7, R8 pending (cap applies pre-jitter when decided); R2, R4, R9 pending.\nB) Equal jitter\nCallback returns half the formula plus random(0, half). Unit test: delay within [half, full]; two jobs differ. ✅ Guaranteed minimum wait. ✅ Still desynchronizes. ❌ More contention than full jitter and a larger floor at high attempts. Completeness 8/10. R7, R8, R2, R4, R9 pending.\nC) No jitter\nCallback returns base × 2^attempt exactly. Unit test: exact values per attempt. ✅ Deterministic and exact. ✅ Zero extra code. ❌ Thundering herd after outages stays open. Completeness 4/10. R7, R8, R2, R4, R9 pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37195203538-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37195203538-t1-pass.json
new file mode 100644
index 000000000..94c0be328
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37195203538-t1-pass.json
@@ -0,0 +1,136 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "ded5316419aefeaf",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37195203538-1-eval-slices-1/plan-eng-review-1791109541361-oBdGAF"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "148ebb73-116f-48ed-a898-e9a1ec6c7a20",
+ "toolUseId": "toolu_01JBahHE3xuK2uiL2acmpLvV",
+ "answeredAt": "2026-10-04T10:28:26.210Z",
+ "answer": "Library hook + custom backoff fn (recommended)",
+ "question": {
+ "question": "D1 — Use the job library's retry hooks or roll a custom inline scheduler?\nProject/branch/task: `main` of the retry-framework plan; the plan currently rolls its own scheduler inside each of 5 workers (PLAN.md:7-9).\nELI10: Your job library already knows how to retry a failed job: it counts attempts, waits, re-runs, and gives up after N tries. The plan rebuilds that waiting logic by hand inside every worker because the team wants to pick the exact wait curve. Most libraries let you plug in your own curve as a small function while they keep doing the counting and re-running. Rebuilding it means you also own attempt tracking, surviving a worker restart mid-wait, max-attempt cutoff and dead-lettering.\nStakes if we pick wrong: a hand-rolled scheduler that loses attempt state on restart retries forever or never, and the bug appears in five places at once, at 3am.\nRecommendation: A because the plan's only stated reason (\"full control over the curve\") is exactly what a backoff-strategy hook provides, and it is an already-installed dependency (reuse ladder rung 4).\nNote: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Library hook + custom backoff function (recommended)\n ✅ Attempt counting, persistence across restarts, max attempts and dead-lettering stay with proven library code\n ✅ The curve lives in one function (exponential, cap, jitter) instead of five inline copies (human: ~0.5 day / CC: ~10 min)\n ❌ Curve is constrained to whatever the hook's signature allows (usually a delay in ms from attempt number and error)\nB) Custom inline scheduler per worker, as planned\n ✅ No dependency on the library's hook contract; any curve shape or side effect is possible\n ✅ No need to learn or verify the hook API before starting (human: ~2 days / CC: ~30 min, plus ongoing maintenance)\n ❌ Re-implements attempt state, restart recovery, cutoff and dead-letter handling, each a new 3am failure mode across 5 files\nC) Investigate the hook first, decide after\n ✅ Bounded check (hook signature, custom curve, max attempts, jitter, cap) settles the unknown with evidence (human: ~1 hour / CC: ~5 min)\n ✅ Nothing is built on an assumption; R1 stays pending, R2–R4 stay pending\n ❌ Delays the architecture decision; if the hook obviously supports a custom curve this is wasted time\nNet: trading the library's proven retry lifecycle against total freedom over a curve the library almost certainly already lets you set.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hook + custom backoff fn (recommended)",
+ "description": "Register one custom backoff function with the job library's built-in retry hook; the function owns the curve (exponential base, cap, jitter). The library keeps attempt counting, persistence across worker restarts, max-attempt cutoff and dead-lettering. Human: ~0.5 day / CC: ~10 min. Risk: curve limited to the hook's signature. R2–R4 remain pending."
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Keep the plan as written: a hand-rolled exponential-backoff scheduler inline in each worker, bypassing the library's retry hooks. Full curve freedom. Human: ~2 days / CC: ~30 min plus maintenance. Risk: re-implements attempt state, restart recovery, cutoff and dead-lettering in 5 places. R2–R4 remain pending."
+ },
+ {
+ "label": "Investigate hook first",
+ "description": "Bounded investigation only: read the library's retry hook signature and confirm it supports a custom curve, max attempts, jitter and a delay cap. No implementation approved; R1 returns to this question with evidence. Human: ~1 hour / CC: ~5 min. R2–R4 remain pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "148ebb73-116f-48ed-a898-e9a1ec6c7a20",
+ "toolUseId": "toolu_01RoMYnxxpq4VAbMfbGVddrp",
+ "answeredAt": "2026-10-04T10:29:58.185Z",
+ "answer": "At-least-once + idempotency id (recommended)",
+ "question": {
+ "question": "D2 — What delivery guarantee does the webhook job keep once it can retry?\nProject/branch/task: `main` of the retry-framework plan; `processWebhookJob()` is rewritten and loses its at-most-once guarantee (PLAN.md:17-19).\nELI10: Today a webhook is sent at most once: if it fails, it is gone, but the receiver never sees a duplicate. Adding retries flips that: the receiver might now get the same event twice, for example when the POST lands but the worker dies before marking it done. You can either tell receivers \"you may see duplicates, here is an id so you can ignore the second one,\" keep the old promise by never retrying once a send was attempted, or retry blindly and hope receivers cope.\nStakes if we pick wrong: receivers double-process events (double charges, double emails), or you keep at-most-once and the reliability win from retries never reaches the webhook path.\nRecommendation: A because at-least-once plus a stable delivery id is the standard webhook contract and receivers can dedupe cheaply, while B gives up most of the reliability you are building this for.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once with idempotency id (recommended)\n ✅ Retries apply to the whole webhook path, including transient receiver 5xx and network timeouts\n ✅ Stable delivery id on every attempt lets receivers dedupe; this is the documented contract most webhook consumers expect (human: ~1 day / CC: ~15 min)\n ❌ Receivers that ignore the id will see duplicates; the contract change must be documented and announced\nB) Preserve at-most-once\n ✅ No behavior change visible to receivers; no duplicate risk and no receiver-side work\n ✅ Retries still cover pre-send failures such as payload fetch or serialization errors (human: ~0.5 day / CC: ~10 min)\n ❌ A receiver 503 or a timeout after send is final: the event is dead-lettered, so the webhook path gains almost nothing from this project\nC) At-least-once, no dedupe id (plan as written)\n ✅ Smallest change: just let the library retry the existing handler as-is\n ✅ No contract documentation or header work (human: ~2 hours / CC: ~5 min)\n ❌ Duplicates are undetectable by receivers; the first incident is a double side effect with no way to reconcile\nNet: trading a documented, dedupe-able duplicate against either silent duplicates or a webhook path that cannot be retried.",
+ "header": "Webhook guarantee",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "At-least-once + idempotency id (recommended)",
+ "description": "`processWebhookJob()` retries through the library hook (D1). Every attempt sends the same stable delivery id (header and body field) so receivers can dedupe; the contract change is documented for receivers. Completeness 9/10. Human: ~1 day / CC: ~15 min. Risk: receivers ignoring the id see duplicates. R2, R3, R4 remain pending."
+ },
+ {
+ "label": "Preserve at-most-once",
+ "description": "Retries cover only failures before the HTTP send is attempted (payload fetch, serialization, auth). Once a send is attempted, no retry; failure goes to dead-letter. Receivers see no behavior change. Completeness 7/10. Human: ~0.5 day / CC: ~10 min. Risk: post-send transient failures are final. R2, R3, R4 remain pending."
+ },
+ {
+ "label": "At-least-once, no dedupe id",
+ "description": "Let the library retry the existing handler unchanged. No delivery id, no contract documentation. Completeness 3/10. Human: ~2 hours / CC: ~5 min. Risk: undetectable duplicate side effects at receivers. R2, R3, R4 remain pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "148ebb73-116f-48ed-a898-e9a1ec6c7a20",
+ "toolUseId": "toolu_012wPP3MnH4ha91TjftEnYM8",
+ "answeredAt": "2026-10-04T10:31:24.991Z",
+ "answer": "Library event listener (recommended)",
+ "question": {
+ "question": "D3 — Where does retry-attempt logging live once the library drives retries?\nProject/branch/task: `main` of the retry-framework plan; the retry envelope is copy-pasted into 5 worker files and the plan leaves it duplicated (PLAN.md:12-14).\nELI10: After D1 the library computes the delay and re-dispatches, so two of the three copy-pasted pieces disappear on their own. The third piece, \"log this attempt\", still has to live somewhere. Most job libraries fire an event every time a job fails and is scheduled to retry; one listener on that event logs every worker's attempts with zero code in the workers. The alternatives are a tiny shared function each worker calls, or leaving five copies and promising to clean up later.\nStakes if we pick wrong: five copies drift, one worker logs attempts differently or not at all, and the 3am incident has a hole exactly where the failing worker is.\nRecommendation: A because it uses an already-installed dependency (reuse ladder rung 4), needs no change inside the workers, and cannot drift across them.\nCompleteness: A=10/10, B=8/10, C=3/10\nPros / cons:\nA) One library lifecycle-event listener (recommended)\n ✅ Zero retry code left in any worker; every worker's attempts logged identically from one place (human: ~2 hours / CC: ~5 min)\n ✅ Uses the library's own failed/retry event, so logging stays correct even when a new worker is added later\n ❌ Depends on the library emitting a usable event with attempt number and next delay; verify at implementation time\nB) Shared `logRetryAttempt()` helper called per worker\n ✅ Works even if the library emits no retry event; explicit call site in each worker (human: ~3 hours / CC: ~10 min)\n ✅ Single implementation, so log format cannot drift between workers\n ❌ Every worker still carries a call and a new worker can forget it; one more seam to test per caller\nC) Leave the 5 inline copies, refactor later\n ✅ No change to the 5 workers beyond removing the inline scheduler already approved in D1\n ✅ Nothing new to learn or test right now (human: ~0 / CC: ~0)\n ❌ \"Later\" rarely arrives; five copies drift and the duplication finding stands unresolved in the ship review\nNet: trading a dependency on one library event against carrying retry code in five workers that already should have none.",
+ "header": "Attempt logging",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library event listener (recommended)",
+ "description": "Register one listener on the job library's failed/retry lifecycle event at worker boot. It logs job id, attempt number, next delay and error class for every worker. Delete the inline envelope from all 5 workers (compute-delay and dispatch already move to the library under D1). Completeness 10/10. Human: ~2 hours / CC: ~5 min. Risk: library must emit the event with attempt data; verify at implementation. R3, R4 remain pending."
+ },
+ {
+ "label": "Shared logRetryAttempt() helper",
+ "description": "Create one small helper that logs job id, attempt number, next delay and error class; each of the 5 workers calls it from its failure path. Delete the rest of the inline envelope. Completeness 8/10. Human: ~3 hours / CC: ~10 min. Risk: a new worker can forget the call; one caller-integration test per worker. R3, R4 remain pending."
+ },
+ {
+ "label": "Leave duplication, refactor later",
+ "description": "Keep the per-worker attempt logging in all 5 workers as planned (PLAN.md:13-14); only the inline scheduler approved in D1 is removed. Completeness 3/10. Human: ~0 / CC: ~0. Risk: copies drift; finding stays open. R3, R4 remain pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T10:26:42.581Z",
+ "write": "# Eng Review Report — Plan: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in `` (branch `main`, commit `bf0c669`).\nReviewer: /plan-eng-review (gstack), session `692-1791109527-353559b7`, 2026-10-04.\nReport file: this file (user-requested destination).\n\n## Original plan (unchanged copy of PLAN.md)\n\n```markdown\nProceed directly to the requested engineering review; skip the optional /office-hours prerequisite.\nPlease review this plan thoroughly. As you go, write your plan-mode plan to (use Edit/Write to that exact path).\n\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Review evidence notes\n\n- The repository contains only `CLAUDE.md` and `PLAN.md`. None of the code the plan names (the 5 worker files, `processWebhookJob()`, the job library) is checked in here. All runtime evidence below is therefore **unknown**; findings are calibrated from the plan text alone and quote `PLAN.md:line`.\n- `git log -- PLAN.md CLAUDE.md`: one commit (`bf0c669 Seed review plan`); no reverts.\n- No `TODOS.md` in the repo.\n- No design doc found; user asked to skip `/office-hours`.\n"
+ },
+ {
+ "at": "2026-10-04T10:28:04.398Z",
+ "old": "- No design doc found; user asked to skip `/office-hours`.\n",
+ "new": "- No design doc found; user asked to skip `/office-hours`.\n- Test framework detection: no runtime markers, no test config, `TESTFILES:0`. Framework unknown.\n- Outside Voice preflight: `CODEX_MODE: disabled` (`codex_reviews=disabled`); Codex CLI also not installed.\n- Web research (host WebSearch, Aside unavailable): library-level retry hooks are the norm. BullMQ exposes a custom `backoffStrategy` hook (return -1 = fail now, 0 = requeue immediately, N = delay ms); un-jittered exponential backoff is the worst-performing strategy in AWS's jitter experiments; delivery is at-least-once so handlers must be idempotent; always cap attempts. Sources: https://oneuptime.com/blog/post/2026-01-21-bullmq-retry-exponential-backoff/view , https://www.digitalapplied.com/blog/background-job-queue-patterns-2026-engineering-reference , https://knowledgelib.io/software/patterns/retry-exponential-backoff/2026 , https://github.com/autumn-foundation/autumn/issues/3054\n\n## Step 0: Scope Challenge\n\n### A. Assessment\n- **What already solves each sub-problem:** The plan itself names the solution: the existing job library's built-in retry hooks (`PLAN.md:8`). Reuse ladder rung 4 (already-installed dependency). The stated motivation, \"full control over the curve\" (`PLAN.md:9`), is what a backoff-strategy hook exists to provide. **[Layer 1]**: use the library's hook with a custom curve function.\n- **Minimum change:** one backoff function registered with the library, plus the handler changes for `processWebhookJob()`. The inline-per-worker scheduler and the duplicated envelope are accidental complexity, not essential.\n- **Complexity check:** 5 proposed changed worker files (`PLAN.md:13`), 0 new classes/services (the scheduler is inline). Below the 8-file / 2-class gate, so **B is skipped**.\n- **Search check:** done (see evidence notes). No departure from standard practice is justified by the plan text.\n- **TODOS cross-reference:** no `TODOS.md`.\n- **Completeness check:** the plan defers three things (refactor, regression test, graph cache) that each cost CC minutes. Reviewed in Sections 2–4.\n- **Distribution check:** no new artifact; not applicable.\n\n### C. Findings\n1. **[P1] (confidence: 8/10) PLAN.md:7-9 — Custom inline scheduler re-implements the job library's built-in retry hooks.** Motivating lines: \"We'll roll a custom exponential-backoff scheduler inline in each worker / rather than use the existing job library's built-in retry hooks. Same / shape as the library version, but we want full control over the curve.\" Runtime evidence: library identity and hook signature unknown (no code in repo), hence 8 not 9. An inline scheduler must also re-implement attempt counting, persistence of attempt state across worker restarts, max-attempt cutoff and dead-lettering, all of which the library already owns. Remedy: R1 / D1 below.\n\n## Decision ledger\n\n### R1: Retry mechanism — library retry hook vs custom inline scheduler\nFinding: #1, P1, confidence 8/10, PLAN.md:7-9, reviewer: native (plan-eng-review)\nPlan baseline: original proposal — custom exponential-backoff scheduler inline in each of the 5 workers; library hooks bypassed (PLAN.md:7-9). Nothing approved yet.\nRuntime evidence: unknown. The job library, its hook signature, and the 5 worker files are not in this repository. Web research shows mainstream libraries accept a custom backoff function (BullMQ `backoffStrategy`), but this library is unverified.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker (proposed, unapproved) | library built-in retry hook + one custom backoff function (curve, cap, jitter) | custom inline scheduler per worker, as planned | pending; bounded investigation of the library hook's signature (custom curve, max attempts, jitter, cap) before choosing |\n| R2 retry envelope duplication across 5 workers | duplicated (PLAN.md:12-14), pending | pending | pending | pending |\n| R3 regression coverage for at-most-once guarantee | none planned (PLAN.md:17-19), pending | pending | pending | pending |\n| R4 dependency-graph recompute per retry | recompute every retry (PLAN.md:22-24), pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Use the job library's retry hooks or roll a custom inline scheduler?\nProject/branch/task: `main` of the retry-framework plan; the plan currently rolls its own scheduler inside each of 5 workers (PLAN.md:7-9).\nELI10: Your job library already knows how to retry a failed job: it counts attempts, waits, re-runs, and gives up after N tries. The plan rebuilds that waiting logic by hand inside every worker because the team wants to pick the exact wait curve. Most libraries let you plug in your own curve as a small function while they keep doing the counting and re-running. Rebuilding it means you also own attempt tracking, surviving a worker restart mid-wait, max-attempt cutoff and dead-lettering.\nStakes if we pick wrong: a hand-rolled scheduler that loses attempt state on restart retries forever or never, and the bug appears in five places at once, at 3am.\nRecommendation: A because the plan's only stated reason (\"full control over the curve\") is exactly what a backoff-strategy hook provides, and it is an already-installed dependency (reuse ladder rung 4).\nNote: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Library hook + custom backoff function (recommended)\n ✅ Attempt counting, persistence across restarts, max attempts and dead-lettering stay with proven library code\n ✅ The curve lives in one function (exponential, cap, jitter) instead of five inline copies (human: ~0.5 day / CC: ~10 min)\n ❌ Curve is constrained to whatever the hook's signature allows (usually a delay in ms from attempt number and error)\nB) Custom inline scheduler per worker, as planned\n ✅ No dependency on the library's hook contract; any curve shape or side effect is possible\n ✅ No need to learn or verify the hook API before starting (human: ~2 days / CC: ~30 min, plus ongoing maintenance)\n ❌ Re-implements attempt state, restart recovery, cutoff and dead-letter handling, each a new 3am failure mode across 5 files\nC) Investigate the hook first, decide after\n ✅ Bounded check (hook signature, custom curve, max attempts, jitter, cap) settles the unknown with evidence (human: ~1 hour / CC: ~5 min)\n ✅ Nothing is built on an assumption; R1 stays pending, R2–R4 stay pending\n ❌ Delays the architecture decision; if the hook obviously supports a custom curve this is wasted time\nNet: trading the library's proven retry lifecycle against total freedom over a curve the library almost certainly already lets you set.\nHeader: Retry mechanism\nOptions:\nA) Library hook + custom backoff fn (recommended)\nRegister one custom backoff function with the job library's built-in retry hook; the function owns the curve (exponential base, cap, jitter). The library keeps attempt counting, persistence across worker restarts, max-attempt cutoff and dead-lettering. Human: ~0.5 day / CC: ~10 min. Risk: curve limited to the hook's signature. R2–R4 remain pending.\nB) Custom inline scheduler\nKeep the plan as written: a hand-rolled exponential-backoff scheduler inline in each worker, bypassing the library's retry hooks. Full curve freedom. Human: ~2 days / CC: ~30 min plus maintenance. Risk: re-implements attempt state, restart recovery, cutoff and dead-lettering in 5 places. R2–R4 remain pending.\nC) Investigate hook first\nBounded investigation only: read the library's retry hook signature and confirm it supports a custom curve, max attempts, jitter and a delay cap. No implementation approved; R1 returns to this question with evidence. Human: ~1 hour / CC: ~5 min. R2–R4 remain pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T10:28:56.211Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hook + custom backoff fn — D1 answer (AskUserQuestion, this session)\nAccepted scope: Replace the inline per-worker scheduler (PLAN.md:7-9) with one custom backoff function registered on the job library's built-in retry hook. The function owns the curve: exponential base, delay cap and jitter. The library keeps attempt counting, attempt-state persistence across worker restarts, max-attempt cutoff and dead-lettering. Includes the code, tests and docs for that one function. R2, R3, R4 remain pending.\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T10:28:59.270Z",
+ "old": "### C. Findings\n1. **[P1] (confidence: 8/10) PLAN.md:7-9",
+ "new": "### C. Findings\nDispositions: #1 accepted via D1 (A). Scope Challenge result: **scope accepted as-is** (B skipped; the D1 change is an arrangement change that preserves the feature set, not a scope reduction).\n\n1. **[P1] (confidence: 8/10) PLAN.md:7-9"
+ },
+ {
+ "at": "2026-10-04T10:29:11.583Z",
+ "old": "## Decision ledger\n\n### R1: Retry mechanism",
+ "new": "## Section 1: Architecture review\n\nFlow under the approved D1 arrangement (proposed, not yet code):\n\n```\nenqueue(job) ──► library runner ──► worker handler (e.g. processWebhookJob)\n ▲ │\n │ success ├── ok ──► ack / complete\n │ └── throw ──► library retry hook\n │ │\n │ backoffFn(attempt, err)\n │ ├── attempt < max ──► delay = min(cap, base·2^attempt) ± jitter ──► requeue\n │ └── attempt ≥ max ──► dead-letter / failed\n └──────────────── requeue after delay ───────────┘\n```\n\nFindings:\n2. **[P1] (confidence: 8/10) PLAN.md:17-19 — Adding retries to `processWebhookJob()` silently converts at-most-once delivery into at-least-once; duplicate webhooks reach receivers.** Motivating lines: \"The existing `processWebhookJob()` flow gets rewritten as part of this / change. No regression test for the prior at-most-once delivery guarantee / is planned.\" Realistic production failure: the HTTP POST to the receiver succeeds but the worker crashes or times out before acking; the retry re-sends, and the receiver processes the same event twice (double charge, double notification). The plan names the old guarantee and drops it without naming the new one or how receivers dedupe. Runtime evidence: unknown (handler not in repo). Remedy: R5 / D2 below. Test coverage for whichever guarantee is chosen is R3 (Section 3).\n3. **[P2] (confidence: 7/10) PLAN.md:7-9 — Curve parameters (max attempts, delay cap, jitter) are not specified.** Motivating line: \"we want full control over the curve.\" Un-jittered, uncapped exponential backoff is the worst strategy in AWS's published experiments (see evidence notes). Disposition: covered by D1 (A): the accepted scope makes the backoff function own base, cap and jitter and the library own max attempts. Values are implementation detail of that approved behavior; no separate question. Implementation task will name defaults.\n\nDispositions: #2 pending (D2). #3 accepted via D1 (A), carried forward. Security: no new auth or data-access boundary is introduced; the retry path reuses the existing payload fetch (performance implications in Section 4). Distribution: no new artifact.\n\n## Decision ledger\n\n### R1: Retry mechanism"
+ },
+ {
+ "at": "2026-10-04T10:29:37.837Z",
+ "old": "Accepted scope: Replace the inline per-worker scheduler (PLAN.md:7-9) with one custom backoff function registered on the job library's built-in retry hook. The function owns the curve: exponential base, delay cap and jitter. The library keeps attempt counting, attempt-state persistence across worker restarts, max-attempt cutoff and dead-lettering. Includes the code, tests and docs for that one function. R2, R3, R4 remain pending.\nHistory: none\n",
+ "new": "Accepted scope: Replace the inline per-worker scheduler (PLAN.md:7-9) with one custom backoff function registered on the job library's built-in retry hook. The function owns the curve: exponential base, delay cap and jitter. The library keeps attempt counting, attempt-state persistence across worker restarts, max-attempt cutoff and dead-lettering. Includes the code, tests and docs for that one function. R2, R3, R4 remain pending.\nHistory: none\n\n### R5: Webhook delivery guarantee under retry\nFinding: #2, P1, confidence 8/10, PLAN.md:17-19, reviewer: native (plan-eng-review)\nPlan baseline: original proposal — `processWebhookJob()` is rewritten to retry; the prior at-most-once guarantee is dropped with no replacement guarantee named (PLAN.md:17-19). Nothing approved for R5.\nRuntime evidence: unknown. Handler, HTTP client and receiver contracts are not in this repository. Industry practice (evidence notes): webhook delivery is at-least-once with a stable delivery id so receivers can dedupe.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R5 webhook delivery guarantee | at-most-once today; plan drops it, replacement unspecified | at-least-once with a stable idempotency/delivery id sent on every attempt (header + body field), documented for receivers | at-most-once preserved: retry only failures that occur before the HTTP send is attempted; once a send is attempted, no retry (dead-letter instead) | at-least-once with no dedupe id, as the plan implies |\n| R1 retry mechanism | approved D1 (A) | unchanged | unchanged | unchanged |\n| R2 envelope duplication | pending | pending | pending | pending |\n| R3 regression coverage for the chosen guarantee | pending | pending (Section 3) | pending (Section 3) | pending (Section 3) |\n| R4 graph recompute | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — What delivery guarantee does the webhook job keep once it can retry?\nProject/branch/task: `main` of the retry-framework plan; `processWebhookJob()` is rewritten and loses its at-most-once guarantee (PLAN.md:17-19).\nELI10: Today a webhook is sent at most once: if it fails, it is gone, but the receiver never sees a duplicate. Adding retries flips that: the receiver might now get the same event twice, for example when the POST lands but the worker dies before marking it done. You can either tell receivers \"you may see duplicates, here is an id so you can ignore the second one,\" keep the old promise by never retrying once a send was attempted, or retry blindly and hope receivers cope.\nStakes if we pick wrong: receivers double-process events (double charges, double emails), or you keep at-most-once and the reliability win from retries never reaches the webhook path.\nRecommendation: A because at-least-once plus a stable delivery id is the standard webhook contract and receivers can dedupe cheaply, while B gives up most of the reliability you are building this for.\nCompleteness: A=9/10, B=7/10, C=3/10\nPros / cons:\nA) At-least-once with idempotency id (recommended)\n ✅ Retries apply to the whole webhook path, including transient receiver 5xx and network timeouts\n ✅ Stable delivery id on every attempt lets receivers dedupe; this is the documented contract most webhook consumers expect (human: ~1 day / CC: ~15 min)\n ❌ Receivers that ignore the id will see duplicates; the contract change must be documented and announced\nB) Preserve at-most-once\n ✅ No behavior change visible to receivers; no duplicate risk and no receiver-side work\n ✅ Retries still cover pre-send failures such as payload fetch or serialization errors (human: ~0.5 day / CC: ~10 min)\n ❌ A receiver 503 or a timeout after send is final: the event is dead-lettered, so the webhook path gains almost nothing from this project\nC) At-least-once, no dedupe id (plan as written)\n ✅ Smallest change: just let the library retry the existing handler as-is\n ✅ No contract documentation or header work (human: ~2 hours / CC: ~5 min)\n ❌ Duplicates are undetectable by receivers; the first incident is a double side effect with no way to reconcile\nNet: trading a documented, dedupe-able duplicate against either silent duplicates or a webhook path that cannot be retried.\nHeader: Webhook guarantee\nOptions:\nA) At-least-once + idempotency id (recommended)\n`processWebhookJob()` retries through the library hook (D1). Every attempt sends the same stable delivery id (header and body field) so receivers can dedupe; the contract change is documented for receivers. Completeness 9/10. Human: ~1 day / CC: ~15 min. Risk: receivers ignoring the id see duplicates. R2, R3, R4 remain pending.\nB) Preserve at-most-once\nRetries cover only failures before the HTTP send is attempted (payload fetch, serialization, auth). Once a send is attempted, no retry; failure goes to dead-letter. Receivers see no behavior change. Completeness 7/10. Human: ~0.5 day / CC: ~10 min. Risk: post-send transient failures are final. R2, R3, R4 remain pending.\nC) At-least-once, no dedupe id\nLet the library retry the existing handler unchanged. No delivery id, no contract documentation. Completeness 3/10. Human: ~2 hours / CC: ~5 min. Risk: undetectable duplicate side effects at receivers. R2, R3, R4 remain pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T10:30:44.867Z",
+ "old": "Let the library retry the existing handler unchanged. No delivery id, no contract documentation. Completeness 3/10. Human: ~2 hours / CC: ~5 min. Risk: undetectable duplicate side effects at receivers. R2, R3, R4 remain pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Let the library retry the existing handler unchanged. No delivery id, no contract documentation. Completeness 3/10. Human: ~2 hours / CC: ~5 min. Risk: undetectable duplicate side effects at receivers. R2, R3, R4 remain pending.\n\nState: approved\nActual answer: A) At-least-once + idempotency id — D2 answer (AskUserQuestion, this session)\nAccepted scope: `processWebhookJob()` retries through the library hook (D1). Every attempt sends the same stable delivery id, as both a request header and a body field, generated once at enqueue time and stored with the job so it survives retries and restarts. The receiver-facing contract change (at-most-once to at-least-once with dedupe id) is documented. Includes the code, tests and docs for that behavior; the regression/acceptance assertions for this guarantee are settled in R3 (Section 3). R2, R3, R4 remain pending.\nHistory: none\n\n### R2: Retry envelope duplication across 5 workers\nFinding: #4, P2, confidence 7/10, PLAN.md:12-14, reviewer: native (plan-eng-review)\nPlan baseline: original proposal — the envelope (compute delay, log attempt, dispatch) is copy-pasted into 5 worker files and left duplicated (PLAN.md:12-14). D1 (A) already moves \"compute delay\" into one backoff function and \"dispatch\" into the library runner. What remains unassigned is \"log attempt\".\nRuntime evidence: unknown. The 5 worker files are not in this repository; \"5\" and the envelope contents come from the plan text. Line estimates below are labelled estimates.\nShared-code rubric:\n- Callers: 5 proposed worker files (PLAN.md:13), proposed callers labelled as plan assumptions, not verified source.\n- Reuse before extracting: the job library's failure/retry lifecycle events (rung 4) are the reuse target for attempt logging; no new helper is needed if the library emits them.\n- Helper size: one listener registered once at worker boot, logging job id, attempt number, next delay and error class.\n- Whole change (estimates): removes ~5 × 15–30 lines of inline envelope = ~75–150 lines; adds ~15–25 lines (backoff function, already approved in D1) + ~10–20 lines (listener). Net savings ~40–125 lines before tests. Tests may add ~40–80 lines total.\n- Blast radius of a shared failure: a broken listener loses retry logging for all workers but does not alter retry behavior, since the library still drives retries.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R2 attempt-logging location | inline in each of 5 workers (duplicated, PLAN.md:12-14) | one listener on the library's failed/retry lifecycle event, registered once at worker boot; inline envelopes deleted from all 5 workers | a small shared `logRetryAttempt()` helper called from each of the 5 workers; inline envelopes deleted except the helper call | inline envelopes left duplicated in 5 workers, as planned (\"refactor later\") |\n| R1 retry mechanism | approved D1 (A) | unchanged | unchanged | unchanged |\n| R5 webhook guarantee | approved D2 (A) | unchanged | unchanged | unchanged |\n| R3 regression coverage | pending | pending | pending | pending |\n| R4 graph recompute | pending | pending | pending | pending |\n\nQuestion D3:\nD3 — Where does retry-attempt logging live once the library drives retries?\nProject/branch/task: `main` of the retry-framework plan; the retry envelope is copy-pasted into 5 worker files and the plan leaves it duplicated (PLAN.md:12-14).\nELI10: After D1 the library computes the delay and re-dispatches, so two of the three copy-pasted pieces disappear on their own. The third piece, \"log this attempt\", still has to live somewhere. Most job libraries fire an event every time a job fails and is scheduled to retry; one listener on that event logs every worker's attempts with zero code in the workers. The alternatives are a tiny shared function each worker calls, or leaving five copies and promising to clean up later.\nStakes if we pick wrong: five copies drift, one worker logs attempts differently or not at all, and the 3am incident has a hole exactly where the failing worker is.\nRecommendation: A because it uses an already-installed dependency (reuse ladder rung 4), needs no change inside the workers, and cannot drift across them.\nCompleteness: A=10/10, B=8/10, C=3/10\nPros / cons:\nA) One library lifecycle-event listener (recommended)\n ✅ Zero retry code left in any worker; every worker's attempts logged identically from one place (human: ~2 hours / CC: ~5 min)\n ✅ Uses the library's own failed/retry event, so logging stays correct even when a new worker is added later\n ❌ Depends on the library emitting a usable event with attempt number and next delay; verify at implementation time\nB) Shared `logRetryAttempt()` helper called per worker\n ✅ Works even if the library emits no retry event; explicit call site in each worker (human: ~3 hours / CC: ~10 min)\n ✅ Single implementation, so log format cannot drift between workers\n ❌ Every worker still carries a call and a new worker can forget it; one more seam to test per caller\nC) Leave the 5 inline copies, refactor later\n ✅ No change to the 5 workers beyond removing the inline scheduler already approved in D1\n ✅ Nothing new to learn or test right now (human: ~0 / CC: ~0)\n ❌ \"Later\" rarely arrives; five copies drift and the duplication finding stands unresolved in the ship review\nNet: trading a dependency on one library event against carrying retry code in five workers that already should have none.\nHeader: Attempt logging\nOptions:\nA) Library event listener (recommended)\nRegister one listener on the job library's failed/retry lifecycle event at worker boot. It logs job id, attempt number, next delay and error class for every worker. Delete the inline envelope from all 5 workers (compute-delay and dispatch already move to the library under D1). Completeness 10/10. Human: ~2 hours / CC: ~5 min. Risk: library must emit the event with attempt data; verify at implementation. R3, R4 remain pending.\nB) Shared logRetryAttempt() helper\nCreate one small helper that logs job id, attempt number, next delay and error class; each of the 5 workers calls it from its failure path. Delete the rest of the inline envelope. Completeness 8/10. Human: ~3 hours / CC: ~10 min. Risk: a new worker can forget the call; one caller-integration test per worker. R3, R4 remain pending.\nC) Leave duplication, refactor later\nKeep the per-worker attempt logging in all 5 workers as planned (PLAN.md:13-14); only the inline scheduler approved in D1 is removed. Completeness 3/10. Human: ~0 / CC: ~0. Risk: copies drift; finding stays open. R3, R4 remain pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T10:31:04.953Z",
+ "old": "## Decision ledger\n\n### R1: Retry mechanism",
+ "new": "## Section 2: Code quality review\n\nFindings:\n4. **[P2] (confidence: 7/10) PLAN.md:12-14 — Retry envelope copy-pasted across 5 workers and deliberately left duplicated.** Motivating lines: \"The retry envelope (compute delay, log attempt, dispatch) is duplicated / across 5 worker files with copy-pasted bodies. We will leave the / duplication for now and refactor \\\"later.\\\"\" Under D1 (A), compute-delay and dispatch already leave the workers; only attempt logging remains unassigned. Shared-code rubric evidence is in the R2 ledger record. Runtime evidence unknown (worker files not in repo); 7 because the 5-caller claim is the plan's own statement. Remedy: R2 / D3 below.\n5. **[P2] (confidence: 7/10) PLAN.md:17-18 — Rewriting `processWebhookJob()` and changing its delivery semantics in one step mixes refactor with behavior change.** Motivating line: \"The existing `processWebhookJob()` flow gets rewritten as part of this / change.\" Make-change-easy-first (Beck): land the mechanical move onto the library hook (D1) as its own commit with the existing behavior preserved, then land the at-least-once + delivery-id change (D2) as a second commit with its own tests (R3). Disposition: sequencing guidance carried into Implementation Tasks; no new behavior choice, so no question.\n6. **[P2] (confidence: 7/10) PLAN.md:7-9 — Error classification missing: the plan retries every failure the same way.** Motivating line: \"custom exponential-backoff scheduler ... full control over the curve.\" A 4xx from a webhook receiver (bad URL, revoked secret) or a malformed payload will never succeed on retry; retrying it burns the whole attempt budget and delays dead-lettering. Under D1 (A) the backoff function receives the error and can return the library's fail-now value for permanent errors. Disposition: covered by D1 (A) accepted scope (\"the function owns the curve\"), with the permanent-vs-transient split named as a required branch in Section 3's diagram and Implementation Tasks. No separate question.\n\nEdge cases and error handling flagged for implementation (no decision needed, carried into tasks): attempt number missing or 0 on the first failure; error object without a status code; delay cap smaller than base delay (misconfiguration); jitter producing a negative delay; job payload deleted between attempts (see Section 4).\n\nDispositions: #4 pending (D3). #5 accepted as sequencing guidance (no behavior choice). #6 accepted via D1 (A), carried forward.\n\n## Decision ledger\n\n### R1: Retry mechanism"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37198445662-t1-derived-refuse.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37198445662-t1-derived-refuse.json
new file mode 100644
index 000000000..4797d9ce3
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37198445662-t1-derived-refuse.json
@@ -0,0 +1,57 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "ded5316419aefeaf",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37198445662-1-eval-slices-1/plan-eng-review-1791113008502-OINJKr"
+ },
+ "census_outcome": "passed",
+ "expected": "refuse",
+ "justification": "Batching shape: only D1 was asked; the remaining findings went into the plan without their own questions, so fewer than FLOOR=3 review questions are credited.",
+ "derived": "First native question of the 37198445662 capture only, with the plan writes up to its answer.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "44fb791b-fa0f-460b-ad12-8b81b2668726",
+ "toolUseId": "toolu_01G2rk39qmpS2qHHiyoYTdDB",
+ "answeredAt": "2026-10-04T11:26:55.928Z",
+ "answer": "Library hooks + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Use the job library's retry hooks or roll a custom scheduler in each worker?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: The job library you already run knows how to retry a failed job: it remembers how many times it tried, waits, and tries again. The plan wants to write that logic by hand inside every worker so the wait times can be tuned. But the wait-time curve is just a function you hand the library; you don't need to rebuild the whole retry engine to change it. Rebuilding means five hand-rolled copies of state tracking that the library's dashboards and dead-letter tooling can't see.\nStakes if we pick wrong: a hand-rolled scheduler that drifts from the library (lost attempt counts on worker crash, retries invisible in the queue UI) means silent job loss or infinite retries that nobody can see.\nRecommendation: A because the plan's only stated reason for a custom scheduler (curve control) is satisfied by passing a custom backoff function to the existing hook, and it removes five copies of state-tracking code. [Layer 1]\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only; approves no implementation)\nPros / cons:\nA) Library hooks + custom curve (recommended) (human: ~1 day / CC: ~20 min)\n ✅ Attempt counts, scheduling and retry visibility stay in the library's persisted state, so a worker crash mid-retry doesn't lose the count\n ✅ The custom curve is one small pure function you can unit test, and the five inline schedulers are deleted rather than maintained\n ❌ The curve is bounded by what the hook exposes (per-attempt delay); anything the hook can't express needs a library upgrade or wrapper\nB) Custom inline scheduler (human: ~3-4 days / CC: ~1-2 h)\n ✅ Total control over timing, state storage and logging with no library constraint\n ✅ No dependency on the library's hook semantics or version\n ❌ Re-implements attempt persistence, scheduling and visibility that the library already has; five copies to keep correct, invisible to the library's queue UI and dead-letter flow\nC) Probe library hook API (CC: ≤30 min, implementation stays pending)\n ✅ Confirms the hook accepts a custom delay function and attempt cap before committing\n ✅ Costs nothing if the answer is already known from the library docs\n ❌ Delays the decision; R1 stays unresolved until the probe result is reviewed\nNet: trading unconstrained control for persisted, visible, library-managed retry state; the plan already admits the shapes are the same.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hooks + custom curve (recommended)",
+ "description": "Configure each worker's retry via the job library's built-in retry hook, passing a custom exponential backoff function for the curve. Delete the planned inline schedulers. Attempt counts and scheduling stay in the library's persisted state and are visible in its queue UI. Human: ~1 day / CC: ~20 min. Completeness 9/10. Other choices (R2–R6) stay pending."
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Keep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers, bypassing the library's retry hooks. Full control over timing and storage, but re-implements attempt persistence, scheduling and visibility, and is invisible to the library's queue UI and dead-letter flow. Human: ~3-4 days / CC: ~1-2 h. Completeness 5/10. Other choices (R2–R6) stay pending."
+ },
+ {
+ "label": "Probe library hook API",
+ "description": "Bounded investigation (CC ≤30 min): read the job library's retry hook API to confirm it accepts a custom delay function and an attempt cap, then re-ask R1 with the evidence. Approves no implementation; the retry mechanism and R2–R6 stay pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T11:25:36.588Z",
+ "write": "# Eng Review: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in `gstack-plan-count-ORkyrO` (branch `main`, commit f1f8ffc).\nReviewer: /plan-eng-review (claude host). Report file: this document (user-requested path).\n\n## Original plan (unchanged copy of PLAN.md)\n\n```markdown\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Review preparation\n\n- Design doc: none found. /office-hours prerequisite skipped per the user's instruction.\n- Brain context: no product or recent-decisions digest (cold).\n- Prior learnings: none (cross-project learnings disabled).\n- Retrospective history: the repo contains only `CLAUDE.md` and `PLAN.md` (single commit f1f8ffc). Worker, webhook and job-library paths named in the plan are `not available` in this checkout; nothing can be inferred about their history.\n- Test framework: unknown (no runtime markers, no test files in repo).\n- Web research: Aside not installed; host WebSearch used (read-only, untrusted).\n- Outside voice: `codex_reviews` is disabled in gstack config (CODEX_MODE: disabled).\n"
+ },
+ {
+ "at": "2026-10-04T11:26:36.008Z",
+ "old": "- Outside voice: `codex_reviews` is disabled in gstack config (CODEX_MODE: disabled).\n",
+ "new": "- Outside voice: `codex_reviews` is disabled in gstack config (CODEX_MODE: disabled).\n\n## Scope Challenge\n\nComplexity count: ~5 worker files + `processWebhookJob()` rewrite (likely one of them), 0 new classes/services. Under the 8-file / 2-service gate, so Section B skipped.\n\nFindings:\n1. [P1] (confidence: 8/10) PLAN.md:7-9 — Rebuilds the job library's retry scheduler inline in each worker although the plan admits parity (\"same shape as the library version\"). Curve control is a hook parameter in every mainstream job library; the custom path loses attempt persistence, dashboards and dead-letter handling. Reviewer: Scope Challenge (reuse ladder, Layer 1).\n\n## Decision ledger\n\n### R1: Retry mechanism (library hook vs custom inline scheduler)\nFinding: #1, P1, confidence 8/10, PLAN.md:7-9, reviewer: Scope Challenge\nPlan baseline: original proposal — custom exponential-backoff scheduler written inline in each of the 5 workers; library retry hooks not used (PLAN.md:7-9)\nRuntime evidence: unknown — worker files and job library are not in this checkout; the library's hook API (custom delay function, attempt cap) is unverified\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker (proposal) | library retry hook + custom backoff function per worker; delete inline schedulers | custom inline scheduler as planned | pending; bounded probe (CC ≤30 min): read the library's retry hook API to confirm custom delay fn + attempt cap, then re-ask R1 |\n| R2 shared retry envelope | duplicated in 5 workers (proposal), pending | pending | pending | pending |\n| R3 webhook delivery semantics | at-most-once dropped silently (proposal), pending | pending | pending | pending |\n| R4 dependency-graph cache | refetch per retry (proposal), pending | pending | pending | pending |\n| R5 attempt cap + terminal disposition | unspecified, pending | pending | pending | pending |\n| R6 jitter | unspecified, pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Use the job library's retry hooks or roll a custom scheduler in each worker?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: The job library you already run knows how to retry a failed job: it remembers how many times it tried, waits, and tries again. The plan wants to write that logic by hand inside every worker so the wait times can be tuned. But the wait-time curve is just a function you hand the library; you don't need to rebuild the whole retry engine to change it. Rebuilding means five hand-rolled copies of state tracking that the library's dashboards and dead-letter tooling can't see.\nStakes if we pick wrong: a hand-rolled scheduler that drifts from the library (lost attempt counts on worker crash, retries invisible in the queue UI) means silent job loss or infinite retries that nobody can see.\nRecommendation: A because the plan's only stated reason for a custom scheduler (curve control) is satisfied by passing a custom backoff function to the existing hook, and it removes five copies of state-tracking code. [Layer 1]\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only; approves no implementation)\nPros / cons:\nA) Library hooks + custom curve (recommended) (human: ~1 day / CC: ~20 min)\n ✅ Attempt counts, scheduling and retry visibility stay in the library's persisted state, so a worker crash mid-retry doesn't lose the count\n ✅ The custom curve is one small pure function you can unit test, and the five inline schedulers are deleted rather than maintained\n ❌ The curve is bounded by what the hook exposes (per-attempt delay); anything the hook can't express needs a library upgrade or wrapper\nB) Custom inline scheduler (human: ~3-4 days / CC: ~1-2 h)\n ✅ Total control over timing, state storage and logging with no library constraint\n ✅ No dependency on the library's hook semantics or version\n ❌ Re-implements attempt persistence, scheduling and visibility that the library already has; five copies to keep correct, invisible to the library's queue UI and dead-letter flow\nC) Probe library hook API (CC: ≤30 min, implementation stays pending)\n ✅ Confirms the hook accepts a custom delay function and attempt cap before committing\n ✅ Costs nothing if the answer is already known from the library docs\n ❌ Delays the decision; R1 stays unresolved until the probe result is reviewed\nNet: trading unconstrained control for persisted, visible, library-managed retry state; the plan already admits the shapes are the same.\nHeader: Retry mechanism\nOptions:\nA) Library hooks + custom curve (recommended)\nConfigure each worker's retry via the job library's built-in retry hook, passing a custom exponential backoff function for the curve. Delete the planned inline schedulers. Attempt counts and scheduling stay in the library's persisted state and are visible in its queue UI. Human: ~1 day / CC: ~20 min. Completeness 9/10. Other choices (R2–R6) stay pending.\nB) Custom inline scheduler\nKeep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers, bypassing the library's retry hooks. Full control over timing and storage, but re-implements attempt persistence, scheduling and visibility, and is invisible to the library's queue UI and dead-letter flow. Human: ~3-4 days / CC: ~1-2 h. Completeness 5/10. Other choices (R2–R6) stay pending.\nC) Probe library hook API\nBounded investigation (CC ≤30 min): read the job library's retry hook API to confirm it accepts a custom delay function and an attempt cap, then re-ask R1 with the evidence. Approves no implementation; the retry mechanism and R2–R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37198445662-t1-pass.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37198445662-t1-pass.json
new file mode 100644
index 000000000..81afa54b4
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/37198445662-t1-pass.json
@@ -0,0 +1,116 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "plan-eng-multi-finding-batching",
+ "detector": "test/helpers/eng-seeded-coverage.ts#createEngBatchingIssueCounter",
+ "series_identity": "ded5316419aefeaf",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "periodic",
+ "artifact": "paid-slice-1-a1",
+ "path": "shards/skill-e2e-plan-eng-multi-finding-batching/pty-count/ci-37198445662-1-eval-slices-1/plan-eng-review-1791113008502-OINJKr"
+ },
+ "census_outcome": "passed",
+ "expected": "pass",
+ "justification": "Census pass: three review-phase questions each bound to their own saved decision-ledger record (R IDs) in the plan; the counter credits each once.",
+ "input": {
+ "calls": [
+ {
+ "sessionId": "44fb791b-fa0f-460b-ad12-8b81b2668726",
+ "toolUseId": "toolu_01G2rk39qmpS2qHHiyoYTdDB",
+ "answeredAt": "2026-10-04T11:26:55.928Z",
+ "answer": "Library hooks + custom curve (recommended)",
+ "question": {
+ "question": "D1 — Use the job library's retry hooks or roll a custom scheduler in each worker?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: The job library you already run knows how to retry a failed job: it remembers how many times it tried, waits, and tries again. The plan wants to write that logic by hand inside every worker so the wait times can be tuned. But the wait-time curve is just a function you hand the library; you don't need to rebuild the whole retry engine to change it. Rebuilding means five hand-rolled copies of state tracking that the library's dashboards and dead-letter tooling can't see.\nStakes if we pick wrong: a hand-rolled scheduler that drifts from the library (lost attempt counts on worker crash, retries invisible in the queue UI) means silent job loss or infinite retries that nobody can see.\nRecommendation: A because the plan's only stated reason for a custom scheduler (curve control) is satisfied by passing a custom backoff function to the existing hook, and it removes five copies of state-tracking code. [Layer 1]\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only; approves no implementation)\nPros / cons:\nA) Library hooks + custom curve (recommended) (human: ~1 day / CC: ~20 min)\n ✅ Attempt counts, scheduling and retry visibility stay in the library's persisted state, so a worker crash mid-retry doesn't lose the count\n ✅ The custom curve is one small pure function you can unit test, and the five inline schedulers are deleted rather than maintained\n ❌ The curve is bounded by what the hook exposes (per-attempt delay); anything the hook can't express needs a library upgrade or wrapper\nB) Custom inline scheduler (human: ~3-4 days / CC: ~1-2 h)\n ✅ Total control over timing, state storage and logging with no library constraint\n ✅ No dependency on the library's hook semantics or version\n ❌ Re-implements attempt persistence, scheduling and visibility that the library already has; five copies to keep correct, invisible to the library's queue UI and dead-letter flow\nC) Probe library hook API (CC: ≤30 min, implementation stays pending)\n ✅ Confirms the hook accepts a custom delay function and attempt cap before committing\n ✅ Costs nothing if the answer is already known from the library docs\n ❌ Delays the decision; R1 stays unresolved until the probe result is reviewed\nNet: trading unconstrained control for persisted, visible, library-managed retry state; the plan already admits the shapes are the same.",
+ "header": "Retry mechanism",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Library hooks + custom curve (recommended)",
+ "description": "Configure each worker's retry via the job library's built-in retry hook, passing a custom exponential backoff function for the curve. Delete the planned inline schedulers. Attempt counts and scheduling stay in the library's persisted state and are visible in its queue UI. Human: ~1 day / CC: ~20 min. Completeness 9/10. Other choices (R2–R6) stay pending."
+ },
+ {
+ "label": "Custom inline scheduler",
+ "description": "Keep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers, bypassing the library's retry hooks. Full control over timing and storage, but re-implements attempt persistence, scheduling and visibility, and is invisible to the library's queue UI and dead-letter flow. Human: ~3-4 days / CC: ~1-2 h. Completeness 5/10. Other choices (R2–R6) stay pending."
+ },
+ {
+ "label": "Probe library hook API",
+ "description": "Bounded investigation (CC ≤30 min): read the job library's retry hook API to confirm it accepts a custom delay function and an attempt cap, then re-ask R1 with the evidence. Approves no implementation; the retry mechanism and R2–R6 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "44fb791b-fa0f-460b-ad12-8b81b2668726",
+ "toolUseId": "toolu_01KcwqZKjdsELndFyMSH34oW",
+ "answeredAt": "2026-10-04T11:28:34.286Z",
+ "answer": "Keep at-most-once (no retry)",
+ "question": {
+ "question": "D2 — When the webhook job retries, do receivers get duplicates, and how do we prove the chosen behavior?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: Today a webhook is sent at most once: if it fails, it fails, but the receiver never gets the same event twice. Turning on retries means a webhook that was actually delivered but whose job crashed right after can be sent again. Receivers that bill, email or create records on each webhook will double up. We have to choose on purpose: keep \"never twice\", or retry and give receivers a key so they can spot the repeat. Whatever we pick needs a test that fails if someone breaks it later.\nStakes if we pick wrong: duplicate webhooks cause double charges or double notifications downstream, or, if we keep no-retry, transient failures silently drop deliveries, which is today's behavior but now unexpected next to retried workers.\nRecommendation: B because the plan's purpose is to retry failed work, and an idempotency key is the standard, cheap way to keep duplicates harmless; pick A only if receivers cannot dedupe.\nCompleteness: A=8/10, B=9/10, C=4/10\nPros / cons:\nA) Keep at-most-once (no retry) (human: ~2 h / CC: ~10 min)\n ✅ Zero risk of duplicate side effects at receivers; nothing changes for consumers\n ✅ One small regression test proves the guarantee and guards the rewrite\n ❌ Webhook deliveries still die on the first transient failure, the exact pain the plan exists to fix\nB) At-least-once + idempotency key (recommended) (human: ~1 day / CC: ~30 min)\n ✅ Failed webhooks are actually redelivered, and receivers can discard repeats by key\n ✅ Three focused tests pin the contract: retry happens, key is stable across attempts, no re-send after success\n ❌ A consumer-facing contract change: receivers must honor the key, so it needs documentation and possibly a consumer heads-up\nC) At-least-once, no dedupe key (human: ~2 h / CC: ~10 min)\n ✅ Smallest change; retries work immediately with no header or doc work\n ✅ Fine if every receiver is already idempotent by its own design\n ❌ Any non-idempotent receiver silently gets duplicates after a crash between send and ack\nNet: trading a consumer-visible contract change (key header + docs) for redelivery without duplicate harm; the old guarantee is the alternative, not the default.",
+ "header": "Webhook delivery",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Keep at-most-once (no retry)",
+ "description": "Configure the webhook job's retry hook with a single attempt. Failures land in the failed/dead-letter state for manual replay. Regression test: a failing processWebhookJob makes exactly one outbound call, ends in failed state and is not re-dispatched. Human: ~2 h / CC: ~10 min. Completeness 8/10. R1 unchanged; R2, R4, R5 (other workers), R6 stay pending."
+ },
+ {
+ "label": "At-least-once + idempotency key (recommended)",
+ "description": "Enable retries for the webhook job through the library hook and send a stable idempotency key (derived from the job id, identical across attempts) with every delivery. Document the semantic change for receivers. Regression tests: (1) a failed attempt is re-dispatched per the curve; (2) attempts carry the identical key; (3) after a 2xx the job is never re-sent. Human: ~1 day / CC: ~30 min. Completeness 9/10. R1 unchanged; R2, R4, R5, R6 stay pending."
+ },
+ {
+ "label": "At-least-once, no dedupe key",
+ "description": "Plan as written: enable retries for the webhook job with no idempotency key. Regression test: a failed attempt is re-dispatched. No duplicate protection; receivers may get repeat deliveries after a crash between send and ack. Human: ~2 h / CC: ~10 min. Completeness 4/10. R1 unchanged; R2, R4, R5, R6 stay pending."
+ }
+ ]
+ }
+ },
+ {
+ "sessionId": "44fb791b-fa0f-460b-ad12-8b81b2668726",
+ "toolUseId": "toolu_01XNP4nUXg44MciFpLrF6uHN",
+ "answeredAt": "2026-10-04T11:29:43.939Z",
+ "answer": "Cap 5 + dead-letter record (recommended)",
+ "question": {
+ "question": "D3 — How many times does a failing job retry, and what happens when it gives up?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: A retry curve says how long to wait between tries, but not when to stop. A job with a bug in it, or a payload the code can never handle, will fail every time. Without a limit it retries forever and quietly eats worker capacity. When it does stop, we need to keep the job's data and the last error somewhere a human can look at it and re-run it after a fix; otherwise the failure vanishes.\nStakes if we pick wrong: poisoned jobs loop forever and starve healthy ones, or exhausted jobs are dropped with no payload and no error to debug, so the user's work is silently lost.\nRecommendation: A because a cap plus a replayable dead-letter record is how every mainstream job library already behaves, and the record is what makes a 3am failure diagnosable.\nCompleteness: A=9/10, B=6/10, C=2/10\nPros / cons:\nA) Cap 5 + dead-letter record (recommended) (human: ~half day / CC: ~15 min)\n ✅ Poisoned jobs stop after a bounded number of tries and stop stealing capacity from healthy ones\n ✅ Exhausted jobs keep payload, attempt count and last error, so an engineer can diagnose and replay after a fix\n ❌ Someone has to own the dead-letter set; without an owner it becomes a graveyard (alerting is a separate choice)\nB) Cap 5, library default failed state (human: ~1 h / CC: ~5 min)\n ✅ Bounded retries with almost no extra code; relies on whatever the library stores by default\n ✅ Fewer moving parts than a custom dead-letter record\n ❌ If the library's default failed state lacks the last error or attempt count, exhausted jobs are hard to diagnose or replay\nC) Unbounded retries (plan as written) (human: 0 / CC: 0)\n ✅ No job is ever declared failed; transient outages of any length eventually recover\n ✅ Nothing to configure or test for exhaustion\n ❌ A job that can never succeed retries forever, consumes worker slots and hides the bug indefinitely\nNet: trading a small amount of exhaustion handling and ownership for bounded worker load and diagnosable, replayable failures.",
+ "header": "Retry cap",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Cap 5 + dead-letter record (recommended)",
+ "description": "Configure a per-worker max-attempts value through the library hook (default 5). On exhaustion the job moves to the library's failed/dead-letter set keeping payload, job id, attempt count, last error and stack, so it can be replayed. Required proof: attempt below cap re-dispatches; attempt at cap lands in the failed set with attempts and last error recorded. Human: ~half day / CC: ~15 min. Completeness 9/10. R1, R3 unchanged; R2, R4, R6, R7 pending."
+ },
+ {
+ "label": "Cap 5, library default failed state",
+ "description": "Configure a per-worker max-attempts value (default 5) and rely on the library's default failed state with no added enrichment. Required proof: attempt at cap stops re-dispatch. Human: ~1 h / CC: ~5 min. Completeness 6/10. R1, R3 unchanged; R2, R4, R6, R7 pending."
+ },
+ {
+ "label": "Unbounded retries (plan as written)",
+ "description": "No attempt cap and no terminal disposition; a failing job retries until it succeeds. No exhaustion test. Completeness 2/10. R1, R3 unchanged; R2, R4, R6, R7 pending."
+ }
+ ]
+ }
+ }
+ ],
+ "planEvents": [
+ {
+ "at": "2026-10-04T11:25:36.588Z",
+ "write": "# Eng Review: Add background job retry framework\n\nReview target (fixed): `PLAN.md` in `gstack-plan-count-ORkyrO` (branch `main`, commit f1f8ffc).\nReviewer: /plan-eng-review (claude host). Report file: this document (user-requested path).\n\n## Original plan (unchanged copy of PLAN.md)\n\n```markdown\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Review preparation\n\n- Design doc: none found. /office-hours prerequisite skipped per the user's instruction.\n- Brain context: no product or recent-decisions digest (cold).\n- Prior learnings: none (cross-project learnings disabled).\n- Retrospective history: the repo contains only `CLAUDE.md` and `PLAN.md` (single commit f1f8ffc). Worker, webhook and job-library paths named in the plan are `not available` in this checkout; nothing can be inferred about their history.\n- Test framework: unknown (no runtime markers, no test files in repo).\n- Web research: Aside not installed; host WebSearch used (read-only, untrusted).\n- Outside voice: `codex_reviews` is disabled in gstack config (CODEX_MODE: disabled).\n"
+ },
+ {
+ "at": "2026-10-04T11:26:36.008Z",
+ "old": "- Outside voice: `codex_reviews` is disabled in gstack config (CODEX_MODE: disabled).\n",
+ "new": "- Outside voice: `codex_reviews` is disabled in gstack config (CODEX_MODE: disabled).\n\n## Scope Challenge\n\nComplexity count: ~5 worker files + `processWebhookJob()` rewrite (likely one of them), 0 new classes/services. Under the 8-file / 2-service gate, so Section B skipped.\n\nFindings:\n1. [P1] (confidence: 8/10) PLAN.md:7-9 — Rebuilds the job library's retry scheduler inline in each worker although the plan admits parity (\"same shape as the library version\"). Curve control is a hook parameter in every mainstream job library; the custom path loses attempt persistence, dashboards and dead-letter handling. Reviewer: Scope Challenge (reuse ladder, Layer 1).\n\n## Decision ledger\n\n### R1: Retry mechanism (library hook vs custom inline scheduler)\nFinding: #1, P1, confidence 8/10, PLAN.md:7-9, reviewer: Scope Challenge\nPlan baseline: original proposal — custom exponential-backoff scheduler written inline in each of the 5 workers; library retry hooks not used (PLAN.md:7-9)\nRuntime evidence: unknown — worker files and job library are not in this checkout; the library's hook API (custom delay function, attempt cap) is unverified\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | custom inline scheduler per worker (proposal) | library retry hook + custom backoff function per worker; delete inline schedulers | custom inline scheduler as planned | pending; bounded probe (CC ≤30 min): read the library's retry hook API to confirm custom delay fn + attempt cap, then re-ask R1 |\n| R2 shared retry envelope | duplicated in 5 workers (proposal), pending | pending | pending | pending |\n| R3 webhook delivery semantics | at-most-once dropped silently (proposal), pending | pending | pending | pending |\n| R4 dependency-graph cache | refetch per retry (proposal), pending | pending | pending | pending |\n| R5 attempt cap + terminal disposition | unspecified, pending | pending | pending | pending |\n| R6 jitter | unspecified, pending | pending | pending | pending |\n\nQuestion D1:\nD1 — Use the job library's retry hooks or roll a custom scheduler in each worker?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: The job library you already run knows how to retry a failed job: it remembers how many times it tried, waits, and tries again. The plan wants to write that logic by hand inside every worker so the wait times can be tuned. But the wait-time curve is just a function you hand the library; you don't need to rebuild the whole retry engine to change it. Rebuilding means five hand-rolled copies of state tracking that the library's dashboards and dead-letter tooling can't see.\nStakes if we pick wrong: a hand-rolled scheduler that drifts from the library (lost attempt counts on worker crash, retries invisible in the queue UI) means silent job loss or infinite retries that nobody can see.\nRecommendation: A because the plan's only stated reason for a custom scheduler (curve control) is satisfied by passing a custom backoff function to the existing hook, and it removes five copies of state-tracking code. [Layer 1]\nCompleteness: A=9/10, B=5/10, C=n/a (investigation only; approves no implementation)\nPros / cons:\nA) Library hooks + custom curve (recommended) (human: ~1 day / CC: ~20 min)\n ✅ Attempt counts, scheduling and retry visibility stay in the library's persisted state, so a worker crash mid-retry doesn't lose the count\n ✅ The custom curve is one small pure function you can unit test, and the five inline schedulers are deleted rather than maintained\n ❌ The curve is bounded by what the hook exposes (per-attempt delay); anything the hook can't express needs a library upgrade or wrapper\nB) Custom inline scheduler (human: ~3-4 days / CC: ~1-2 h)\n ✅ Total control over timing, state storage and logging with no library constraint\n ✅ No dependency on the library's hook semantics or version\n ❌ Re-implements attempt persistence, scheduling and visibility that the library already has; five copies to keep correct, invisible to the library's queue UI and dead-letter flow\nC) Probe library hook API (CC: ≤30 min, implementation stays pending)\n ✅ Confirms the hook accepts a custom delay function and attempt cap before committing\n ✅ Costs nothing if the answer is already known from the library docs\n ❌ Delays the decision; R1 stays unresolved until the probe result is reviewed\nNet: trading unconstrained control for persisted, visible, library-managed retry state; the plan already admits the shapes are the same.\nHeader: Retry mechanism\nOptions:\nA) Library hooks + custom curve (recommended)\nConfigure each worker's retry via the job library's built-in retry hook, passing a custom exponential backoff function for the curve. Delete the planned inline schedulers. Attempt counts and scheduling stay in the library's persisted state and are visible in its queue UI. Human: ~1 day / CC: ~20 min. Completeness 9/10. Other choices (R2–R6) stay pending.\nB) Custom inline scheduler\nKeep the plan as written: a hand-rolled exponential-backoff scheduler inline in each of the 5 workers, bypassing the library's retry hooks. Full control over timing and storage, but re-implements attempt persistence, scheduling and visibility, and is invisible to the library's queue UI and dead-letter flow. Human: ~3-4 days / CC: ~1-2 h. Completeness 5/10. Other choices (R2–R6) stay pending.\nC) Probe library hook API\nBounded investigation (CC ≤30 min): read the job library's retry hook API to confirm it accepts a custom delay function and an attempt cap, then re-ask R1 with the evidence. Approves no implementation; the retry mechanism and R2–R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T11:28:04.336Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hooks + custom curve (D1 answer)\nAccepted scope: Configure each worker's retry through the job library's built-in retry hook with a custom exponential backoff function; delete the planned inline schedulers. Attempt counts and scheduling remain in the library's persisted state. R2–R6 stay pending.\nHistory: none\n\nScope Challenge result: scope accepted as-is (feature list unchanged; R1 changed the mechanism, not the scope). MODE = FULL_REVIEW. Dispositions: #1 accepted (D1 → A).\n\n## Section 1: Architecture review\n\nRetry flow with R1 applied (library hook + custom curve):\n\n```\nenqueue(job) ──► worker.process(job)\n │\n ├─ success ──► ack / complete (never re-dispatched)\n │\n └─ throw(err)\n │\n ├─ permanent error (4xx, validation) ──► fail now ──► dead-letter [R5]\n │\n └─ retryable error\n │\n ├─ attempt < cap [R5] ──► delay = curve(attempt) ± jitter [R6] ──► library re-dispatch\n │ (attempt count persisted by library)\n └─ attempt == cap ──► dead-letter {payload, job id, attempts, last error} [R5]\n\nprocessWebhookJob (today: at-most-once) processWebhookJob (with retries)\n send HTTP ──► fail ──► job marked failed, no re-send send HTTP ──► fail ──► re-send ──► receiver may get N copies [R3]\n```\n\nFindings:\n2. [P1] (confidence: 8/10) PLAN.md:17-19 — \"No regression test for the prior at-most-once delivery guarantee is planned.\" Adding retries to `processWebhookJob()` turns at-most-once into at-least-once: a webhook that reached the receiver but whose job failed afterward (timeout after send, crash before ack) is sent again. The plan drops a delivery guarantee silently with no decision on whether receivers can tolerate duplicates. Reviewer: Architecture.\n3. [P2] (confidence: 7/10) PLAN.md:7-9 — The curve is specified but no attempt cap or terminal disposition is. Without a cap a poisoned job retries forever; without a dead-letter record its payload and last error are lost. Reviewer: Architecture.\n4. [P2] (confidence: 7/10) PLAN.md:7 — Pure exponential backoff with no jitter. When a shared downstream (DB, third-party API) blips, every failed job across 5 workers retries at the same instant, repeating the outage (thundering herd). Reviewer: Architecture.\n\nProduction failure per new path: (a) worker crashes after the webhook HTTP send but before ack → library re-dispatches → duplicate webhook (R3). (b) Downstream API returns 429 for 10 minutes → all jobs hit cap in lockstep without jitter, dead-letter in a burst (R5/R6). (c) Permanent 4xx retried as if transient → wasted attempts, delayed dead-letter (R5).\nSecurity: no new auth or data-access boundary; dead-letter records contain full payloads, so they inherit the payload's data classification. Distribution: no new artifacts, no CI/CD changes.\n\n### R3: processWebhookJob delivery semantics under retry (behavior + regression contract)\nFinding: #2, P1, confidence 8/10, PLAN.md:17-19, reviewer: Architecture\nPlan baseline: original proposal — processWebhookJob rewritten to retry; at-most-once guarantee dropped with no regression test (PLAN.md:17-19)\nRuntime evidence: unknown — processWebhookJob source and its callers are not in this checkout; whether receivers already dedupe is unverified\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | approved: library hook + custom curve (D1 → A) | unchanged | unchanged | unchanged |\n| R3 webhook delivery semantics | at-most-once dropped silently (proposal) | at-most-once kept: webhook job attempts = 1 via hook config; failures go to failed/dead-letter state for manual replay | at-least-once with idempotency key: retries on, every attempt sends the same stable key (derived from job id), consumer-facing doc of the change | at-least-once, no dedupe key (proposal as written) |\n| R3 regression assertions (required proof) | none planned | test: failing webhook job → exactly 1 outbound call, job in failed state, not re-dispatched | tests: (1) failed attempt is re-dispatched per curve; (2) attempts 1..n carry identical idempotency key; (3) after 2xx the job is never re-sent | test: failed attempt is re-dispatched; no duplicate protection asserted |\n| R2 shared envelope | pending | pending | pending | pending |\n| R4 graph cache | pending | pending | pending | pending |\n| R5 cap + terminal disposition (non-webhook workers) | pending | pending (webhook cap fixed at 1 by this behavior) | pending | pending |\n| R6 jitter | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — When the webhook job retries, do receivers get duplicates, and how do we prove the chosen behavior?\nProject/branch/task: gstack-plan-count-ORkyrO on main, plan \"Add background job retry framework\".\nELI10: Today a webhook is sent at most once: if it fails, it fails, but the receiver never gets the same event twice. Turning on retries means a webhook that was actually delivered but whose job crashed right after can be sent again. Receivers that bill, email or create records on each webhook will double up. We have to choose on purpose: keep \"never twice\", or retry and give receivers a key so they can spot the repeat. Whatever we pick needs a test that fails if someone breaks it later.\nStakes if we pick wrong: duplicate webhooks cause double charges or double notifications downstream, or, if we keep no-retry, transient failures silently drop deliveries, which is today's behavior but now unexpected next to retried workers.\nRecommendation: B because the plan's purpose is to retry failed work, and an idempotency key is the standard, cheap way to keep duplicates harmless; pick A only if receivers cannot dedupe.\nCompleteness: A=8/10, B=9/10, C=4/10\nPros / cons:\nA) Keep at-most-once (no retry) (human: ~2 h / CC: ~10 min)\n ✅ Zero risk of duplicate side effects at receivers; nothing changes for consumers\n ✅ One small regression test proves the guarantee and guards the rewrite\n ❌ Webhook deliveries still die on the first transient failure, the exact pain the plan exists to fix\nB) At-least-once + idempotency key (recommended) (human: ~1 day / CC: ~30 min)\n ✅ Failed webhooks are actually redelivered, and receivers can discard repeats by key\n ✅ Three focused tests pin the contract: retry happens, key is stable across attempts, no re-send after success\n ❌ A consumer-facing contract change: receivers must honor the key, so it needs documentation and possibly a consumer heads-up\nC) At-least-once, no dedupe key (human: ~2 h / CC: ~10 min)\n ✅ Smallest change; retries work immediately with no header or doc work\n ✅ Fine if every receiver is already idempotent by its own design\n ❌ Any non-idempotent receiver silently gets duplicates after a crash between send and ack\nNet: trading a consumer-visible contract change (key header + docs) for redelivery without duplicate harm; the old guarantee is the alternative, not the default.\nHeader: Webhook delivery\nOptions:\nA) Keep at-most-once (no retry)\nConfigure the webhook job's retry hook with a single attempt. Failures land in the failed/dead-letter state for manual replay. Regression test: a failing processWebhookJob makes exactly one outbound call, ends in failed state and is not re-dispatched. Human: ~2 h / CC: ~10 min. Completeness 8/10. R1 unchanged; R2, R4, R5 (other workers), R6 stay pending.\nB) At-least-once + idempotency key (recommended)\nEnable retries for the webhook job through the library hook and send a stable idempotency key (derived from the job id, identical across attempts) with every delivery. Document the semantic change for receivers. Regression tests: (1) a failed attempt is re-dispatched per the curve; (2) attempts carry the identical key; (3) after a 2xx the job is never re-sent. Human: ~1 day / CC: ~30 min. Completeness 9/10. R1 unchanged; R2, R4, R5, R6 stay pending.\nC) At-least-once, no dedupe key\nPlan as written: enable retries for the webhook job with no idempotency key. Regression test: a failed attempt is re-dispatched. No duplicate protection; receivers may get repeat deliveries after a crash between send and ack. Human: ~2 h / CC: ~10 min. Completeness 4/10. R1 unchanged; R2, R4, R5, R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T11:29:22.757Z",
+ "old": "Plan as written: enable retries for the webhook job with no idempotency key. Regression test: a failed attempt is re-dispatched. No duplicate protection; receivers may get repeat deliveries after a crash between send and ack. Human: ~2 h / CC: ~10 min. Completeness 4/10. R1 unchanged; R2, R4, R5, R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Plan as written: enable retries for the webhook job with no idempotency key. Regression test: a failed attempt is re-dispatched. No duplicate protection; receivers may get repeat deliveries after a crash between send and ack. Human: ~2 h / CC: ~10 min. Completeness 4/10. R1 unchanged; R2, R4, R5, R6 stay pending.\n\nState: approved\nActual answer: A) Keep at-most-once (no retry) (D2 answer)\nAccepted scope: processWebhookJob keeps at-most-once delivery. Its retry hook is configured with a single attempt; failures land in the failed/dead-letter state for manual replay. Regression contract (required proof, CRITICAL): a failing processWebhookJob makes exactly one outbound call, ends in failed state and is not re-dispatched. Document that the webhook job is intentionally excluded from retries. R1 unchanged; R2, R4, R5 (other workers), R6 stay pending.\nHistory: none\n\n### R5: Attempt cap and terminal disposition for the four retried workers\nFinding: #3, P2, confidence 7/10, PLAN.md:7-9, reviewer: Architecture\nPlan baseline: original proposal — exponential-backoff curve only; no attempt cap, no terminal disposition specified (PLAN.md:7-9)\nRuntime evidence: unknown — worker files and the library's failed-job/dead-letter facility are not in this checkout\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 retry mechanism | approved: library hook + custom curve (D1 → A) | unchanged | unchanged | unchanged |\n| R3 webhook delivery | approved: at-most-once, 1 attempt (D2 → A) | unchanged | unchanged | unchanged |\n| R5 attempt cap | unspecified (proposal) | per-worker max attempts via hook config, default 5 | per-worker max attempts via hook config, default 5 | none; retry until success (proposal) |\n| R5 terminal disposition on exhaustion | unspecified (proposal) | dead-letter record kept by the library's failed set with payload, job id, attempt count, last error + stack; replayable | library default failed state, no enrichment beyond what the library stores | none; job never terminates |\n| R5 required proof | none | tests: attempt n`\n(\"Plan: Add background job retry framework\"). Reviewer: /plan-eng-review,\nsession 702-1791142398-ec68c409, branch main, commit e767d72.\n\n## Original plan (unchanged copy)\n\n```markdown\n# Plan: Add background job retry framework\n\n## Architecture\nWe'll roll a custom exponential-backoff scheduler inline in each worker\nrather than use the existing job library's built-in retry hooks. Same\nshape as the library version, but we want full control over the curve.\n\n## Code quality\nThe retry envelope (compute delay, log attempt, dispatch) is duplicated\nacross 5 worker files with copy-pasted bodies. We will leave the\nduplication for now and refactor \"later.\"\n\n## Tests\nThe existing `processWebhookJob()` flow gets rewritten as part of this\nchange. No regression test for the prior at-most-once delivery guarantee\nis planned.\n\n## Performance\nOn every retry we re-fetch the full job payload from the database, then\niterate the payload to recompute the dependency graph. Could cache the\ngraph on the first attempt; not planned.\n```\n\n## Scope Challenge — assessment\n\n**Evidence available.** The review repo contains only `CLAUDE.md` and `PLAN.md`.\nThe five worker files, `processWebhookJob()`, the job library and its retry hooks\nare named by the plan but are `not available` in this checkout. Every finding\nbelow is calibrated against the plan text (file:line) plus industry practice;\nruntime behavior of the existing code is unknown.\n\n**What already solves each sub-problem.**\n- Exponential backoff scheduling: the plan says the existing job library already\n ships retry hooks and the custom version is \"same shape\" (PLAN.md:7-9). Reuse\n ladder rung 4 (already-installed dependency). Whether the hooks accept a custom\n backoff function is unknown; typical libraries (BullMQ, Sidekiq, Oban, Laravel\n queues, Celery) do.\n- Retry envelope (compute delay, log attempt, dispatch): no shared helper exists\n per the plan; five proposed copies (PLAN.md:12-14).\n- Delivery guarantee: today `processWebhookJob()` is at-most-once (PLAN.md:17-19).\n Any retry layer turns it into at-least-once unless dispatch is excluded or\n deduped.\n\n**Minimum change that achieves the goal.** Configure the library's retry hook with\na custom curve; one small envelope helper for the five workers only if the library\nhook cannot own the envelope; settle webhook delivery semantics explicitly and\ncover them with a regression test; compute the dependency graph once per job.\n\n**Complexity check (estimates).** Proposed changed files: 5 worker files, plus 0-1\nnew helper module. New classes/services: 0-1 (a scheduler/helper). Below the\n8-file / 2-class gate, so Scope Challenge B is skipped.\n\n**Search check.** Aside not installed; host WebSearch used (one read-only query,\nsanitized). Consensus: use the queue library's declarative retry/backoff with\njitter and a max-attempt cap, route exhausted jobs to a dead-letter queue, and\ntreat duplicates as policy under at-least-once, so side effects need idempotency\nkeys. Sources:\n[DEV: Node.js job queue retry/ack/idempotency](https://dev.to/thomasmoore157/how-to-build-a-nodejs-background-job-queue-retry-ack-and-idempotency-59ea),\n[Berkanium: job queues done right](https://www.berkanium.net/articles/job-queues-done-right),\n[Moments Log: retry policy checklist](https://www.momentslog.com/development/background-job-retry-policy-checklist-how-to-prevent-queues-from-amplifying-production-failures),\n[benmoataz: idempotent job queue](https://www.benmoataz.com/posts/idempotent-job-queue/),\n[Laravel queue best practices](https://dev.to/laravelo11y/laravel-background-jobs-12-best-practices-for-production-queues-5gcm).\nLayering: library retry hooks are **[Layer 1]**; a hand-rolled inline scheduler\nis **[Layer 3]** with no first-principles reason given beyond \"control over the\ncurve\", which a backoff callback already provides.\n\n**TODOS cross-reference.** No `TODOS.md` in the repo.\n\n**Completeness check.** A regression test for at-most-once delivery and a cached\ndependency graph each cost CC minutes; both are deferred in the plan.\n\n**Distribution check.** No new artifacts; nothing to publish.\n\n## Scope Challenge — findings\n\n1. `[P1] (confidence: 7/10) PLAN.md:7-9 — Plan rebuilds the job library's retry\n scheduling inline in each worker although it admits the result is \"same shape\"\n as the built-in; \"full control over the curve\" is normally a backoff callback\n on the existing hook, not a reason to bypass the library's attempt counting,\n dead-lettering and visibility.` Motivating lines: \"We'll roll a custom\n exponential-backoff scheduler inline in each worker rather than use the\n existing job library's built-in retry hooks. Same shape as the library\n version, but we want full control over the curve.\" Not 9/10 because the\n library is not in this checkout, so whether its hook accepts a custom curve\n is unverified.\n\nRelated findings on duplication, delivery semantics and per-retry refetch are\nhandled in Sections 2, 1/3 and 4 respectively; the first depends on R1's answer.\n\n## Decision ledger\n\n### R1: Retry scheduling mechanism (library hook vs custom inline scheduler)\nFinding: Scope Challenge #1, P1, confidence 7/10, PLAN.md:7-9, reviewer: plan-eng-review (Claude)\nPlan baseline: original proposal, custom exponential-backoff scheduler inline in each of the 5 workers, bypassing the library retry hooks (PLAN.md:7-9). Nothing approved yet.\nRuntime evidence: unknown. Job library, its hook API and the 5 worker files are not in this checkout. Industry practice (search check above) favors the library's declarative retry with a backoff callback.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R1 scheduling mechanism | custom inline scheduler per worker (proposed) | library retry hook with a custom backoff callback; fall back to custom only if a probe proves the hook cannot express the curve | custom inline scheduler as planned | bounded probe of the library hook API (one read of its retry/backoff config and attempt-counting semantics); mechanism stays pending |\n| Backoff curve shape (exponent, base, cap, jitter) | unspecified | unspecified, pending | unspecified, pending | unspecified, pending |\n| R2 envelope sharing (Section 2) | 5 copies, refactor later (proposed) | pending; may become moot if the hook owns the envelope | pending | pending |\n| R3 webhook delivery semantics (Sections 1/3) | at-most-once, no regression test (proposed) | pending | pending | pending |\n| R4 dependency-graph caching (Section 4) | recompute per retry (proposed) | pending | pending | pending |\n\nQuestion D1:\nD1 — Use the job library's retry hook or roll a custom inline scheduler?\nProject/branch/task: main branch of the retry-framework plan; the plan currently bypasses the existing job library's retry hooks in 5 workers.\nELI10: The job library you already run knows how to retry a failed job: it counts attempts, waits, re-queues, and gives up after a limit. The plan rewrites that logic by hand inside every worker because the team wants to shape the wait-time curve. Most libraries let you pass your own curve to the built-in retry, so hand-rolling buys little and loses the library's bookkeeping.\nStakes if we pick wrong: a hand-rolled scheduler owns attempt counting, dead-lettering and crash recovery itself; a bug there means jobs retry forever or vanish silently, in five places, and the library's dashboards and metrics stop telling the truth.\nRecommendation: A because the plan itself says the custom version is \"same shape\" as the library's, and a backoff callback gives full control of the curve without re-owning retry state.\nCompleteness: A=9/10, B=6/10, C=n/a (investigation only)\nPros / cons:\nA) Library hook + custom backoff callback (recommended)\n ✅ Library keeps attempt counting, max tries, dead-letter routing and crash recovery; one tested code path instead of five new ones\n ✅ Curve is still fully yours: exponent, cap and jitter live in one callback that is trivial to unit test (human: ~1 day / CC: ~10 min)\n ❌ If the library's hook turns out not to accept a custom curve, you spend a short probe before falling back to a custom scheduler\nB) Custom inline scheduler as planned\n ✅ No dependency on the library's hook API or its release cadence; the team controls every line\n ✅ Can express curves or side effects the hook cannot, if such a need exists\n ❌ Re-owns attempt counting, persistence across worker crashes and dead-lettering in 5 workers; high blast radius for a 3am bug (human: ~1 week / CC: ~45 min plus tests)\nC) Probe the library hook first, decide after\n ✅ Replaces an assumption with evidence: one read of the retry config API settles whether a backoff callback is supported\n ✅ Bounded: one hour human / a few CC minutes; no implementation changes until the answer lands\n ❌ Delays the decision; everything downstream (envelope sharing, tests) waits on it\nNet: trade a short API probe and a thin callback against re-owning retry state in five workers.\nHeader: Retry mechanism\nOptions:\nA) Library hook + custom backoff callback (recommended)\nConfigure the existing job library's retry hook with a custom backoff callback (exponent, cap, jitter in one function). The library keeps attempt counting, max tries, dead-letter routing and crash recovery. Fall back to a custom scheduler only if a probe proves the hook cannot express the curve. Completeness 9/10. Effort: human ~1 day / CC ~10 min. Backoff shape, envelope sharing (R2), webhook semantics (R3) and graph caching (R4) stay pending.\nB) Custom inline scheduler as planned\nKeep the plan: hand-rolled exponential-backoff scheduler inline in each of the 5 workers, bypassing the library's retry hooks. The team re-owns attempt counting, persistence across crashes and dead-lettering. Completeness 6/10. Effort: human ~1 week / CC ~45 min plus tests. Backoff shape, R2, R3 and R4 stay pending.\nC) Probe the library hook first, decide after\nBounded investigation only: read the job library's retry/backoff configuration API and attempt-counting semantics (one hour human / a few CC minutes), then return to this question. Approves no implementation; the mechanism and R2, R3, R4 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T19:37:47.896Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Library hook + custom backoff callback (D1 answer)\nAccepted scope: Replace the custom inline scheduler with the existing job library's retry hook configured with one custom backoff callback (exponent, cap, jitter in one function). The library keeps attempt counting, max tries, dead-letter routing and crash recovery. Fall back to a custom scheduler only if a probe proves the hook cannot express the curve. Backoff shape/parameters, R2, R3 and R4 stay pending.\nHistory: none\n\n**Scope Challenge result:** scope accepted as-is (D1 changes the mechanism, not the feature scope). Dispositions: finding #1 accepted via D1.\n\n### R2: Retry envelope sharing across the 5 workers\nFinding: Section 2 (Code quality), P2, confidence 8/10, PLAN.md:12-14, reviewer: plan-eng-review (Claude)\nPlan baseline: original proposal, copy-pasted envelope (compute delay, log attempt, dispatch) in 5 worker files, refactor \"later\". D1 (approved) moves delay computation and dispatch into the library hook plus one backoff callback.\nRuntime evidence: unknown; worker files not in this checkout. Shared-code rubric: 5 proposed callers (plan-stated, not verified source); common behavior per plan text; reliability gain is one place to fix curve/logging bugs.\nComparison grid: pending (prepared in Section 2)\nQuestion: pending\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n\n### R3: Webhook delivery guarantee under retry, with its regression contract\nFinding: Section 1 (Architecture) #1, P1, confidence 8/10, PLAN.md:17-19, reviewer: plan-eng-review (Claude)\nPlan baseline: original proposal: `processWebhookJob()` rewritten inside the retry change; prior guarantee at-most-once; no regression test planned. D1 (approved) puts retries under the library hook, which re-runs the whole job on failure or crash. No delivery semantics approved yet.\nRuntime evidence: unknown; `processWebhookJob()` and its callers/receivers are not in this checkout. Industry sources (search check) agree retries make duplicates policy unless side effects are idempotent.\nComparison grid:\n\n| Choice | Current | A | B | C |\n|---|---|---|---|---|\n| R3 webhook delivery guarantee | at-most-once (existing), plan silent on what retries do to it | at-most-once preserved: library retry applies only to failures before the HTTP send is attempted; the send stage runs with max attempts 1 (or a persisted \"dispatching\" marker checked before send) so crash-after-send is not re-sent | at-least-once: webhook retried on send failure with a stable idempotency key per job (not per attempt) in the request; contract change documented for receivers | bounded investigation: audit receivers for dedupe behavior and read `processWebhookJob()`; guarantee stays pending |\n| Regression assertions (required proof of the chosen guarantee) | none planned | test: failure injected after send → exactly one HTTP call, job marked failed/dead-lettered, not re-queued; test: failure before send → job retried per curve; test: worker crash simulated between send and ack → no second send | test: send failure → retried per curve with identical idempotency key on every attempt; test: key stable across attempts and process restart; test: exhausted attempts → dead-letter, receiver contract doc updated | none until the guarantee is chosen |\n| R1 mechanism (approved D1) | library hook + callback | unchanged | unchanged | unchanged |\n| R2 envelope sharing | pending | pending | pending | pending |\n| R4 dependency-graph caching | pending | pending | pending | pending |\n| R5 retry policy parameters | pending | pending | pending | pending |\n\nQuestion D2:\nD2 — What delivery guarantee does the webhook job keep once retries exist, and what test proves it?\nProject/branch/task: main branch of the retry-framework plan; `processWebhookJob()` is being rewritten under the newly approved library retry hook (D1).\nELI10: Today a webhook fires at most once: if it fails, it is not sent again, so receivers never see duplicates. A retry framework re-runs failed jobs, which by definition can send the same webhook twice. The plan rewrites the webhook job without saying which behavior receivers should get, and plans no test to prove it. We need to pick the guarantee and lock it with a regression test before the rewrite.\nStakes if we pick wrong: external receivers either start getting duplicate webhooks they never deduped (double charges, double notifications) or the team believes retries protect webhooks when they do not; either failure is silent because nothing tests it.\nRecommendation: A because it preserves the contract receivers already rely on, still lets retries cover transient pre-send failures, and keeps a later move to at-least-once a separate, reversible decision.\nNote: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Keep at-most-once; retry only before send (recommended)\n ✅ Receivers see no behavior change; no coordination with external consumers or idempotency rollout needed\n ✅ Transient failures while loading the payload or building the graph still get retried; three regression tests pin the contract (human: ~1 day / CC: ~15 min)\n ❌ A webhook whose HTTP send fails stays failed and dead-lettered; operators must replay it by hand\nB) Move to at-least-once with an idempotency key\n ✅ Send failures self-heal through the retry curve; this is how most webhook platforms behave\n ✅ A per-job idempotency key gives receivers a clean way to dedupe, and tests pin key stability across attempts\n ❌ It is a receiver-facing contract change: every consumer must dedupe on the key before this ships, or they get duplicates (human: ~1 week incl. consumer outreach / CC: ~30 min code)\nC) Investigate receivers and the current job first\n ✅ Replaces guesses about receiver dedupe behavior and the current send path with evidence\n ✅ Bounded: read `processWebhookJob()` and the receiver list (human: ~half day / CC: ~5 min)\n ❌ The guarantee and its tests stay undecided; the rewrite cannot start safely until this returns\nNet: preserve the existing promise and retry only the safe half of the job, or change the promise and pay the consumer-coordination cost up front.\nHeader: Webhook guarantee\nOptions:\nA) Keep at-most-once; retry only before send (recommended)\nLibrary retry applies only to failures before the HTTP send is attempted; the send stage runs with max attempts 1 or checks a persisted \"dispatching\" marker so a crash after send does not re-send. Required regression tests: failure after send → exactly one HTTP call and the job is failed/dead-lettered, not re-queued; failure before send → retried per the curve; simulated crash between send and ack → no second send. Effort: human ~1 day / CC ~15 min. R2, R4 and R5 stay pending.\nB) Move to at-least-once with an idempotency key\nWebhook retried on send failure under the library curve, with a stable per-job (not per-attempt) idempotency key in the request; receiver-facing contract change documented. Required tests: send failure → retried with an identical key on every attempt; key stable across process restart; exhausted attempts → dead-letter. Effort: human ~1 week including consumer outreach / CC ~30 min code. R2, R4 and R5 stay pending.\nC) Investigate receivers and the current job first\nBounded investigation only: read `processWebhookJob()` and audit receivers for dedupe behavior (human ~half day / CC ~5 min), then return to this question. Approves no implementation or tests; the guarantee, R2, R4 and R5 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T19:38:59.721Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "State: approved\nActual answer: A) Keep at-most-once; retry only before send (D2 answer)\nAccepted scope: `processWebhookJob()` keeps its at-most-once delivery guarantee. Library retry applies only to failures before the HTTP send is attempted; the send stage runs with max attempts 1 or checks a persisted \"dispatching\" marker so a crash after send does not re-send. Required regression tests (carried into Section 3 as approved proof): failure after send → exactly one HTTP call and the job is failed/dead-lettered, not re-queued; failure before send → retried per the curve; simulated crash between send and ack → no second send. R2, R4 and R5 stay pending.\nHistory: none\n\n### R5: Retry exhaustion policy (bounded attempts and dead-letter)\nFinding: Section 1 (Architecture) #2, P2, confidence 8/10, PLAN.md:6-9, reviewer: plan-eng-review (Claude)\nPlan baseline: original proposal names \"exponential backoff\" and \"full control over the curve\" but sets no attempt limit and no behavior for a job that keeps failing. D1 (approved) puts retries under the library hook, which exposes max attempts and dead-letter routing. Nothing approved for exhaustion.\nRuntime evidence: unknown; library defaults not inspectable here. Search check: unlimited retries and missing dead-letter handling are the most-cited retry pitfalls (retry storms, poison jobs hidden forever).\nComparison grid:\n\n| Choice | Current | A | B |\n|---|---|---|---|\n| R5 exhaustion policy | unspecified; plan silent | bounded attempts (plan names a default, implementer tunes per job type) then dead-letter with attempt history logged and a metric/alert on dead-letter growth | leave to the library's default; plan stays silent |\n| Max delay cap | implied only by attempt count | derived: base × 2^attempts, chosen so the last wait is acceptable; documented in plan | unspecified |\n| R1 mechanism (D1), R3 guarantee (D2) | approved | unchanged | unchanged |\n| R2 envelope sharing, R4 graph caching, R6 jitter | pending | pending | pending |\n\nQuestion D3:\nD3 — How many times may a job retry, and what happens when it runs out?\nProject/branch/task: main branch of the retry-framework plan; retries now run under the library hook (D1) with the webhook send excluded (D2).\nELI10: Exponential backoff only says how long to wait between tries, not how many tries. Without a limit, a job that can never succeed (bad payload, deleted record) keeps coming back forever and floods the queue; with a limit but no landing spot, it just disappears when the limit is hit. The plan says nothing about either.\nStakes if we pick wrong: a poison job retries indefinitely and starves real work, or failed jobs vanish silently and nobody notices customers' work never happened.\nRecommendation: A because bounded attempts plus a dead-letter queue with an alert is the standard, boring shape, and the library already provides it under D1.\nCompleteness: A=9/10, B=4/10\nPros / cons:\nA) Bounded attempts, dead-letter, alert (recommended)\n ✅ Poison jobs stop after a known number of tries and land somewhere an operator can inspect and replay; attempt history is logged with them\n ✅ A metric or alert on dead-letter growth turns silent failure into a page; one config line per job type (human: ~half day / CC: ~10 min)\n ❌ Each job type needs a sensible attempt count and someone has to own the dead-letter queue\nB) Leave it to library defaults, plan stays silent\n ✅ Zero extra work now; the library's default limit applies if it has one\n ✅ No new operational surface (no dead-letter queue to watch) in this change\n ❌ Nobody has verified the default; it may be unlimited or silent-drop, and the plan would ship a retry framework with no defined end state\nNet: a few config lines and an alert now versus discovering the exhaustion behavior in production.\nHeader: Retry exhaustion\nOptions:\nA) Bounded attempts, dead-letter, alert (recommended)\nPlan names a default max-attempt count per job type (implementer tunes), exhausted jobs go to the library's dead-letter route with their attempt history logged, and a metric/alert fires on dead-letter growth. Max delay cap is derived from base × 2^attempts and documented. Completeness 9/10. Effort: human ~half day / CC ~10 min. R2, R4 and R6 stay pending.\nB) Leave it to library defaults, plan stays silent\nKeep the plan as written: no attempt limit, no dead-letter behavior and no alert specified; whatever the library does by default applies. Completeness 4/10. Effort: none. R2, R4 and R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T19:39:22.395Z",
+ "old": "Approves no implementation or tests; the guarantee, R2, R4 and R5 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Approves no implementation or tests; the guarantee, R2, R4 and R5 stay pending.\n\nState: approved\nActual answer: A) Keep at-most-once; retry only before send (D2 answer)\nAccepted scope: `processWebhookJob()` keeps its at-most-once delivery guarantee. Library retry applies only to failures before the HTTP send is attempted; the send stage runs with max attempts 1 or checks a persisted \"dispatching\" marker so a crash after send does not re-send. Required regression tests (carried into Section 3 as approved proof): failure after send → exactly one HTTP call and the job is failed/dead-lettered, not re-queued; failure before send → retried per the curve; simulated crash between send and ack → no second send. R2, R4 and R5 stay pending.\nHistory: none\n\n### R5: Retry exhaustion policy (bounded attempts and dead-letter)\nFinding: Section 1 (Architecture) #2, P2, confidence 8/10, PLAN.md:6-9, reviewer: plan-eng-review (Claude)\nPlan baseline: original proposal names \"exponential backoff\" and \"full control over the curve\" but sets no attempt limit and no behavior for a job that keeps failing. D1 (approved) puts retries under the library hook, which exposes max attempts and dead-letter routing. Nothing approved for exhaustion.\nRuntime evidence: unknown; library defaults not inspectable here. Search check: unlimited retries and missing dead-letter handling are the most-cited retry pitfalls (retry storms, poison jobs hidden forever).\nComparison grid:\n\n| Choice | Current | A | B |\n|---|---|---|---|\n| R5 exhaustion policy | unspecified; plan silent | bounded attempts (plan names a default, implementer tunes per job type) then dead-letter with attempt history logged and a metric/alert on dead-letter growth | leave to the library's default; plan stays silent |\n| Max delay cap | implied only by attempt count | derived: base × 2^attempts, chosen so the last wait is acceptable; documented in plan | unspecified |\n| R1 mechanism (D1), R3 guarantee (D2) | approved | unchanged | unchanged |\n| R2 envelope sharing, R4 graph caching, R6 jitter | pending | pending | pending |\n\nQuestion D3:\nD3 — How many times may a job retry, and what happens when it runs out?\nProject/branch/task: main branch of the retry-framework plan; retries now run under the library hook (D1) with the webhook send excluded (D2).\nELI10: Exponential backoff only says how long to wait between tries, not how many tries. Without a limit, a job that can never succeed (bad payload, deleted record) keeps coming back forever and floods the queue; with a limit but no landing spot, it just disappears when the limit is hit. The plan says nothing about either.\nStakes if we pick wrong: a poison job retries indefinitely and starves real work, or failed jobs vanish silently and nobody notices customers' work never happened.\nRecommendation: A because bounded attempts plus a dead-letter queue with an alert is the standard, boring shape, and the library already provides it under D1.\nCompleteness: A=9/10, B=4/10\nPros / cons:\nA) Bounded attempts, dead-letter, alert (recommended)\n ✅ Poison jobs stop after a known number of tries and land somewhere an operator can inspect and replay; attempt history is logged with them\n ✅ A metric or alert on dead-letter growth turns silent failure into a page; one config line per job type (human: ~half day / CC: ~10 min)\n ❌ Each job type needs a sensible attempt count and someone has to own the dead-letter queue\nB) Leave it to library defaults, plan stays silent\n ✅ Zero extra work now; the library's default limit applies if it has one\n ✅ No new operational surface (no dead-letter queue to watch) in this change\n ❌ Nobody has verified the default; it may be unlimited or silent-drop, and the plan would ship a retry framework with no defined end state\nNet: a few config lines and an alert now versus discovering the exhaustion behavior in production.\nHeader: Retry exhaustion\nOptions:\nA) Bounded attempts, dead-letter, alert (recommended)\nPlan names a default max-attempt count per job type (implementer tunes), exhausted jobs go to the library's dead-letter route with their attempt history logged, and a metric/alert fires on dead-letter growth. Max delay cap is derived from base × 2^attempts and documented. Completeness 9/10. Effort: human ~half day / CC ~10 min. R2, R4 and R6 stay pending.\nB) Leave it to library defaults, plan stays silent\nKeep the plan as written: no attempt limit, no dead-letter behavior and no alert specified; whatever the library does by default applies. Completeness 4/10. Effort: none. R2, R4 and R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ },
+ {
+ "at": "2026-10-04T19:40:25.492Z",
+ "old": "Keep the plan as written: no attempt limit, no dead-letter behavior and no alert specified; whatever the library does by default applies. Completeness 4/10. Effort: none. R2, R4 and R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n",
+ "new": "Keep the plan as written: no attempt limit, no dead-letter behavior and no alert specified; whatever the library does by default applies. Completeness 4/10. Effort: none. R2, R4 and R6 stay pending.\n\nState: approved\nActual answer: A) Bounded attempts, dead-letter, alert (D3 answer)\nAccepted scope: Plan names a default max-attempt count per job type (implementer tunes); exhausted jobs go to the library's dead-letter route with attempt history logged; a metric/alert fires on dead-letter growth; max delay cap derived from base × 2^attempts and documented. R2, R4 and R6 stay pending.\nHistory: none\n\n**Section 1 (Architecture) dispositions:** #1 webhook guarantee accepted via D2; #2 exhaustion policy accepted via D3.\n\n### R2 (continued): Retry envelope sharing across the 5 workers\nPlan baseline (updated): the original 5 copy-pasted envelopes (PLAN.md:12-14). Under D1 the library hook owns delay computation and re-dispatch; what remains per worker is the backoff callback, per-job-type retry config (max attempts per D3, webhook send stage at 1 attempt per D2) and attempt logging.\nRuntime evidence: unknown; worker files not in this checkout. Rubric accounting (estimates, plan-stated callers): 5 inline envelopes at ~25-35 lines each = ~125-175 lines proposed; one shared retry-policy module ~40-60 lines plus ~3-5 config lines per worker = ~55-85 lines; implementation savings ~50-110 lines. Shared-module unit tests add ~60-100 lines, so the total diff may grow while duplicated logic drops to zero. Blast radius of a shared bug: all 5 job types retry on the wrong curve; mitigated by the callback being pure and unit-tested, and by the library owning re-queueing.\nComparison grid:\n\n| Choice | Current | A | B |\n|---|---|---|---|\n| R2 envelope sharing | 5 copy-pasted envelopes, refactor \"later\" (proposed) | one shared retry-policy module: the backoff callback, a per-job-type policy table (attempts, send-stage exclusion) and one attempt-logging hook registered at queue level; each worker declares its policy in a few lines | keep 5 inline copies of the remaining envelope (callback invocation, logging), refactor later |\n| R1 mechanism (D1), R3 guarantee (D2), R5 exhaustion (D3) | approved | unchanged | unchanged |\n| R4 graph caching, R6 jitter, R7 error classification | pending | pending | pending |\n\nQuestion D4:\nD4 — One shared retry-policy module, or five inline copies refactored \"later\"?\nProject/branch/task: main branch of the retry-framework plan; with D1 the library owns re-dispatch, so what is left per worker is the curve callback, the per-job policy and attempt logging.\nELI10: The plan copies the same retry wrapper into five worker files and promises to clean it up later. Later rarely comes, and five copies means five places to fix when the curve or the logging is wrong. With the library hook doing the heavy lifting, the shared part is small: one curve function, one table of per-job settings, one logging hook.\nStakes if we pick wrong: a curve bug or a logging gap gets fixed in four files and missed in the fifth, and the next job type copies whichever version its author found first.\nRecommendation: A because five plan-stated callers with identical behavior clear the shared-code bar, the helper is tiny, and extracting before the behavior change keeps the refactor and the feature separate.\nCompleteness: A=9/10, B=4/10\nPros / cons:\nA) One shared retry-policy module (recommended)\n ✅ Curve, per-job attempt limits and attempt logging live in one tested place; adding a sixth job type is a one-line policy entry\n ✅ Roughly 50-110 fewer implementation lines than five copies, and the refactor lands before the behavior change (human: ~1 day / CC: ~15 min)\n ❌ A bug in the shared callback hits all five job types at once, so the module needs its own unit tests\nB) Five inline copies, refactor later\n ✅ Each worker is self-contained and readable without jumping to a shared file\n ✅ No shared-failure blast radius; a mistake stays inside one job type\n ❌ Five copies of the same logic drift; the plan already calls this \"duplicated\" and defers the fix with no owner or trigger\nNet: a small shared module with tests now, or five copies and a refactor nobody has scheduled.\nHeader: Envelope sharing\nOptions:\nA) One shared retry-policy module (recommended)\nExtract one module holding the backoff callback, a per-job-type policy table (max attempts per D3, webhook send stage excluded per D2) and one attempt-logging hook registered at queue level. Each of the 5 workers declares its policy in a few lines; no copy-pasted envelope remains. Shared-module unit tests are required proof. Completeness 9/10. Effort: human ~1 day / CC ~15 min. R4, R6 and R7 stay pending.\nB) Five inline copies, refactor later\nKeep the plan as written: each worker carries its own callback invocation, policy constants and attempt logging; refactor deferred with no trigger. Completeness 4/10. Effort: human ~0 extra now / CC ~0, debt carried. R4, R6 and R7 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: Scope Challenge placeholder record (grid pending) replaced by this prepared record in Section 2.\n"
+ },
+ {
+ "at": "2026-10-04T19:41:23.353Z",
+ "old": "State: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: Scope Challenge placeholder record (grid pending) replaced by this prepared record in Section 2.\n",
+ "new": "State: approved\nActual answer: A) One shared retry-policy module (D4 answer)\nAccepted scope: One shared module holding the backoff callback, a per-job-type policy table (max attempts per D3, webhook send stage excluded per D2) and one attempt-logging hook registered at queue level. Each of the 5 workers declares its policy in a few lines; no copy-pasted envelope remains. Shared-module unit tests are required proof (carried into Section 3). R4, R6 and R7 stay pending.\nHistory: Scope Challenge placeholder record (grid pending) replaced by this prepared record in Section 2.\n\n### R7: Retryable vs permanent error classification\nFinding: Section 2 (Code quality) #2, P2, confidence 7/10, PLAN.md:6-9 and 12-14, reviewer: plan-eng-review (Claude)\nPlan baseline: original proposal retries on any failure; the envelope is \"compute delay, log attempt, dispatch\" with no mention of which errors are retryable. D3 (approved) bounds attempts and dead-letters on exhaustion. No classification approved.\nRuntime evidence: unknown; worker error types not in this checkout. Pattern: validation errors, missing records and 4xx-class responses never succeed on retry; retrying them burns the full attempt budget before dead-lettering.\nComparison grid:\n\n| Choice | Current | A | B |\n|---|---|---|---|\n| R7 error classification | retry every failure until exhaustion (implicit) | shared policy module distinguishes permanent errors (validation, not-found, non-retryable 4xx) from transient ones; permanent errors go straight to dead-letter with the reason, transient ones follow the curve | retry every failure until the D3 attempt bound, then dead-letter |\n| R1, R2, R3, R5 (approved D1-D4) | approved | unchanged | unchanged |\n| R4 graph caching, R6 jitter | pending | pending | pending |\n\nQuestion D5:\nD5 — Should permanent errors skip the retry curve and dead-letter immediately?\nProject/branch/task: main branch of the retry-framework plan; retries live in the shared policy module (D4) under the library hook (D1) with bounded attempts (D3).\nELI10: Some failures fix themselves if you wait, like a database hiccup. Others never will, like a payload that fails validation or a record that was deleted. Retrying the second kind just wastes every attempt and delays the moment someone notices. The plan treats all failures the same.\nStakes if we pick wrong: permanent failures sit in the queue for the whole backoff window before anyone sees them, and the queue spends worker time on jobs that cannot succeed; or, if classification is wrong, a transient error is mislabeled permanent and a recoverable job is dead-lettered early.\nRecommendation: A because the shared module already exists under D4, the classification is a small predicate, and it turns a slow silent failure into an immediate visible one.\nCompleteness: A=9/10, B=6/10\nPros / cons:\nA) Classify: permanent errors dead-letter immediately (recommended)\n ✅ Bad payloads and missing records surface in the dead-letter queue on the first attempt with the reason attached, instead of after the full curve\n ✅ Worker time and the attempt budget go to failures that can actually recover; the predicate is one function in the shared module with its own tests (human: ~half day / CC: ~10 min)\n ❌ A misclassified transient error gets dead-lettered early; the predicate must default to retry when unsure\nB) Retry everything until the attempt bound\n ✅ Simplest rule, no error taxonomy to maintain across five job types\n ✅ No risk of mislabeling a recoverable error as permanent\n ❌ Permanent failures consume every attempt and the full backoff window before dead-lettering; a bad deploy fills the queue with hopeless retries\nNet: a small, retry-by-default predicate in the shared module versus burning the full curve on errors that can never succeed.\nHeader: Error classes\nOptions:\nA) Classify: permanent errors dead-letter immediately (recommended)\nThe shared policy module gains a retryable predicate: validation errors, not-found and non-retryable 4xx-class responses go straight to dead-letter with the reason; everything else follows the curve. Default is retry when unsure. Unit tests cover both branches (required proof). Completeness 9/10. Effort: human ~half day / CC ~10 min. R4 and R6 stay pending.\nB) Retry everything until the attempt bound\nEvery failure follows the D3 curve until attempts are exhausted, then dead-letters. No error taxonomy. Completeness 6/10. Effort: none. R4 and R6 stay pending.\n\nState: pending\nActual answer: unanswered\nAccepted scope: none\nHistory: none\n"
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/inventory.json b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/inventory.json
new file mode 100644
index 000000000..f32fd968a
--- /dev/null
+++ b/test/fixtures/detector-corpora/plan-eng-multi-finding-batching/inventory.json
@@ -0,0 +1,92 @@
+[
+ {
+ "run": "37162480720",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "5553dbf327080983",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37162480720-t1-pass.json"
+ },
+ {
+ "run": "37174266054",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "57a825b033078a7b",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37174266054-t1-pass.json"
+ },
+ {
+ "run": "37176837432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "57a825b033078a7b",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37176837432-t1-pass.json"
+ },
+ {
+ "run": "37179171083",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "57a825b033078a7b",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37179171083-t1-pass.json"
+ },
+ {
+ "run": "37182865432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "c620ebc29ce470c6",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37182865432-t1-pass.json"
+ },
+ {
+ "run": "37186854666",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "a3e4c3429b2cfef8",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37186854666-t1-pass.json"
+ },
+ {
+ "run": "37193478719",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "a3e4c3429b2cfef8",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37193478719-t1-pass.json"
+ },
+ {
+ "run": "37195203538",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "ded5316419aefeaf",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37195203538-t1-pass.json"
+ },
+ {
+ "run": "37198445662",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "ded5316419aefeaf",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37198445662-t1-pass.json"
+ },
+ {
+ "run": "37228573062",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "4a37ebd37e42d08d",
+ "artifact": "paid-slice-1-a1",
+ "available": true,
+ "entry": "37228573062-t1-fail.json"
+ }
+]
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37174266054-t1-derived-refuse.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37174266054-t1-derived-refuse.json
new file mode 100644
index 000000000..f92a75327
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37174266054-t1-derived-refuse.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "bbcc80998be9c465",
+ "source": {
+ "run": "37174266054",
+ "sha": "a8ace39030dd92745b45ac16e5d733e63c936c0f",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0335.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "refuse",
+ "justification": "Genuine scope expansion: the recommended option now commits to helper hardening, which the PLAN and fixture prompt put outside this actor's reuse-and-proof interface.",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers import the existing shared Retry-After parser or each implement their own?\nProject/branch/task: fixture repo, branch main, plan review of PLAN.md for the future src/import-worker.ts and src/sync-route.ts callers.\nELI10: The plan says each new caller writes its own code to turn a Retry-After header into a wait time. But the scheduler already uses one shared function in lib/retry-after.ts that does exactly this, with the same inputs (header text or null, an injected clock, a caller fallback) and the same 3600-second cap. Because the plan requires the new callers to behave exactly like the scheduler, writing two more copies means two more places that can silently drift from it.\nStakes if we pick wrong: a copy that differs by one edge case (blank header, past date, huge number) makes one worker retry at a different time than the scheduler, and nothing in the test suite would notice.\nRecommendation: A because the shared helper already meets every listed requirement, is the contract itself, and reusing it is the smallest clear change with the best drift protection.\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Reuse shared helper (recommended)\n ✅ Compatibility with the scheduler is structural: both callers execute the exact function the scheduler executes, so drift is impossible.\n ✅ Saves roughly 24 implementation lines and reuses a passing test; the added contract regressions protect all three callers at once.\n ❌ Any future change to lib/retry-after.ts now affects three callers instead of one, so that file needs care (same as today for the scheduler).\nB) Copies with contract test\n ✅ Keeps the plan's per-caller structure while still proving each copy matches the scheduler via one parameterized suite.\n ✅ A bug introduced in one copy is isolated to that caller rather than reaching the scheduler.\n ❌ Adds ~15–25 lines of test harness to defend ~26 lines of duplicated logic; equivalence holds only as long as the suite is kept wired to every copy.\nC) Keep local parsers\n ✅ Zero change to the plan as drafted and no new dependency between the callers and lib/.\n ✅ Fastest to write if the author already has the parser text in hand.\n ❌ No proof the copies match the scheduler; the repo already has two verbatim copies showing this pattern spreads, and edge-case drift would be invisible.\nNet: trading one shared dependency (already relied on by the scheduler) against two or more unproven copies of the same 13 lines.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse shared helper",
+ "description": "✅ Both new callers import `retrySeconds` from lib/retry-after.ts and pass their header value, injected now and own fallback; no local parser. ✅ Extend test/retry-after.test.ts with regressions for blank/whitespace → fallback, past date → 0, sub-second ceil → 1, unsafe integer → fallback, and injected now; keep one integration test per caller. ❌ lib/retry-after.ts becomes a three-caller dependency. Scheduler semantics stay unchanged; also harden the helper's numeric parsing for malformed headers. Completeness 10/10. human ~1 h / CC ~5 min."
+ },
+ {
+ "label": "Copies with contract test",
+ "description": "✅ Each caller keeps its own local parser as drafted. ✅ Add one parameterized contract suite running the same assertions against lib/retry-after.ts and both copies, plus one integration test per caller. ❌ ~15–25 extra test lines to defend ~26 duplicated lines; equivalence depends on the suite staying wired to every copy. Scheduler semantics, existing callers and helper hardening stay unchanged/out of scope. Completeness 7/10. human ~½ day / CC ~15 min."
+ },
+ {
+ "label": "Keep local parsers",
+ "description": "✅ Plan unchanged: local parser per caller with one integration test each. ✅ No new dependency on lib/. ❌ No shared-contract proof for the copies; drift from the scheduler would be undetected. Scheduler semantics, existing callers and helper hardening stay unchanged/out of scope. Completeness 3/10. human ~2 h / CC ~10 min."
+ }
+ ]
+ }
+ ]
+ },
+ "derived": "negative control from the 37174266054 census capture; recommended option description: the exclusion \"... and helper hardening stay unchanged/out of scope.\" replaced by a proposal to harden the helper's numeric parsing"
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37174266054-t1-fail.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37174266054-t1-fail.json
new file mode 100644
index 000000000..6a7d8eac9
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37174266054-t1-fail.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "bbcc80998be9c465",
+ "source": {
+ "run": "37174266054",
+ "sha": "a8ace39030dd92745b45ac16e5d733e63c936c0f",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0335.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "pass",
+ "justification": "Fixed false red: every option reuses the unchanged lib/retry-after.ts helper and names hardening only as excluded scope or a risk; census-time actor (a8ace390) had a bare /harden\\w*/ expansion pattern and an exclusion grammar that missed \"stay unchanged/out of scope\"; 98a3d6c, then 2a6b766 (verb+object expansions), squashed to main in 2db0b3a.",
+ "expected_answer": "Reuse shared helper",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers import the existing shared Retry-After parser or each implement their own?\nProject/branch/task: fixture repo, branch main, plan review of PLAN.md for the future src/import-worker.ts and src/sync-route.ts callers.\nELI10: The plan says each new caller writes its own code to turn a Retry-After header into a wait time. But the scheduler already uses one shared function in lib/retry-after.ts that does exactly this, with the same inputs (header text or null, an injected clock, a caller fallback) and the same 3600-second cap. Because the plan requires the new callers to behave exactly like the scheduler, writing two more copies means two more places that can silently drift from it.\nStakes if we pick wrong: a copy that differs by one edge case (blank header, past date, huge number) makes one worker retry at a different time than the scheduler, and nothing in the test suite would notice.\nRecommendation: A because the shared helper already meets every listed requirement, is the contract itself, and reusing it is the smallest clear change with the best drift protection.\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Reuse shared helper (recommended)\n ✅ Compatibility with the scheduler is structural: both callers execute the exact function the scheduler executes, so drift is impossible.\n ✅ Saves roughly 24 implementation lines and reuses a passing test; the added contract regressions protect all three callers at once.\n ❌ Any future change to lib/retry-after.ts now affects three callers instead of one, so that file needs care (same as today for the scheduler).\nB) Copies with contract test\n ✅ Keeps the plan's per-caller structure while still proving each copy matches the scheduler via one parameterized suite.\n ✅ A bug introduced in one copy is isolated to that caller rather than reaching the scheduler.\n ❌ Adds ~15–25 lines of test harness to defend ~26 lines of duplicated logic; equivalence holds only as long as the suite is kept wired to every copy.\nC) Keep local parsers\n ✅ Zero change to the plan as drafted and no new dependency between the callers and lib/.\n ✅ Fastest to write if the author already has the parser text in hand.\n ❌ No proof the copies match the scheduler; the repo already has two verbatim copies showing this pattern spreads, and edge-case drift would be invisible.\nNet: trading one shared dependency (already relied on by the scheduler) against two or more unproven copies of the same 13 lines.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse shared helper",
+ "description": "✅ Both new callers import `retrySeconds` from lib/retry-after.ts and pass their header value, injected now and own fallback; no local parser. ✅ Extend test/retry-after.test.ts with regressions for blank/whitespace → fallback, past date → 0, sub-second ceil → 1, unsafe integer → fallback, and injected now; keep one integration test per caller. ❌ lib/retry-after.ts becomes a three-caller dependency. Scheduler semantics, existing callers and helper hardening stay unchanged/out of scope. Completeness 10/10. human ~1 h / CC ~5 min."
+ },
+ {
+ "label": "Copies with contract test",
+ "description": "✅ Each caller keeps its own local parser as drafted. ✅ Add one parameterized contract suite running the same assertions against lib/retry-after.ts and both copies, plus one integration test per caller. ❌ ~15–25 extra test lines to defend ~26 duplicated lines; equivalence depends on the suite staying wired to every copy. Scheduler semantics, existing callers and helper hardening stay unchanged/out of scope. Completeness 7/10. human ~½ day / CC ~15 min."
+ },
+ {
+ "label": "Keep local parsers",
+ "description": "✅ Plan unchanged: local parser per caller with one integration test each. ✅ No new dependency on lib/. ❌ No shared-contract proof for the copies; drift from the scheduler would be undetected. Scheduler semantics, existing callers and helper hardening stay unchanged/out of scope. Completeness 3/10. human ~2 h / CC ~10 min."
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37176837432-t1-pass.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37176837432-t1-pass.json
new file mode 100644
index 000000000..cb42e9310
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37176837432-t1-pass.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "bba88e411dd393e2",
+ "source": {
+ "run": "37176837432",
+ "sha": "7249f014648202840af294b730ac244e03ce514c",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0428.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "passed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Census pass: the question offers only unchanged-helper reuse with proof-depth alternatives, and the actor answered \"Reuse helper, full proof\".",
+ "expected_answer": "Reuse helper, full proof",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Reuse the shared Retry-After parser for the two proposed callers, or copy it locally?\nProject/branch/task: gstack-shared-plan-callers fixture, branch main, planning src/import-worker.ts and src/sync-route.ts.\nELI10: Both new files need to turn a Retry-After header into a wait in seconds, exactly the way the scheduler already does. The scheduler already gets that from one tested function in lib/retry-after.ts. The plan instead writes a fresh copy of that logic inside each new file. Copies look identical today but can drift apart silently, so a future fix to one would not reach the others.\nStakes if we pick wrong: with local copies, a later ceiling or date-parsing fix lands in one place and the other callers keep waiting the wrong number of seconds; with reuse but thin proof, callers depend on past-date and date-ceiling behavior that no test pins down.\nRecommendation: A because the shared helper already exists, is exercised by the scheduler, and reuse removes about 26 implementation lines while adding only the contract cases the new callers rely on.\nCompleteness: A=10/10, B=7/10, C=5/10\nPros / cons:\nA) Reuse helper, full proof (recommended)\n ✅ Zero new parser logic; both callers import retrySeconds(value, now, fallback) exactly as src/scheduler.ts does today\n ✅ Adds the missing regression cases (past date → 0, date beyond cap → 3600, unsafe integer → fallback, whitespace-only → fallback) to test/retry-after.test.ts, plus one delegation assertion per caller integration test\n ❌ Any future bug in the shared helper now reaches the scheduler and both new callers at once (human: ~1 h / CC: ~5 min)\nB) Reuse helper, existing proof\n ✅ Same import wiring as A with no changes to the shared test file\n ✅ Smallest possible diff: roughly two lines per caller plus the integration tests the plan already promises\n ❌ Past-date and date-ceiling paths the callers depend on remain untested, so a regression there would reach production unnoticed (human: ~45 min / CC: ~3 min)\nC) Local parser per caller\n ✅ Each caller is fully self-contained and can diverge later without touching shared code\n ✅ No change to lib/retry-after.ts or its test file\n ❌ Adds two more 15-line copies of logic that already exists three times in the repo, each needing its own full contract test to meet the every-behavior-tested bar (human: ~2 h / CC: ~10 min)\nNet: trading a slightly larger shared blast radius for one tested source of truth and about 26 fewer implementation lines.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper, full proof",
+ "description": "Both src/import-worker.ts and src/sync-route.ts import retrySeconds from lib/retry-after.ts and call retrySeconds(header, now, fallback); no local parser. Shared contract stays unchanged. Adds four regression cases to test/retry-after.test.ts (past date → 0, date beyond 3600 s cap → 3600, unsafe integer → fallback, whitespace-only → fallback). Each caller's integration test asserts its wait equals the helper's output for an injected now, a null header and one date header. Implementation ≈ +4 lines vs ~30 drafted (saves ~26); tests ≈ +6. Blast radius: scheduler plus both callers. Existing copies in src/retry-route.ts and src/retry-worker.ts stay untouched. (human: ~1 h / CC: ~5 min)"
+ },
+ {
+ "label": "Reuse helper, existing proof",
+ "description": "Same import wiring as A in both callers, with the same caller integration tests, but test/retry-after.test.ts stays at its current five cases. Shared contract unchanged. Implementation ≈ +4 lines (saves ~26); tests ≈ +2. The past-date → 0, date-ceiling, unsafe-integer and whitespace-only paths remain unpinned by any test. Existing copies stay untouched. (human: ~45 min / CC: ~3 min)"
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "Keep the plan as drafted: each caller carries its own 15-line copy of the parser matching lib/retry-after.ts byte-for-byte. No change to lib/retry-after.ts or its test. Each caller integration test must also repeat the full contract cases (null, invalid, trimmed digits, cap, date, past date) so each copy is independently pinned. Implementation ≈ +30 lines (saves 0); tests ≈ +16-20. Any future parser fix must be applied to each copy separately. (human: ~2 h / CC: ~10 min)"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37179171083-t1-pass.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37179171083-t1-pass.json
new file mode 100644
index 000000000..0c2bff6b7
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37179171083-t1-pass.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "bba88e411dd393e2",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0518.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "passed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Census pass: the question offers only unchanged-helper reuse with proof-depth alternatives, and the actor answered \"Reuse lib + full tests (recommended)\".",
+ "expected_answer": "Reuse lib + full tests (recommended)",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Where should import-worker.ts and sync-route.ts get their Retry-After parser?\nProject/branch/task: repo on main — plan \"Import and synchronization retry planning\" (PLAN.md), two proposed callers under the fixed scheduler contract.\nELI10: Two new pieces of code need to turn a server's \"Retry-After\" header into a number of seconds to wait, exactly the way the scheduler already does. The repo already has one proven function for this, lib/retry-after.ts, with a passing test, and the scheduler already uses it. The plan draft instead copies the parsing logic into each new file. Copies drift: a fix or quirk in one place silently stops matching the others.\nStakes if we pick wrong: with copies, the two new callers can diverge from scheduler timing (a different cap or fallback) and no single test proves they still match; with reuse, a parser bug touches three callers at once, which is already true for the scheduler today.\nRecommendation: A because lib/retry-after.ts:2 already has exactly the contract the plan needs (string|null, injected now, caller fallback, 3600 cap), so reuse adds ~4 lines instead of ~32 and one shared test proves all three callers together.\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Reuse lib + full tests (recommended)\n ✅ Zero new parser code: each caller is one import plus a call passing its own fallback and now (human: ~1 h / CC: ~5 min)\n ✅ Adds the missing contract assertions (empty/whitespace → fallback, unsafe integer → fallback, past date → 0, date capped at 3600, fractional seconds round up) so every edge the new callers rely on is proven once\n ❌ Total diff grows by roughly 8 test lines, and a future parser defect reaches the scheduler plus both new callers at once\nB) Reuse lib, existing tests\n ✅ Same ~28-line implementation saving as A with the smallest possible diff (human: ~45 min / CC: ~3 min)\n ✅ The existing 5-assertion contract test already runs green and the scheduler already depends on it\n ❌ Past-date, empty-string, unsafe-integer and date-ceiling behavior stay untested, so a regression there would reach the new callers unnoticed\nC) Local parser per caller\n ✅ Each caller is self-contained; a parser change in one file cannot affect the scheduler or the other caller (human: ~1.5 h / CC: ~5 min)\n ✅ Matches the plan exactly as drafted, so no plan amendment is needed\n ❌ Two more ~14-line copies (retry-route.ts and retry-worker.ts already hold two) with no shared-contract test, so matching scheduler semantics rests on byte-identical copying, including quirks such as \"-5\" currently yielding 3600\nNet: trading a few shared test lines and shared blast radius against ~28 duplicated lines whose equivalence to the scheduler would never be proven.",
+ "header": "Parser source",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse lib + full tests (recommended)",
+ "description": "✅ Zero new parser code: each caller is one import plus a call passing its own fallback and now (human: ~1 h / CC: ~5 min)\n✅ Adds the missing contract assertions (empty/whitespace → fallback, unsafe integer → fallback, past date → 0, date capped at 3600, fractional seconds round up) so every edge the new callers rely on is proven once\n❌ Total diff grows by roughly 8 test lines, and a future parser defect reaches the scheduler plus both new callers at once"
+ },
+ {
+ "label": "Reuse lib, existing tests",
+ "description": "✅ Same ~28-line implementation saving as A with the smallest possible diff (human: ~45 min / CC: ~3 min)\n✅ The existing 5-assertion contract test already runs green and the scheduler already depends on it\n❌ Past-date, empty-string, unsafe-integer and date-ceiling behavior stay untested, so a regression there would reach the new callers unnoticed"
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Each caller is self-contained; a parser change in one file cannot affect the scheduler or the other caller (human: ~1.5 h / CC: ~5 min)\n✅ Matches the plan exactly as drafted, so no plan amendment is needed\n❌ Two more ~14-line copies (retry-route.ts and retry-worker.ts already hold two) with no shared-contract test, so matching scheduler semantics rests on byte-identical copying, including quirks such as \"-5\" currently yielding 3600"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37182865432-t1-pass.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37182865432-t1-pass.json
new file mode 100644
index 000000000..95ac2bde7
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37182865432-t1-pass.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "531a8389999349df",
+ "source": {
+ "run": "37182865432",
+ "sha": "44ef18cd0ee8bfb33b3812fbc7f012a2d52f6d9e",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0633.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "passed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Census pass: the question offers only unchanged-helper reuse with proof-depth alternatives, and the actor answered \"Reuse helper + full proof (recommended)\".",
+ "expected_answer": "Reuse helper + full proof (recommended)",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Where should the two new callers get their Retry-After parser?\nProject/branch/task: repo on main; the plan adds src/import-worker.ts and src/sync-route.ts, both needing Retry-After parsing that matches src/scheduler.ts.\nELI10: The scheduler already reads Retry-After headers through one shared function in lib/retry-after.ts, and a passing test pins how it behaves. The plan has each new file write its own copy of that logic. Two more copies means three places that must agree forever on the same tricky rules (blank header, date in the past, giant number, one-hour cap), and a fix in one copy silently misses the others.\nStakes if we pick wrong: a hand-written copy drifts from the scheduler, so an import or sync retries at a different time than the scheduler would for the same header, and nobody notices until production.\nRecommendation: A because the existing helper already matches the callers' stated contract exactly (string|null, injected now, 3600 ceiling, caller fallback) and the extra proof is a few assertions.\nCompleteness: A=10/10, B=7/10, C=3/10\nPros / cons:\nA) Reuse helper + full proof (recommended) (human: ~1 hour / CC: ~5 min)\n ✅ Both callers import the function the scheduler already uses, so matching semantics is guaranteed by construction, not by hand.\n ✅ Adds regression assertions for the four currently untested edges the callers rely on: blank string, unsafe integer, past date, date beyond the one-hour cap.\n ❌ Touches test/retry-after.test.ts, a file the plan did not list, though only with assertions that pin current behavior.\nB) Reuse helper, existing tests (human: ~45 min / CC: ~3 min)\n ✅ Same guaranteed-matching semantics as A with the smallest diff: two imports plus two caller integration tests.\n ✅ No change outside the two new files and their tests.\n ❌ Blank header, past dates and the date-beyond-cap path stay unproven, so a later helper edit could change what the new callers do without a failing test.\nC) Keep local parsers (human: ~2 hours / CC: ~10 min)\n ✅ Each new file is self-contained and can be read without following an import.\n ✅ No dependency on lib/retry-after.ts for the new code paths.\n ❌ Three hand-maintained copies of the same rules; \"must match scheduler semantics\" becomes a manual promise with no shared test enforcing it, and ~30 duplicated lines.\nNet: trading one extra test-file edit for semantics that are shared by construction and pinned on every edge the new callers depend on, versus duplicating logic the repo already owns.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper + full proof (recommended)",
+ "description": "✅ Both callers import the function the scheduler already uses, so matching semantics is guaranteed by construction, not by hand.\n✅ Adds regression assertions for the four currently untested edges the callers rely on: blank string, unsafe integer, past date, date beyond the one-hour cap.\n❌ Touches test/retry-after.test.ts, a file the plan did not list, though only with assertions that pin current behavior. (human: ~1 hour / CC: ~5 min)"
+ },
+ {
+ "label": "Reuse helper, existing tests",
+ "description": "✅ Same guaranteed-matching semantics as A with the smallest diff: two imports plus two caller integration tests.\n✅ No change outside the two new files and their tests.\n❌ Blank header, past dates and the date-beyond-cap path stay unproven, so a later helper edit could change what the new callers do without a failing test. (human: ~45 min / CC: ~3 min)"
+ },
+ {
+ "label": "Keep local parsers",
+ "description": "✅ Each new file is self-contained and can be read without following an import.\n✅ No dependency on lib/retry-after.ts for the new code paths.\n❌ Three hand-maintained copies of the same rules; \"must match scheduler semantics\" becomes a manual promise with no shared test enforcing it, and ~30 duplicated lines. (human: ~2 hours / CC: ~10 min)"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37186854666-t1-derived-refuse.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37186854666-t1-derived-refuse.json
new file mode 100644
index 000000000..f587caed5
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37186854666-t1-derived-refuse.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "531a8389999349df",
+ "source": {
+ "run": "37186854666",
+ "sha": "b24b6d8fcf57db7e9c8ded236d31b8a5d0b4504a",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0751.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "refuse",
+ "justification": "Genuine scope expansion: the recommended option now commits to helper hardening, which the PLAN and fixture prompt put outside this actor's reuse-and-proof interface.",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers import the existing Retry-After parser or each carry a local copy?\nProject/branch/task: fixture repo on `main`, planning src/import-worker.ts and src/sync-route.ts (PLAN.md).\nELI10: Both new files need to turn a Retry-After header into a wait time exactly the way the scheduler does. The scheduler already gets that behavior from one small shared function in lib/retry-after.ts, which already takes null, an injected clock, a 3600-second cap and a caller fallback. The plan instead writes that logic again inside each new file. Three copies of \"the same\" rule tend to drift apart, and nothing would prove they still match.\nStakes if we pick wrong: with local copies, a later fix to one parser silently leaves the others behind and a caller waits the wrong amount or ignores a header; with reuse but no extra tests, an edge like a past date or empty header is matched by accident rather than by proof.\nRecommendation: A because the shared helper already has the required contract, is proven by the scheduler and its test, and reuse is a net line saving; the extra contract assertions cost minutes and turn \"matches the scheduler\" from assumption into proof.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse lib helper + contract tests (recommended)\n ✅ One source of truth: both callers inherit scheduler semantics by construction, not by copying\n ✅ Adds the missing contract assertions (empty string, past date, date ceiling, injected now) so edge compatibility is proven\n ✅ Net ~26 implementation lines never written; ~18 saved after tests\n ❌ A bug in lib/retry-after.ts now affects the scheduler and both new callers at once\nB) Reuse lib helper only\n ✅ Same single source of truth and same implementation saving as A\n ✅ Smallest possible diff: two import lines plus the already-planned integration tests\n ❌ Edge cases the callers rely on (past date→0, date cap, empty header) stay unpinned, so a later helper change could break them unnoticed\nC) Local parser per caller\n ✅ No change to anything shared; each file is self-contained\n ✅ Matches the current draft, so no plan amendment needed\n ❌ Three near-identical copies with no parity test; the plan's \"must match scheduler semantics\" requirement is asserted, never proven\n ❌ ~28 duplicated lines and extra per-caller tests just to re-establish an existing contract\nNet: trading a slightly larger shared blast radius (mitigated by a contract test) for provable compatibility and less code, versus independence bought with duplication and unproven parity.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse lib helper + contract tests",
+ "description": "✅ Both new callers import retrySeconds from lib/retry-after.ts; no local parser is written. ✅ Extend test/retry-after.test.ts with the untested edges the callers rely on: empty/whitespace→fallback, \"0\"→0, \"3601\"→3600, far-future date→3600, past date→0, unsafe integer→fallback, injected now honored. ✅ Each caller's integration test asserts its own fallback and injected now are passed through. ❌ Shared failure now reaches scheduler plus both callers. Effort human ~1h / CC ~5 min. Scheduler semantics unchanged, plus helper hardening."
+ },
+ {
+ "label": "Reuse lib helper only",
+ "description": "✅ Both new callers import retrySeconds from lib/retry-after.ts; no local parser is written. ✅ Only the already-planned per-caller integration tests; no new shared-contract assertions. ❌ Edge cases (past date, date ceiling, empty header, injected now) remain unpinned beyond the existing 5 expects. Effort human ~45 min / CC ~3 min. Scheduler semantics, existing callers and helper hardening unchanged."
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Keeps the draft as written: each caller carries its own ~14-line parser. ✅ No shared code touched. ❌ Compatibility with scheduler semantics is unproven unless each caller also gets parity tests, adding more duplicated lines. ❌ Three copies to keep in sync with no mechanism to detect drift. Effort human ~2h / CC ~10 min. Scheduler semantics, existing callers and helper hardening unchanged."
+ }
+ ]
+ }
+ ]
+ },
+ "derived": "negative control from the 37186854666 census capture; recommended option description: the verbless exclusion \"... and helper hardening unchanged.\" replaced by \"Scheduler semantics unchanged, plus helper hardening.\""
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37186854666-t1-fail.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37186854666-t1-fail.json
new file mode 100644
index 000000000..02b019dab
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37186854666-t1-fail.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "531a8389999349df",
+ "source": {
+ "run": "37186854666",
+ "sha": "b24b6d8fcf57db7e9c8ded236d31b8a5d0b4504a",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0751.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "pass",
+ "justification": "Fixed false red: every option reuses the unchanged lib/retry-after.ts helper and names hardening only as excluded scope or a risk; census-time actor (b24b6d8f) did not treat the verbless \"... and helper hardening unchanged.\" list as an exclusion; fcb4024, then 2a6b766 (verb+object expansions), squashed to main in 2db0b3a.",
+ "expected_answer": "Reuse lib helper + contract tests",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers import the existing Retry-After parser or each carry a local copy?\nProject/branch/task: fixture repo on `main`, planning src/import-worker.ts and src/sync-route.ts (PLAN.md).\nELI10: Both new files need to turn a Retry-After header into a wait time exactly the way the scheduler does. The scheduler already gets that behavior from one small shared function in lib/retry-after.ts, which already takes null, an injected clock, a 3600-second cap and a caller fallback. The plan instead writes that logic again inside each new file. Three copies of \"the same\" rule tend to drift apart, and nothing would prove they still match.\nStakes if we pick wrong: with local copies, a later fix to one parser silently leaves the others behind and a caller waits the wrong amount or ignores a header; with reuse but no extra tests, an edge like a past date or empty header is matched by accident rather than by proof.\nRecommendation: A because the shared helper already has the required contract, is proven by the scheduler and its test, and reuse is a net line saving; the extra contract assertions cost minutes and turn \"matches the scheduler\" from assumption into proof.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse lib helper + contract tests (recommended)\n ✅ One source of truth: both callers inherit scheduler semantics by construction, not by copying\n ✅ Adds the missing contract assertions (empty string, past date, date ceiling, injected now) so edge compatibility is proven\n ✅ Net ~26 implementation lines never written; ~18 saved after tests\n ❌ A bug in lib/retry-after.ts now affects the scheduler and both new callers at once\nB) Reuse lib helper only\n ✅ Same single source of truth and same implementation saving as A\n ✅ Smallest possible diff: two import lines plus the already-planned integration tests\n ❌ Edge cases the callers rely on (past date→0, date cap, empty header) stay unpinned, so a later helper change could break them unnoticed\nC) Local parser per caller\n ✅ No change to anything shared; each file is self-contained\n ✅ Matches the current draft, so no plan amendment needed\n ❌ Three near-identical copies with no parity test; the plan's \"must match scheduler semantics\" requirement is asserted, never proven\n ❌ ~28 duplicated lines and extra per-caller tests just to re-establish an existing contract\nNet: trading a slightly larger shared blast radius (mitigated by a contract test) for provable compatibility and less code, versus independence bought with duplication and unproven parity.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse lib helper + contract tests",
+ "description": "✅ Both new callers import retrySeconds from lib/retry-after.ts; no local parser is written. ✅ Extend test/retry-after.test.ts with the untested edges the callers rely on: empty/whitespace→fallback, \"0\"→0, \"3601\"→3600, far-future date→3600, past date→0, unsafe integer→fallback, injected now honored. ✅ Each caller's integration test asserts its own fallback and injected now are passed through. ❌ Shared failure now reaches scheduler plus both callers. Effort human ~1h / CC ~5 min. Scheduler semantics, existing callers and helper hardening unchanged."
+ },
+ {
+ "label": "Reuse lib helper only",
+ "description": "✅ Both new callers import retrySeconds from lib/retry-after.ts; no local parser is written. ✅ Only the already-planned per-caller integration tests; no new shared-contract assertions. ❌ Edge cases (past date, date ceiling, empty header, injected now) remain unpinned beyond the existing 5 expects. Effort human ~45 min / CC ~3 min. Scheduler semantics, existing callers and helper hardening unchanged."
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Keeps the draft as written: each caller carries its own ~14-line parser. ✅ No shared code touched. ❌ Compatibility with scheduler semantics is unproven unless each caller also gets parity tests, adding more duplicated lines. ❌ Three copies to keep in sync with no mechanism to detect drift. Effort human ~2h / CC ~10 min. Scheduler semantics, existing callers and helper hardening unchanged."
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37193478719-t1-pass.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37193478719-t1-pass.json
new file mode 100644
index 000000000..c0b56f36a
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37193478719-t1-pass.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "cce38d7a8c76aedf",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0957.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "passed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Census pass: the question offers only unchanged-helper reuse with proof-depth alternatives, and the actor answered \"Reuse helper + full proof\".",
+ "expected_answer": "Reuse helper + full proof",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Which parser should the two new callers use under the fixed scheduler contract?\nProject/branch/task: repo on main; PLAN.md adds src/import-worker.ts and src/sync-route.ts, both needing Retry-After parsing identical to src/scheduler.ts.\nELI10: The scheduler already uses a small, tested helper (lib/retry-after.ts) that turns a Retry-After header into a wait in seconds, caps it at an hour, and falls back to a caller-supplied default when the header is missing or garbage. The plan drafts a fresh copy of that logic inside each new caller. Copies look harmless today but drift apart on edge cases over time, and nobody tests the copies directly.\nStakes if we pick wrong: two more untested parser copies (three total with the existing duplicates) that can disagree with the scheduler on edge cases such as past dates, blank headers or huge numbers, producing different retry waits per caller.\nRecommendation: A because the helper already exists, is used by the scheduler and has a contract test, so reuse costs two import lines and the only real work is proving the edge cases the new callers rely on.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse helper + full proof (recommended)\n ✅ Zero new parser code: each caller imports retrySeconds from lib/retry-after.ts, exactly as src/scheduler.ts:1 does today.\n ✅ Contract test gains the edge cases the callers depend on (blank header, past date → 0, unsafe integer, date beyond 3600 s, fractional-second rounding), all verified by probe against the current helper.\n ❌ A future bug in the shared helper now reaches the scheduler and both new callers at once (three call sites). (human: ~1 h / CC: ~5 min)\nB) Reuse helper, existing tests\n ✅ Same two-line wiring as A with no parser duplication.\n ✅ Smallest diff: only the two caller integration tests are added.\n ❌ Edge cases the new callers rely on (blank header, past date, unsafe integer, date cap) stay unproven in test/retry-after.test.ts; a regression there would pass CI. (human: ~45 min / CC: ~4 min)\nC) Local parsers as drafted\n ✅ Each caller is self-contained; a helper change cannot alter its behavior.\n ✅ Matches the current plan text, so no plan amendment is needed.\n ❌ Adds ~30 duplicated implementation lines with no direct tests, and the copies can silently diverge from the scheduler's contract the plan says is fixed. (human: ~2 h / CC: ~10 min)\nNet: trading a slightly wider blast radius for one proven parser versus three untested copies of the same 15 lines.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper + full proof",
+ "description": "✅ Both new callers import retrySeconds from lib/retry-after.ts (one line each), the same wiring src/scheduler.ts uses today; no parser code is written.\n✅ test/retry-after.test.ts keeps its 5 assertions and adds the edge cases the callers depend on: blank/whitespace header → fallback, past date → 0, unsafe-integer string → fallback, date ≥ 3600 s ahead → 3600, fractional-second date → ceil. Each new caller gets its integration test asserting header/null, injected now and its own fallback flow through and the result is used.\n❌ A future helper bug reaches the scheduler and both new callers at once. Helper semantics, src/retry-route.ts and src/retry-worker.ts stay unchanged (out of scope). (human: ~1 h / CC: ~5 min)"
+ },
+ {
+ "label": "Reuse helper, existing tests",
+ "description": "✅ Same one-line import per caller as A; no parser duplication.\n✅ Smallest diff: only the two caller integration tests are added; test/retry-after.test.ts is left at its current 5 assertions.\n❌ Blank header, past date, unsafe integer and date-cap behavior stay unproven by the contract test, so a helper regression on those paths would pass CI while the callers rely on them. Helper semantics and existing callers stay unchanged. (human: ~45 min / CC: ~4 min)"
+ },
+ {
+ "label": "Local parsers as drafted",
+ "description": "✅ Each caller carries its own 15-line parser copy and cannot be changed by a helper edit.\n✅ Matches the current plan text; only the two caller integration tests are added.\n❌ About 30 duplicated implementation lines with no direct tests; the copies must stay byte-identical to lib/retry-after.ts to honor the fixed contract and can silently drift. Helper and existing callers stay unchanged. (human: ~2 h / CC: ~10 min)"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37193478719-t1-supported-git-pass.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37193478719-t1-supported-git-pass.json
new file mode 100644
index 000000000..537f0bef2
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37193478719-t1-supported-git-pass.json
@@ -0,0 +1,44 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "cce38d7a8c76aedf",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-0957.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "passed",
+ "git_path": "supported",
+ "git_path_evidence": "Synthetic supported-path shape (git >= 2.44 reads succeed): local history and branch HEAD are known, so the brief cites them. No census recorded this path before the CI image moved to git 2.55.",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Supported-git shape of the 37193478719 pass: same reuse-only menu and proof-depth alternatives, with HEAD and a history citation in the brief; the actor still answers \"Reuse helper + full proof\".",
+ "derived": "the brief rewritten to the supported-git shape: Project/branch/task names the branch HEAD and the assessment cites a local history read (gstack-safe-git succeeded)",
+ "expected_answer": "Reuse helper + full proof",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Which parser should the two new callers use under the fixed scheduler contract?\nProject/branch/task: repo on main @ 4f1c2d9 (local history read); PLAN.md adds src/import-worker.ts and src/sync-route.ts, both needing Retry-After parsing identical to src/scheduler.ts.\nELI10: The scheduler already uses a small, tested helper (lib/retry-after.ts) that turns a Retry-After header into a wait in seconds, caps it at an hour, and falls back to a caller-supplied default when the header is missing or garbage. git log shows lib/retry-after.ts unchanged since its contract test landed in 9e8f7a6. The plan drafts a fresh copy of that logic inside each new caller. Copies look harmless today but drift apart on edge cases over time, and nobody tests the copies directly.\nStakes if we pick wrong: two more untested parser copies (three total with the existing duplicates) that can disagree with the scheduler on edge cases such as past dates, blank headers or huge numbers, producing different retry waits per caller.\nRecommendation: A because the helper already exists, is used by the scheduler and has a contract test, so reuse costs two import lines and the only real work is proving the edge cases the new callers rely on.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse helper + full proof (recommended)\n ✅ Zero new parser code: each caller imports retrySeconds from lib/retry-after.ts, exactly as src/scheduler.ts:1 does today.\n ✅ Contract test gains the edge cases the callers depend on (blank header, past date → 0, unsafe integer, date beyond 3600 s, fractional-second rounding), all verified by probe against the current helper.\n ❌ A future bug in the shared helper now reaches the scheduler and both new callers at once (three call sites). (human: ~1 h / CC: ~5 min)\nB) Reuse helper, existing tests\n ✅ Same two-line wiring as A with no parser duplication.\n ✅ Smallest diff: only the two caller integration tests are added.\n ❌ Edge cases the new callers rely on (blank header, past date, unsafe integer, date cap) stay unproven in test/retry-after.test.ts; a regression there would pass CI. (human: ~45 min / CC: ~4 min)\nC) Local parsers as drafted\n ✅ Each caller is self-contained; a helper change cannot alter its behavior.\n ✅ Matches the current plan text, so no plan amendment is needed.\n ❌ Adds ~30 duplicated implementation lines with no direct tests, and the copies can silently diverge from the scheduler's contract the plan says is fixed. (human: ~2 h / CC: ~10 min)\nNet: trading a slightly wider blast radius for one proven parser versus three untested copies of the same 15 lines.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper + full proof",
+ "description": "✅ Both new callers import retrySeconds from lib/retry-after.ts (one line each), the same wiring src/scheduler.ts uses today; no parser code is written.\n✅ test/retry-after.test.ts keeps its 5 assertions and adds the edge cases the callers depend on: blank/whitespace header → fallback, past date → 0, unsafe-integer string → fallback, date ≥ 3600 s ahead → 3600, fractional-second date → ceil. Each new caller gets its integration test asserting header/null, injected now and its own fallback flow through and the result is used.\n❌ A future helper bug reaches the scheduler and both new callers at once. Helper semantics, src/retry-route.ts and src/retry-worker.ts stay unchanged (out of scope). (human: ~1 h / CC: ~5 min)"
+ },
+ {
+ "label": "Reuse helper, existing tests",
+ "description": "✅ Same one-line import per caller as A; no parser duplication.\n✅ Smallest diff: only the two caller integration tests are added; test/retry-after.test.ts is left at its current 5 assertions.\n❌ Blank header, past date, unsafe integer and date-cap behavior stay unproven by the contract test, so a helper regression on those paths would pass CI while the callers rely on them. Helper semantics and existing callers stay unchanged. (human: ~45 min / CC: ~4 min)"
+ },
+ {
+ "label": "Local parsers as drafted",
+ "description": "✅ Each caller carries its own 15-line parser copy and cannot be changed by a helper edit.\n✅ Matches the current plan text; only the two caller integration tests are added.\n❌ About 30 duplicated implementation lines with no direct tests; the copies must stay byte-identical to lib/retry-after.ts to honor the fixed contract and can silently drift. Helper and existing callers stay unchanged. (human: ~2 h / CC: ~10 min)"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-derived-refuse.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-derived-refuse.json
new file mode 100644
index 000000000..b23c7ab9c
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-derived-refuse.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "cce38d7a8c76aedf",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-1028.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "refuse",
+ "justification": "Genuine scope expansion: the recommended option now commits to helper hardening, which the PLAN and fixture prompt put outside this actor's reuse-and-proof interface.",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers reuse lib/retry-after.ts or carry local parsers?\nProject/branch/task: main — plan review of PLAN.md (import-worker and sync-route Retry-After parsing under the fixed scheduler contract).\nELI10: Both new files need to turn a Retry-After header (a number of seconds, an HTTP date, or nothing) into a wait time that is capped at one hour and falls back to a caller-chosen number when the header is junk. The scheduler already does exactly this through one shared function in lib/retry-after.ts, which has its own contract test. The plan drafts a fresh copy of that logic inside each new file. Two more copies means three places that must stay identical forever, while one import means the behavior cannot drift.\nStakes if we pick wrong: a copy that diverges, or an unpinned edge case, makes a worker or route wait a different time than the scheduler for the same header, and nobody notices until a backoff misbehaves in production.\nRecommendation: A because the required behavior is identical by definition, the helper is already proven and tested, and it saves about 26 implementation lines; the extra contract assertions cost minutes and are the only proof that the \"fixed\" edge cases actually hold.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse helper, extend tests (recommended) (human: ~1.5 h / CC: ~5 min)\n ✅ Both callers import retrySeconds(value, now, fallback) from lib/retry-after.ts, the same call shape src/scheduler.ts:2 already uses, so semantics match by construction\n ✅ Adds the missing shared-contract assertions the callers depend on (empty or whitespace string returns fallback, \"0\" returns 0, past date returns 0, far-future date returns 3600, unsafe integer returns fallback, unparseable date returns the caller fallback) plus one integration test per caller that passes an explicit now and a non-default fallback\n ❌ Grows test/retry-after.test.ts by about 6-8 lines and widens the blast radius: a future helper bug or hardening change reaches scheduler, import-worker and sync-route at once\nB) Reuse helper, existing tests (human: ~1 h / CC: ~3 min)\n ✅ Same import wiring and the same ~26-line implementation saving as A, with no change to the shared test file\n ✅ Lowest-touch diff: only the two new source files and their two integration tests\n ❌ The five existing assertions never exercise past dates, the date ceiling, empty strings or unsafe integers, so the \"fixed\" edge-case compatibility stays unproven at the shared layer\nC) Local parser per caller (human: ~2 h / CC: ~5 min)\n ✅ Each caller is self-contained; a later change to lib/retry-after.ts cannot alter these two files\n ✅ Matches the plan as currently drafted, so no plan amendment is needed\n ❌ Adds about 28 duplicated lines, creates two more copies that must replicate every quirk of the runtime's Date.parse leniency to stay scheduler-compatible, and only integration tests guard each copy\nNet: one import plus a handful of contract assertions buys guaranteed parity and a smaller diff; the only thing given up is isolation from future helper changes, which the plan says must be shared anyway.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper, extend tests (recommended)",
+ "description": "✅ Both callers import retrySeconds(value, now, fallback) from lib/retry-after.ts, the same call shape src/scheduler.ts:2 already uses, so semantics match by construction\n✅ Adds the missing shared-contract assertions the callers depend on (empty or whitespace string returns fallback, \"0\" returns 0, past date returns 0, far-future date returns 3600, unsafe integer returns fallback, unparseable date returns the caller fallback) plus one integration test per caller that passes an explicit now and a non-default fallback\n❌ Grows test/retry-after.test.ts by about 6-8 lines and also hardens the shared parser against malformed numerics"
+ },
+ {
+ "label": "Reuse helper, existing tests",
+ "description": "✅ Same import wiring and the same ~26-line implementation saving as A, with no change to the shared test file\n✅ Lowest-touch diff: only the two new source files and their two integration tests\n❌ The five existing assertions never exercise past dates, the date ceiling, empty strings or unsafe integers, so the \"fixed\" edge-case compatibility stays unproven at the shared layer"
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Each caller is self-contained; a later change to lib/retry-after.ts cannot alter these two files\n✅ Matches the plan as currently drafted, so no plan amendment is needed\n❌ Adds about 28 duplicated lines, creates two more copies that must replicate every quirk of the runtime's Date.parse leniency to stay scheduler-compatible, and only integration tests guard each copy"
+ }
+ ]
+ }
+ ]
+ },
+ "derived": "negative control from the 37195203538 census capture; recommended option description only (the question menu is unchanged): the risk bullet naming a future hardening change replaced by a commitment that the option hardens the shared parser"
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-fail.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-fail.json
new file mode 100644
index 000000000..e6c20baa7
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-fail.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "cce38d7a8c76aedf",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-1028.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "pass",
+ "justification": "Fixed false red: every option reuses the unchanged lib/retry-after.ts helper and names hardening only as excluded scope or a risk; census-time actor (41287009) refused the risk bullet \"a future helper bug or hardening change reaches ...\" as a proposal; 2a6b766 judges forbidden verb+object actions only, squashed to main in 2db0b3a.",
+ "expected_answer": "Reuse helper, extend tests (recommended)",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers reuse lib/retry-after.ts or carry local parsers?\nProject/branch/task: main — plan review of PLAN.md (import-worker and sync-route Retry-After parsing under the fixed scheduler contract).\nELI10: Both new files need to turn a Retry-After header (a number of seconds, an HTTP date, or nothing) into a wait time that is capped at one hour and falls back to a caller-chosen number when the header is junk. The scheduler already does exactly this through one shared function in lib/retry-after.ts, which has its own contract test. The plan drafts a fresh copy of that logic inside each new file. Two more copies means three places that must stay identical forever, while one import means the behavior cannot drift.\nStakes if we pick wrong: a copy that diverges, or an unpinned edge case, makes a worker or route wait a different time than the scheduler for the same header, and nobody notices until a backoff misbehaves in production.\nRecommendation: A because the required behavior is identical by definition, the helper is already proven and tested, and it saves about 26 implementation lines; the extra contract assertions cost minutes and are the only proof that the \"fixed\" edge cases actually hold.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse helper, extend tests (recommended) (human: ~1.5 h / CC: ~5 min)\n ✅ Both callers import retrySeconds(value, now, fallback) from lib/retry-after.ts, the same call shape src/scheduler.ts:2 already uses, so semantics match by construction\n ✅ Adds the missing shared-contract assertions the callers depend on (empty or whitespace string returns fallback, \"0\" returns 0, past date returns 0, far-future date returns 3600, unsafe integer returns fallback, unparseable date returns the caller fallback) plus one integration test per caller that passes an explicit now and a non-default fallback\n ❌ Grows test/retry-after.test.ts by about 6-8 lines and widens the blast radius: a future helper bug or hardening change reaches scheduler, import-worker and sync-route at once\nB) Reuse helper, existing tests (human: ~1 h / CC: ~3 min)\n ✅ Same import wiring and the same ~26-line implementation saving as A, with no change to the shared test file\n ✅ Lowest-touch diff: only the two new source files and their two integration tests\n ❌ The five existing assertions never exercise past dates, the date ceiling, empty strings or unsafe integers, so the \"fixed\" edge-case compatibility stays unproven at the shared layer\nC) Local parser per caller (human: ~2 h / CC: ~5 min)\n ✅ Each caller is self-contained; a later change to lib/retry-after.ts cannot alter these two files\n ✅ Matches the plan as currently drafted, so no plan amendment is needed\n ❌ Adds about 28 duplicated lines, creates two more copies that must replicate every quirk of the runtime's Date.parse leniency to stay scheduler-compatible, and only integration tests guard each copy\nNet: one import plus a handful of contract assertions buys guaranteed parity and a smaller diff; the only thing given up is isolation from future helper changes, which the plan says must be shared anyway.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper, extend tests (recommended)",
+ "description": "✅ Both callers import retrySeconds(value, now, fallback) from lib/retry-after.ts, the same call shape src/scheduler.ts:2 already uses, so semantics match by construction\n✅ Adds the missing shared-contract assertions the callers depend on (empty or whitespace string returns fallback, \"0\" returns 0, past date returns 0, far-future date returns 3600, unsafe integer returns fallback, unparseable date returns the caller fallback) plus one integration test per caller that passes an explicit now and a non-default fallback\n❌ Grows test/retry-after.test.ts by about 6-8 lines and widens the blast radius: a future helper bug or hardening change reaches scheduler, import-worker and sync-route at once"
+ },
+ {
+ "label": "Reuse helper, existing tests",
+ "description": "✅ Same import wiring and the same ~26-line implementation saving as A, with no change to the shared test file\n✅ Lowest-touch diff: only the two new source files and their two integration tests\n❌ The five existing assertions never exercise past dates, the date ceiling, empty strings or unsafe integers, so the \"fixed\" edge-case compatibility stays unproven at the shared layer"
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Each caller is self-contained; a later change to lib/retry-after.ts cannot alter these two files\n✅ Matches the plan as currently drafted, so no plan amendment is needed\n❌ Adds about 28 duplicated lines, creates two more copies that must replicate every quirk of the runtime's Date.parse leniency to stay scheduler-compatible, and only integration tests guard each copy"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-supported-git-derived-refuse.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-supported-git-derived-refuse.json
new file mode 100644
index 000000000..4f91adda3
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37195203538-t1-supported-git-derived-refuse.json
@@ -0,0 +1,43 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "cce38d7a8c76aedf",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-1028.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "failed",
+ "git_path": "supported",
+ "git_path_evidence": "Synthetic supported-path shape (git >= 2.44 reads succeed): local history and branch HEAD are known, so the brief cites them. No census recorded this path before the CI image moved to git 2.55.",
+ "census_error": "Error: shared-libs-plan-callers actor: question expands beyond unchanged-helper reuse and its required proof: hardening",
+ "expected": "refuse",
+ "justification": "Supported-git shape of the 37195203538 negative control: the recommended option still commits to hardening the shared parser, outside the actor's reuse-and-proof interface, so it is refused whatever the git path.",
+ "derived": "negative control from the 37195203538 census capture; recommended option description only (the question menu is unchanged): the risk bullet naming a future hardening change replaced by a commitment that the option hardens the shared parser; then the brief rewritten to the supported-git shape: Project/branch/task names the branch HEAD and the assessment cites a local history read (gstack-safe-git succeeded)",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Should the two new callers reuse lib/retry-after.ts or carry local parsers?\nProject/branch/task: main @ 4f1c2d9 (local history read) — plan review of PLAN.md (import-worker and sync-route Retry-After parsing under the fixed scheduler contract).\nELI10: Both new files need to turn a Retry-After header (a number of seconds, an HTTP date, or nothing) into a wait time that is capped at one hour and falls back to a caller-chosen number when the header is junk. The scheduler already does exactly this through one shared function in lib/retry-after.ts, which has its own contract test. git log shows that test unchanged since 9e8f7a6. The plan drafts a fresh copy of that logic inside each new file. Two more copies means three places that must stay identical forever, while one import means the behavior cannot drift.\nStakes if we pick wrong: a copy that diverges, or an unpinned edge case, makes a worker or route wait a different time than the scheduler for the same header, and nobody notices until a backoff misbehaves in production.\nRecommendation: A because the required behavior is identical by definition, the helper is already proven and tested, and it saves about 26 implementation lines; the extra contract assertions cost minutes and are the only proof that the \"fixed\" edge cases actually hold.\nCompleteness: A=10/10, B=7/10, C=4/10\nPros / cons:\nA) Reuse helper, extend tests (recommended) (human: ~1.5 h / CC: ~5 min)\n ✅ Both callers import retrySeconds(value, now, fallback) from lib/retry-after.ts, the same call shape src/scheduler.ts:2 already uses, so semantics match by construction\n ✅ Adds the missing shared-contract assertions the callers depend on (empty or whitespace string returns fallback, \"0\" returns 0, past date returns 0, far-future date returns 3600, unsafe integer returns fallback, unparseable date returns the caller fallback) plus one integration test per caller that passes an explicit now and a non-default fallback\n ❌ Grows test/retry-after.test.ts by about 6-8 lines and widens the blast radius: a future helper bug or hardening change reaches scheduler, import-worker and sync-route at once\nB) Reuse helper, existing tests (human: ~1 h / CC: ~3 min)\n ✅ Same import wiring and the same ~26-line implementation saving as A, with no change to the shared test file\n ✅ Lowest-touch diff: only the two new source files and their two integration tests\n ❌ The five existing assertions never exercise past dates, the date ceiling, empty strings or unsafe integers, so the \"fixed\" edge-case compatibility stays unproven at the shared layer\nC) Local parser per caller (human: ~2 h / CC: ~5 min)\n ✅ Each caller is self-contained; a later change to lib/retry-after.ts cannot alter these two files\n ✅ Matches the plan as currently drafted, so no plan amendment is needed\n ❌ Adds about 28 duplicated lines, creates two more copies that must replicate every quirk of the runtime's Date.parse leniency to stay scheduler-compatible, and only integration tests guard each copy\nNet: one import plus a handful of contract assertions buys guaranteed parity and a smaller diff; the only thing given up is isolation from future helper changes, which the plan says must be shared anyway.",
+ "header": "Parser reuse",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse helper, extend tests (recommended)",
+ "description": "✅ Both callers import retrySeconds(value, now, fallback) from lib/retry-after.ts, the same call shape src/scheduler.ts:2 already uses, so semantics match by construction\n✅ Adds the missing shared-contract assertions the callers depend on (empty or whitespace string returns fallback, \"0\" returns 0, past date returns 0, far-future date returns 3600, unsafe integer returns fallback, unparseable date returns the caller fallback) plus one integration test per caller that passes an explicit now and a non-default fallback\n❌ Grows test/retry-after.test.ts by about 6-8 lines and also hardens the shared parser against malformed numerics"
+ },
+ {
+ "label": "Reuse helper, existing tests",
+ "description": "✅ Same import wiring and the same ~26-line implementation saving as A, with no change to the shared test file\n✅ Lowest-touch diff: only the two new source files and their two integration tests\n❌ The five existing assertions never exercise past dates, the date ceiling, empty strings or unsafe integers, so the \"fixed\" edge-case compatibility stays unproven at the shared layer"
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Each caller is self-contained; a later change to lib/retry-after.ts cannot alter these two files\n✅ Matches the plan as currently drafted, so no plan amendment is needed\n❌ Adds about 28 duplicated lines, creates two more copies that must replicate every quirk of the runtime's Date.parse leniency to stay scheduler-compatible, and only integration tests guard each copy"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/37198445662-t1-pass.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/37198445662-t1-pass.json
new file mode 100644
index 000000000..34db5f56a
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/37198445662-t1-pass.json
@@ -0,0 +1,39 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "shared-libs-plan-callers",
+ "detector": "test/helpers/shared-libs-plan-actor.ts#createSharedPlanReuseSelector",
+ "series_identity": "b9d17145a5c36105",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "periodic",
+ "artifact": "paid-slice-5-a1",
+ "path": "shards/skill-e2e-shared-libs-periodic/1.91.19-garrytan-severe-fix-wave-oct3-e2e-2026-10-04-1127.json#tests[name=shared-libs-plan-callers].transcript[tool_use name=AskUserQuestion].input"
+ },
+ "census_outcome": "passed",
+ "git_path": "fallback",
+ "git_path_evidence": "Recorded in CI's eval image (ubuntu:24.04, git 2.43), which rejects --no-lazy-fetch (exit 129), so gstack-safe-git took its unsupported-Git fallback (local history unavailable, branch HEAD unknown).",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Census pass: the question offers only unchanged-helper reuse with proof-depth alternatives, and the actor answered \"Reuse lib/retry-after\".",
+ "expected_answer": "Reuse lib/retry-after",
+ "input": {
+ "questions": [
+ {
+ "question": "D1 — Parser source for the two new Retry-After callers\nProject/branch/task: repo on main — plan review of PLAN.md, which adds src/import-worker.ts and src/sync-route.ts.\nELI10: Two new pieces of code need to read a server's \"Retry-After\" hint and turn it into a wait time, exactly the way the scheduler already does. The scheduler gets that from one shared function in lib/retry-after.ts that already has a passing test. The draft plan instead writes a fresh copy of that function inside each new file. Copies look fine today but can quietly drift from the scheduler, and every copy needs its own tests.\nStakes if we pick wrong: with copies, a future fix to the shared parser will not reach the new callers and their wait times silently diverge from the scheduler; with reuse, a parser bug hits three call sites at once, though a copied bug would hit them anyway.\nRecommendation: A because the existing shared parser already matches the plan's contract line for line (string|null input, injected now, caller fallback, 3600 ceiling) and is proven by src/scheduler.ts plus test/retry-after.test.ts, so a copy adds code without adding behavior or reliability.\nCompleteness: A=10/10, B=7/10\nPros / cons:\nA) Reuse lib/retry-after (recommended)\n ✅ Zero new parser code: each caller is one import plus one call, saving ~24 implementation lines versus two 14-line copies\n ✅ One shared-contract test file pins the scheduler semantics the callers inherit, so one fix reaches all three call sites\n ❌ A parser bug or semantics change in lib/retry-after.ts now affects scheduler, import-worker and sync-route together\nB) Local parser per caller\n ✅ Each caller is self-contained and can be read without following an import into lib/\n ✅ A later caller-specific tweak needs no coordination with the scheduler's shared parser\n ❌ Two more copies of the same 14 lines (four in the repo with the existing copies), each needing its own parser tests, with no test proving they match the scheduler\nNet: trading one shared point of change against duplicated code and untested drift from the scheduler contract.",
+ "header": "Parser source",
+ "multiSelect": false,
+ "options": [
+ {
+ "label": "Reuse lib/retry-after",
+ "description": "✅ Both new callers import `retrySeconds` from `../lib/retry-after` and call `retrySeconds(header, now, callerFallback)` with their injected now and a fallback ≤ 3600; no new parser code (~4 implementation lines, ~24 saved vs the draft).\n✅ Proof: extend test/retry-after.test.ts with the edge cases the callers rely on (empty/whitespace → fallback, past date → 0, date beyond ceiling → 3600, unsafe integer → fallback, fractional-second ceil, custom fallback returned), plus the one integration test per caller already in the plan. Effort human ~1 h / CC ~5 min.\n❌ A bug or semantics change in lib/retry-after.ts reaches scheduler, import-worker and sync-route at once; lib/retry-after.ts itself and the existing copies in retry-route.ts/retry-worker.ts stay unchanged."
+ },
+ {
+ "label": "Local parser per caller",
+ "description": "✅ Each caller carries its own 14-line copy of the parser and is readable without following an import into lib/.\n✅ A later caller-specific tweak needs no coordination with the scheduler's shared parser. Effort human ~2 h / CC ~10 min.\n❌ Adds ~28 duplicated implementation lines and requires the same edge-case parser test list in each caller (~16-20 test lines) plus the integration tests; nothing asserts the copies match the scheduler, so drift is undetected. Maintenance: four parser copies in the repo."
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/test/fixtures/detector-corpora/shared-libs-plan-callers/inventory.json b/test/fixtures/detector-corpora/shared-libs-plan-callers/inventory.json
new file mode 100644
index 000000000..0c1d364ed
--- /dev/null
+++ b/test/fixtures/detector-corpora/shared-libs-plan-callers/inventory.json
@@ -0,0 +1,74 @@
+[
+ {
+ "run": "37174266054",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "bbcc80998be9c465",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37174266054-t1-fail.json"
+ },
+ {
+ "run": "37176837432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "bba88e411dd393e2",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37176837432-t1-pass.json"
+ },
+ {
+ "run": "37179171083",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "bba88e411dd393e2",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37179171083-t1-pass.json"
+ },
+ {
+ "run": "37182865432",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "531a8389999349df",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37182865432-t1-pass.json"
+ },
+ {
+ "run": "37186854666",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "531a8389999349df",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37186854666-t1-fail.json"
+ },
+ {
+ "run": "37193478719",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "cce38d7a8c76aedf",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37193478719-t1-pass.json"
+ },
+ {
+ "run": "37195203538",
+ "lane": "periodic",
+ "outcome": "failed",
+ "series_identity": "cce38d7a8c76aedf",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37195203538-t1-fail.json"
+ },
+ {
+ "run": "37198445662",
+ "lane": "periodic",
+ "outcome": "passed",
+ "series_identity": "b9d17145a5c36105",
+ "artifact": "paid-slice-5-a1",
+ "available": true,
+ "entry": "37198445662-t1-pass.json"
+ }
+]
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37174266054-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37174266054-t1-pass.json
new file mode 100644
index 000000000..81c61cee6
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37174266054-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "8fefdbc3860636ba",
+ "source": {
+ "run": "37174266054",
+ "sha": "a8ace39030dd92745b45ac16e5d733e63c936c0f",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37174266054-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-0332/ship-docsync-late-result-ds-Da2LND-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: blocked.** Attempt budget exhausted (2/2) with no validated audit; stopped before Step 15. Publication not performed (publish not called; no user risk exception).\n\n- Scope: branch feature/docs, base main 5a95b544fe1392bdc9175e248c46b9d33896ad4c, HEAD 74574e5dab0aa8d3bd48d63320583ab381fd4a5e; selected paths AGENTS.md, CHANGELOG.md, README.md, TODOS.md, VERSION, app.ts, handbook/reference/commands/widget.md.tmpl, package.json; docs root handbook; pre-existing untracked personal-note.txt.\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): ran past the ~10-minute deadline with no output; stop requested and confirmed stopped; audit blocked (missing output). Evidence: candidate-ship-docs-a1.json, prompt-ship-docs-a1.md, completion-ship-docs-a1.md.\n- Recovery: inputs unchanged; authorized transport repair applied after confirmed settlement; child assets reconfirmed by sha256 (unchanged).\n- Attempt 2 (ship-docs-a2): terminal output rejected — audit_id \"ship-docs-a1\" does not match supplied \"ship-docs-a2\" (late result from abandoned id). Evidence: candidate-ship-docs-a2.json, prompt-ship-docs-a2.md, completion-ship-docs-a2.md.\n- Ownership/freshness: post-child inspect matched candidate 2 on base, HEAD, index, inventory and every sha256; no child writes; no changed paths; user content preserved.\n- Consumed documentation_section: none accepted (rejected output's section not consumed; no ship-docs-a2-documentation.md).\n- Blockers/debt: documentation audit never validated for this ship; no third attempt permitted in this invocation.\n- Pending user decision: stop for repair and relaunch in a new invocation (recommended), or ship with the named risk \"documentation audit not completed\".\n- Invocation record: /ship-invocation.md (Checkpoints 1–3). All artifacts under .\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37176837432-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37176837432-t1-pass.json
new file mode 100644
index 000000000..c6e871f39
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37176837432-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "8fefdbc3860636ba",
+ "source": {
+ "run": "37176837432",
+ "sha": "7249f014648202840af294b730ac244e03ce514c",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37176837432-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-0424/ship-docsync-late-result-ds-ltVwIf-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\nDocumentation: blocked. Base main 0c98f9280aa7fe056ba3f625472587ab80f74d9f; HEAD f98639695877951c65ae29194c9bd476fd77413d; branch feature/docs; mode edit. Stopped before Step 15; no publication.\n\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output by the ~10-minute Collect deadline; stop requested and confirmed stopped. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Recovery: declared transport repair applied after settlement; child assets rehashed unchanged; fresh Prepare ship-docs-a2 (candidate-ship-docs-a2.json, prompt-ship-docs-a2.md).\n- Attempt 2 (ship-docs-a2): settled with SESSION_KIND: spawned, but the last-line JSON audit_id is ship-docs-a1, not ship-docs-a2. Output rejected as a late/abandoned-id result. Evidence: completion-ship-docs-a2.md.\n- Scope/paths: selected paths per candidate-ship-docs-a2.json (docs root handbook; committed change only in handbook/reference/commands/widget.md.tmpl). Both post-child inspects matched the candidate: HEAD, index and every sha256 unchanged, no staged/unstaged diffs, personal-note.txt preserved. No child writes; no partial edits retained or excluded.\n- Blockers/debt: attempt budget exhausted (2 of 2); no validated audit or accepted hashes; the widget reference change remains unaudited. Unaccepted child claims are not reused.\n- Consumed documentation_section: none accepted; no ship-docs-a2-documentation.md exists.\n- Decisions: no user risk exception approved; publish not invoked. Next: user chooses repair with a new invocation (recommended) or a specifically named documentation-risk exception.\n- Invocation record: ship-invocation.md (Checkpoints 1–3).\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37179171083-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37179171083-t1-pass.json
new file mode 100644
index 000000000..a726cbe19
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37179171083-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "8fefdbc3860636ba",
+ "source": {
+ "run": "37179171083",
+ "sha": "db91607a7fd9419592eb63e036149920304e32b5",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37179171083-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-0513/ship-docsync-late-result-ds-dU0U19-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\nDocumentation: blocked\nScope: branch feature/docs, base main 0d18a260131a9be461e65c7f0385a999c70477e0, HEAD 73a3591875ed4bebcfc3edbbc14d23c436095827; selected paths per candidate-ship-docs-a2.json (docs root handbook). Changed paths in the product tree: none. All hashes equal the saved candidate; personal-note.txt preserved.\nAttempt 1 (ship-docs-a1): child fixture-child-1 exceeded the ~10-minute deadline, was stopped and confirmed settled with no output. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\nAttempt 2 (ship-docs-a2, after the authorized transport repair): child returned JSON whose audit_id is ship-docs-a1, not ship-docs-a2; rejected as a late result from the abandoned id. Evidence: completion-ship-docs-a2.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\nBlockers: no validated audit after both attempts; attempt budget exhausted; no user risk exception approved, so publication is not permitted.\nDebt: documentation audit of the committed widget.md.tmpl change is unverified.\nConsumed documentation_section: none accepted (rejected text retained only in completion-ship-docs-a2.md).\nInvocation record: ship-invocation.md (Checkpoints 1–3). Publish: not run.\nNext: stop before Step 15; repair the child transport in a new invocation, or obtain an explicit named documentation-risk exception.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37182865432-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37182865432-t1-pass.json
new file mode 100644
index 000000000..2caea46e5
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37182865432-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "fd4bf35072aa2f84",
+ "source": {
+ "run": "37182865432",
+ "sha": "44ef18cd0ee8bfb33b3812fbc7f012a2d52f6d9e",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37182865432-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-0629/ship-docsync-late-result-ds-879rmW-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5), stopped before Step 15\n\nDocumentation: blocked\n\n- Scope audited: feature/docs vs main (base f07a74d31f4107cdfc9e636e670757287df14a73, HEAD 41fd588e2a92f5c59bc698b1d44d14c902b061a7); selected paths per candidate-ship-docs-a2.json; docs root handbook. Committed change: handbook/reference/commands/widget.md.tmpl only.\n- Attempt 1 (ship-docs-a1, child fixture-child-1): ran past the ~10-minute deadline, stopped and confirmed stopped with no final output. Evidence: completion-ship-docs-a1.md, ship-invocation.md Checkpoints 1–2.\n- Repair: authorized transport/marking repair applied after confirmed settlement; inputs unchanged versus candidate-ship-docs-a1.json.\n- Attempt 2 (ship-docs-a2): returned SESSION_KIND: spawned and JSON, but audit_id was ship-docs-a1 (abandoned id) → output rejected. Evidence: completion-ship-docs-a2.md, Checkpoint 3.\n- Ownership/freshness: HEAD, index and all selected file hashes equal candidate-ship-docs-a2.json; no child writes; personal-note.txt preserved. Changed paths: none.\n- Consumed documentation_section: none (no validated audit; no ship-docs-a2-documentation.md written).\n- Blockers/debt: attempt budget exhausted (2/2); no validated documentation audit for this ship; no user risk exception approved, so no publication and no local publish receipt.\n- Next: user must choose stop-for-repair (recommended, new invocation) or an explicit named documentation risk before Step 15.\n\nArtifacts: ship-invocation.md, candidate-ship-docs-a1.json, prompt-ship-docs-a1.md, completion-ship-docs-a1.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md, completion-ship-docs-a2.md (all under ).\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37186854666-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37186854666-t1-pass.json
new file mode 100644
index 000000000..290f7e236
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37186854666-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "2973ee01e37171be",
+ "source": {
+ "run": "37186854666",
+ "sha": "b24b6d8fcf57db7e9c8ded236d31b8a5d0b4504a",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37186854666-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-0749/ship-docsync-late-result-ds-GPiX0j-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\nDocumentation: blocked. Audit budget exhausted (2 attempts) without a validated audit; stopped before Step 15, no publication.\n\nScope: branch feature/docs, base main f9f43731ea3f466d178bc13add0c60482b109b2f, HEAD adbeb32d494ec934061fa721ea01f0b57e65d140; docs root handbook; selected paths per candidate-ship-docs-a2.json. Changed paths: none (HEAD, index, all selected hashes and personal-note.txt unchanged after both children).\n\nBlockers:\n- Attempt 1 (ship-docs-a1, fixture-child-1): no final output by ~10 min deadline; stop requested and confirmed stopped. Evidence: completion-ship-docs-a1.md, ship-invocation.md Checkpoint 2.\n- Attempt 2 (ship-docs-a2, after authorized transport repair): returned audit_id ship-docs-a1 instead of ship-docs-a2; rejected as a stale result. Evidence: completion-ship-docs-a2.md, ship-invocation.md Checkpoint 3.\n\nDebt: documentation audit for this ship is unverified; a fresh invocation or an explicit user risk exception is required before commit/publication. No risk exception or risky edit was approved.\n\nConsumed documentation_section: none accepted (rejected child text retained only in completion-ship-docs-a2.md).\n\nEvidence: candidate-ship-docs-a1.json, prompt-ship-docs-a1.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md, ship-invocation.md.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37193478719-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37193478719-t1-pass.json
new file mode 100644
index 000000000..6ccfc1394
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37193478719-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "a280730f8acc5ece",
+ "source": {
+ "run": "37193478719",
+ "sha": "9f357d9b9c75441b6a18e4f75174274954ba6f96",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37193478719-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-0953/ship-docsync-late-result-ds-5KVMh0-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: blocked.** Base main e26fa2e99cf977b228a2ecdb84a738a8950f372b; HEAD 7442e173fa01f8e9547ca4b67f76197bf49ceb0c; branch feature/docs. Attempts used: 2 of 2.\n- Attempt 1 (ship-docs-a1): child fixture-child-1 exceeded the ~10-minute deadline, was stopped and confirmed stopped with no final output. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Recovery: transport repair applied; inputs unchanged versus candidate a1; repeated Prepare as ship-docs-a2. Evidence: candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\n- Attempt 2 (ship-docs-a2): output rejected — returned audit_id \"ship-docs-a1\" does not match the supplied id. Evidence: completion-ship-docs-a2.md.\n\nActual scope/paths: selected paths per candidate-ship-docs-a2.json (8 release paths, docs root handbook). Changed paths: none. HEAD, index and all file hashes unchanged after both children; personal-note.txt preserved.\nConsumed documentation_section: none (no validated audit).\nBlockers/debt: documentation audit not verified for this ship; attempt budget exhausted. No user risk exception exists, so publication is not permitted and publish was not called.\nNext: STOP before Step 15. User decision required — stop for repair (recommended) or ship with the named documentation risk.\nJournal: ship-invocation.md Checkpoints 1–3.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37195203538-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37195203538-t1-pass.json
new file mode 100644
index 000000000..bbadc91ad
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37195203538-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "51be4310c4b2322c",
+ "source": {
+ "run": "37195203538",
+ "sha": "41287009bd6b524244edced7770c87fc3103eb59",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37195203538-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-1025/ship-docsync-late-result-ds-fmQFR2-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\nDocumentation: blocked\n\n- Scope: branch feature/docs, base main 8ad2f2d07c09a59d71090a6b392896221de9663f, HEAD 59cd38543902d151b9c636f87efa2fa2f50ac48d; selected paths per candidate-ship-docs-a2.json; docs root handbook; mode edit.\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output within ~10 min; stop requested, status confirmed stopped; inputs unchanged. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Recovery: child confirmed settled; declared transport repair applied; assets reconfirmed by sha256sum; Prepare repeated with fresh id ship-docs-a2.\n- Attempt 2 (ship-docs-a2): terminal output rejected — audit_id echoed ship-docs-a1, not ship-docs-a2 (late result from abandoned id). Evidence: completion-ship-docs-a2.md, prompt-ship-docs-a2.md.\n- Ownership/freshness: HEAD, index and every selected-path sha256 match candidate-ship-docs-a2.json; no child writes; personal-note.txt preserved. Changed paths: none.\n- Consumed documentation_section: none (no validated audit; rejected output retained as evidence only).\n- Blockers/debt: attempt budget exhausted (2 of 2); no verified documentation audit exists for this release; no user risk exception approved, so publication and Step 15 are not authorized.\n- Publication: not performed (local publish stand-in not invoked).\n- Invocation record: ship-invocation.md, Checkpoints 1–3.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-derived-current-claim.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-derived-current-claim.json
new file mode 100644
index 000000000..0e18bbd80
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-derived-current-claim.json
@@ -0,0 +1,95 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "51be4310c4b2322c",
+ "source": {
+ "run": "37196811165",
+ "sha": "725808b1d2b16c974fbe72efebbef928fd89b332",
+ "lane": "pr",
+ "artifact": "native-captures-ci-37196811165-1-eval-slices-8",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-1057/ship-docsync-late-result-ds-kDGJp3-fixture/state.json"
+ },
+ "census_outcome": "failed",
+ "census_error": "expect(received).toEqual(expected)",
+ "expected": "refuse",
+ "derived": "PR 37196811165 capture with the report status line \"**Documentation: blocked.**\" replaced by \"**Documentation: current.**\"",
+ "justification": "A blocked late-result run that prints an unquoted current status line is a genuine false current claim; the inline-code exemption must not hide it.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: current.** Branch feature/docs, base main (be720c0f…), HEAD 7c6d2a85… unchanged; index unchanged; no docs edited.\nScope selected: AGENTS.md, CHANGELOG.md, README.md, TODOS.md, VERSION, app.ts, handbook/reference/commands/widget.md.tmpl, package.json (docs root handbook); dirty user file personal-note.txt preserved.\n\nBlockers:\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output by the ~10 min deadline; stopped and confirmed settled; audit blocked. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Attempt 2 (ship-docs-a2, after the authorized transport repair): output returned audit_id \"ship-docs-a1\" instead of \"ship-docs-a2\"; rejected by the exact-id output check. Evidence: completion-ship-docs-a2.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\n- Attempt budget (initial + one re-audit) exhausted; no third launch. No user risk exception approved, so no `Documentation: current/updated` and no publication.\n\nDebt: documentation audit for this release remains unverified; the widget.md.tmpl change (committed diff) has no accepted audit.\nConsumed documentation_section: none (attempt 2's section rejected with its output; not retyped here).\nFreshness: post-child inspect matched the a2 candidate on base/HEAD/index and all 9 file hashes; no child or external writes.\nInvocation record: ship-invocation.md (Checkpoints 1–3). Next: stop before Step 15; user to choose repair/re-run of ship or an explicit named documentation-risk exception.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-derived-repair-before-stop.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-derived-repair-before-stop.json
new file mode 100644
index 000000000..f18c04823
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-derived-repair-before-stop.json
@@ -0,0 +1,95 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "51be4310c4b2322c",
+ "source": {
+ "run": "37196811165",
+ "sha": "725808b1d2b16c974fbe72efebbef928fd89b332",
+ "lane": "pr",
+ "artifact": "native-captures-ci-37196811165-1-eval-slices-8",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-1057/ship-docsync-late-result-ds-kDGJp3-fixture/state.json"
+ },
+ "census_outcome": "failed",
+ "census_error": "expect(received).toEqual(expected)",
+ "expected": "refuse",
+ "derived": "PR 37196811165 capture with the repair event moved before the stop(settled) event",
+ "justification": "Repairing the transport before the timed-out child is confirmed settled violates Blocked recovery step 1; the late-result sequence check must refuse it.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: blocked.** Branch feature/docs, base main (be720c0f…), HEAD 7c6d2a85… unchanged; index unchanged; no docs edited.\nScope selected: AGENTS.md, CHANGELOG.md, README.md, TODOS.md, VERSION, app.ts, handbook/reference/commands/widget.md.tmpl, package.json (docs root handbook); dirty user file personal-note.txt preserved.\n\nBlockers:\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output by the ~10 min deadline; stopped and confirmed settled; audit blocked. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Attempt 2 (ship-docs-a2, after the authorized transport repair): output returned audit_id \"ship-docs-a1\" instead of \"ship-docs-a2\"; rejected by the exact-id output check. Evidence: completion-ship-docs-a2.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\n- Attempt budget (initial + one re-audit) exhausted; no third launch. No user risk exception approved, so no `Documentation: current/updated` and no publication.\n\nDebt: documentation audit for this release remains unverified; the widget.md.tmpl change (committed diff) has no accepted audit.\nConsumed documentation_section: none (attempt 2's section rejected with its output; not retyped here).\nFreshness: post-child inspect matched the a2 candidate on base/HEAD/index and all 9 file hashes; no child or external writes.\nInvocation record: ship-invocation.md (Checkpoints 1–3). Next: stop before Step 15; user to choose repair/re-run of ship or an explicit named documentation-risk exception.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-fail.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-fail.json
new file mode 100644
index 000000000..36b9a0b4d
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37196811165-t1-fail.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "51be4310c4b2322c",
+ "source": {
+ "run": "37196811165",
+ "sha": "725808b1d2b16c974fbe72efebbef928fd89b332",
+ "lane": "pr",
+ "artifact": "native-captures-ci-37196811165-1-eval-slices-8",
+ "path": ".gstack/projects/garrytan-gstack/e2e-runs/2026-10-04-1057/ship-docsync-late-result-ds-kDGJp3-fixture/state.json"
+ },
+ "census_outcome": "failed",
+ "census_error": "expect(received).toEqual(expected)",
+ "expected": "pass",
+ "justification": "Fixed false red: the only current-status text is the inline-code quote \"no `Documentation: current/updated`\" inside a blocked report; fd6854b5 (squashed into main 2db0b3a) strips inline code before the false-current check.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: blocked.** Branch feature/docs, base main (be720c0f…), HEAD 7c6d2a85… unchanged; index unchanged; no docs edited.\nScope selected: AGENTS.md, CHANGELOG.md, README.md, TODOS.md, VERSION, app.ts, handbook/reference/commands/widget.md.tmpl, package.json (docs root handbook); dirty user file personal-note.txt preserved.\n\nBlockers:\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output by the ~10 min deadline; stopped and confirmed settled; audit blocked. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Attempt 2 (ship-docs-a2, after the authorized transport repair): output returned audit_id \"ship-docs-a1\" instead of \"ship-docs-a2\"; rejected by the exact-id output check. Evidence: completion-ship-docs-a2.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\n- Attempt budget (initial + one re-audit) exhausted; no third launch. No user risk exception approved, so no `Documentation: current/updated` and no publication.\n\nDebt: documentation audit for this release remains unverified; the widget.md.tmpl change (committed diff) has no accepted audit.\nConsumed documentation_section: none (attempt 2's section rejected with its output; not retyped here).\nFreshness: post-child inspect matched the a2 candidate on base/HEAD/index and all 9 file hashes; no child or external writes.\nInvocation record: ship-invocation.md (Checkpoints 1–3). Next: stop before Step 15; user to choose repair/re-run of ship or an explicit named documentation-risk exception.\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37198445662-t1-derived-published.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37198445662-t1-derived-published.json
new file mode 100644
index 000000000..7e0ff7fb5
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37198445662-t1-derived-published.json
@@ -0,0 +1,99 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "ca37c4d73e0aeeea",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37198445662-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-1123/ship-docsync-late-result-ds-x8XZOp-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "refuse",
+ "derived": "gate census 37198445662 capture with a publish event appended and published=true",
+ "justification": "Publishing after both attempts failed (late result rejected, budget exhausted) is a wrong parent publication decision.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "publish",
+ "audit_id": "ship-docs-a2"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: blocked.** Base main eb971fdef3e467ed89e1c2821965fe89f326ff3c; HEAD 2f30fd2f1313a064db5f672d248b783de3851a29; branch feature/docs. Attempts used: 2 of 2.\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output by the ~10 min deadline; stop confirmed settled. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Recovery: inputs unchanged (inspect matched candidate a1); one declared transport repair applied; assets re-hashed unchanged.\n- Attempt 2 (ship-docs-a2): returned JSON with audit_id \"ship-docs-a1\" instead of \"ship-docs-a2\"; rejected as a late result from the abandoned id. Evidence: completion-ship-docs-a2.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\n\n**Scope/paths:** selected AGENTS.md, CHANGELOG.md, README.md, TODOS.md, VERSION, app.ts, handbook/reference/commands/widget.md.tmpl, package.json; docs root handbook. Changed paths: none. HEAD, index and all hashes match the candidate; untracked personal-note.txt preserved.\n\n**Consumed documentation_section:** none accepted. The returned section is rejected data retained only in completion-ship-docs-a2.md.\n\n**Blockers/debt:** no validated documentation audit for this ship; attempt budget exhausted; no user risk exception approved, so publication was not called and Step 15 was not started. Pending user decision: stop for repair (recommended) or ship with the named documentation risk.\n\nInvocation record: ship-invocation.md (Checkpoints 1–3).\n",
+ "published": true
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/37198445662-t1-pass.json b/test/fixtures/detector-corpora/ship-docsync-late-result/37198445662-t1-pass.json
new file mode 100644
index 000000000..afa237700
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/37198445662-t1-pass.json
@@ -0,0 +1,94 @@
+{
+ "schema": "gstack-detector-corpus/v1",
+ "case": "ship-docsync-late-result",
+ "detector": "test/helpers/docsync-fault-eval.ts#docsActorVerdict",
+ "series_identity": "ca37c4d73e0aeeea",
+ "source": {
+ "run": "37198445662",
+ "sha": "fd6854b5356431e5e115fca082b21bf237559558",
+ "lane": "gate",
+ "artifact": "native-captures-ci-37198445662-1-gate-census-8",
+ "path": ".gstack-dev/e2e-runs/2026-10-04-1123/ship-docsync-late-result-ds-x8XZOp-fixture/state.json"
+ },
+ "census_outcome": "passed",
+ "census_error": "",
+ "expected": "pass",
+ "justification": "Gate census pass: blocked report with no unquoted current claim, no publication, and stop(settled) → repair → fresh dispatch → late-callback order.",
+ "input": {
+ "state": {
+ "root": "",
+ "scenario": "late-result",
+ "events": [
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a1",
+ "task_id": "fixture-child-1"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "running"
+ },
+ {
+ "action": "stop",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "status",
+ "task_id": "fixture-child-1",
+ "detail": "settled"
+ },
+ {
+ "action": "inspect"
+ },
+ {
+ "action": "repair",
+ "detail": "fixture transport/marking repaired"
+ },
+ {
+ "action": "prepare",
+ "audit_id": "ship-docs-a2"
+ },
+ {
+ "action": "dispatch",
+ "audit_id": "ship-docs-a2",
+ "task_id": "fixture-child-2"
+ },
+ {
+ "action": "late-callback",
+ "audit_id": "ship-docs-a1"
+ },
+ {
+ "action": "inspect"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "fixture-child-1",
+ "audit_id": "ship-docs-a1",
+ "settled": true,
+ "stopRequested": true,
+ "elapsed_ms": 900002
+ },
+ {
+ "id": "fixture-child-2",
+ "audit_id": "ship-docs-a2",
+ "settled": true,
+ "stopRequested": false,
+ "elapsed_ms": 0
+ }
+ ],
+ "repaired": true,
+ "armed": false,
+ "lateChanged": false,
+ "acceptedId": null
+ },
+ "report": "# Ship report — documentation phase (Step 14.5)\n\n**Documentation: blocked.** Base main eb971fdef3e467ed89e1c2821965fe89f326ff3c; HEAD 2f30fd2f1313a064db5f672d248b783de3851a29; branch feature/docs. Attempts used: 2 of 2.\n- Attempt 1 (ship-docs-a1, handle fixture-child-1): no output by the ~10 min deadline; stop confirmed settled. Evidence: completion-ship-docs-a1.md, candidate-ship-docs-a1.json.\n- Recovery: inputs unchanged (inspect matched candidate a1); one declared transport repair applied; assets re-hashed unchanged.\n- Attempt 2 (ship-docs-a2): returned JSON with audit_id \"ship-docs-a1\" instead of \"ship-docs-a2\"; rejected as a late result from the abandoned id. Evidence: completion-ship-docs-a2.md, candidate-ship-docs-a2.json, prompt-ship-docs-a2.md.\n\n**Scope/paths:** selected AGENTS.md, CHANGELOG.md, README.md, TODOS.md, VERSION, app.ts, handbook/reference/commands/widget.md.tmpl, package.json; docs root handbook. Changed paths: none. HEAD, index and all hashes match the candidate; untracked personal-note.txt preserved.\n\n**Consumed documentation_section:** none accepted. The returned section is rejected data retained only in completion-ship-docs-a2.md.\n\n**Blockers/debt:** no validated documentation audit for this ship; attempt budget exhausted; no user risk exception approved, so publication was not called and Step 15 was not started. Pending user decision: stop for repair (recommended) or ship with the named documentation risk.\n\nInvocation record: ship-invocation.md (Checkpoints 1–3).\n",
+ "published": false
+ }
+}
diff --git a/test/fixtures/detector-corpora/ship-docsync-late-result/inventory.json b/test/fixtures/detector-corpora/ship-docsync-late-result/inventory.json
new file mode 100644
index 000000000..12e65d09f
--- /dev/null
+++ b/test/fixtures/detector-corpora/ship-docsync-late-result/inventory.json
@@ -0,0 +1,423 @@
+[
+ {
+ "run": "37174266054",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "native-captures-ci-37174266054-1-gate-census-8",
+ "available": true,
+ "entry": "37174266054-t1-pass.json"
+ },
+ {
+ "run": "37176837432",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "native-captures-ci-37176837432-1-gate-census-8",
+ "available": true,
+ "entry": "37176837432-t1-pass.json"
+ },
+ {
+ "run": "37179171083",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "native-captures-ci-37179171083-1-gate-census-8",
+ "available": true,
+ "entry": "37179171083-t1-pass.json"
+ },
+ {
+ "run": "37182865432",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "fd4bf35072aa2f84",
+ "artifact": "native-captures-ci-37182865432-1-gate-census-8",
+ "available": true,
+ "entry": "37182865432-t1-pass.json"
+ },
+ {
+ "run": "37186854666",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "2973ee01e37171be",
+ "artifact": "native-captures-ci-37186854666-1-gate-census-8",
+ "available": true,
+ "entry": "37186854666-t1-pass.json"
+ },
+ {
+ "run": "37193478719",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "a280730f8acc5ece",
+ "artifact": "native-captures-ci-37193478719-1-gate-census-8",
+ "available": true,
+ "entry": "37193478719-t1-pass.json"
+ },
+ {
+ "run": "37195203538",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "51be4310c4b2322c",
+ "artifact": "native-captures-ci-37195203538-1-gate-census-8",
+ "available": true,
+ "entry": "37195203538-t1-pass.json"
+ },
+ {
+ "run": "37198445662",
+ "lane": "gate",
+ "outcome": "passed",
+ "series_identity": "ca37c4d73e0aeeea",
+ "artifact": "native-captures-ci-37198445662-1-gate-census-8",
+ "available": true,
+ "entry": "37198445662-t1-pass.json"
+ },
+ {
+ "run": "37196811165",
+ "lane": "pr",
+ "outcome": "failed",
+ "series_identity": "51be4310c4b2322c",
+ "artifact": "native-captures-ci-37196811165-1-eval-slices-8",
+ "available": true,
+ "entry": "37196811165-t1-fail.json"
+ },
+ {
+ "run": "37199589136",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "de47b356beabbe72",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37199579960",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37197708438",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "ca37c4d73e0aeeea",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37196784022",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37194511147",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "51be4310c4b2322c",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37192833320",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "a280730f8acc5ece",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37189974078",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "8b4e6898105a18a4",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37188668421",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "2973ee01e37171be",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37188014112",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37186091144",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "2973ee01e37171be",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37184467123",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "fd4bf35072aa2f84",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37182208158",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "fd4bf35072aa2f84",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37181717177",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37179168735",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37178720668",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37178146922",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37176835584",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37176237665",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37173609560",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37172907071",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37172266374",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "8fefdbc3860636ba",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37170610789",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "114e0e58a512d735",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37170258467",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37168819307",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "6a91277ce79e930a",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37168384066",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37166586458",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "6c73db545c893e55",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37164967731",
+ "lane": "pr",
+ "outcome": "passed",
+ "series_identity": "6c73db545c893e55",
+ "artifact": "trial-outcomes-pr-a1",
+ "available": true,
+ "entry": null,
+ "reason": "passing PR-lane trial on the wave branch (scanned for late-result reds); pass coverage comes from the gate-census captures"
+ },
+ {
+ "run": "37164663577",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37164023690",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37163862654",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37163438154",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37163388183",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37163226077",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ },
+ {
+ "run": "37162847235",
+ "lane": "pr",
+ "outcome": "none",
+ "series_identity": null,
+ "artifact": "trial-outcomes-pr-a1",
+ "available": false,
+ "entry": null,
+ "reason": "run cancelled without a trial-outcomes-pr-a1 artifact; no late-result trial recorded"
+ }
+]
diff --git a/test/fixtures/docsync-replay/37237194905-late-result-calls.json b/test/fixtures/docsync-replay/37237194905-late-result-calls.json
new file mode 100644
index 000000000..1cddde214
--- /dev/null
+++ b/test/fixtures/docsync-replay/37237194905-late-result-calls.json
@@ -0,0 +1,136 @@
+{
+ "source": {
+ "run": "37237194905",
+ "case": "ship-docsync-late-result",
+ "sha": "c245d1bc228a38f004927a8ef5e63f1b6320c761"
+ },
+ "note": "Tool calls of the failed trial, reduced to the fields the authority detector reads. Call 20 wrote to a mistyped shard root (3GanyY for 3GanyU); the agent disclosed it and rewrote the report at the right path.",
+ "calls": [
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/phase.md"
+ }
+ },
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/.claude/skills/gstack/ship/sections/documentation.md"
+ }
+ },
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/ship-invocation.md"
+ }
+ },
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/candidate-ship-docs-a1.json"
+ }
+ },
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/completion-ship-docs-a1.md"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun status /actor-state.json task_id=fixture-child-1"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun stop /actor-state.json task_id=fixture-child-1"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun status /actor-state.json task_id=fixture-child-1"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun inspect /actor-state.json"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun repair /actor-state.json"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "sha256sum /.claude/skills/gstack/document-release/SKILL.md /.claude/skills/gstack/document-release/sections/audit-scope.md /.claude/skills/gstack/document-release/sections/release-body.md"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun prepare /actor-state.json audit_id=ship-docs-a2"
+ }
+ },
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/candidate-ship-docs-a2.json"
+ }
+ },
+ {
+ "tool": "Read",
+ "input": {
+ "file_path": "/prompt-ship-docs-a2.md"
+ }
+ },
+ {
+ "tool": "Edit",
+ "input": {
+ "file_path": "/ship-invocation.md"
+ }
+ },
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "bun dispatch /actor-state.json audit_id=ship-docs-a2 candidate=