From 726aaf138e93d7816787b7090b9aa72dadd67c9a Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 12:43:49 +0000 Subject: [PATCH 01/11] fix(review): readers start their reply with the bare REVIEWED_TREE line; a printed: label is read (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/review-brief.ts | 6 +++--- test/review-dispatch.test.ts | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 67086d89e..093f37c4b 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -142,7 +142,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { */ export function workspaceParagraph(dir: string): string { const q = `'${dir.replace(/'/g, `'\\''`)}'`; - return `Workspace: the checkout under review is ${dir}. Your shell and file tools may start in another checkout, and every command runs in a fresh shell: begin each command with \`cd ${q} && \`, and give every file read, search, edit or write an absolute path under ${dir}. Make the first line of your final reply the line \`cd ${q} && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"\` prints, even where this brief limits your output to findings; a reply without it, or from another checkout, does not count.`; + return `Workspace: the checkout under review is ${dir}. Your shell and file tools may start in another checkout, and every command runs in a fresh shell: begin each command with \`cd ${q} && \`, and give every file read, search, edit or write an absolute path under ${dir}. Start your final reply with the bare line \`REVIEWED_TREE: \`, even where this brief limits your output to findings: nothing before it, not the command and not a label. Get the line by running \`cd ${q} && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"\` and copy what it prints. A reply without that line, or from another checkout, does not count.`; } export const withWorkspace = (text: string, dir: string) => `${text}\n\n${workspaceParagraph(dir)}`; /** The checkout a brief names: the toplevel of the directory the bins run in, where its diff and merge base come from. */ @@ -152,11 +152,11 @@ export function checkoutRoot(): string { } /** - * One REVIEWED_TREE line as " ", or null. A list marker or heading mark may lead it, and a whole-line `**…**` or + * One REVIEWED_TREE line as " ", or null. A list marker, heading mark or `printed:` label (GST-776) may lead it, and a whole-line `**…**` or * backtick wrapper, a bold label and backticks around the sha or the whole path are removed; the path's own characters are kept. */ function parseTreeLine(line: string): string | null { - let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, ''); + let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, '').replace(/^[Pp]rinted:\s+/, ''); for (const w of ['**', '`']) if (t.length > 2 * w.length && t.startsWith(w) && t.endsWith(w)) t = t.slice(w.length, -w.length).trim(); const m = /^(?:\*\*REVIEWED_TREE:\*\*|REVIEWED_TREE:)\s+`?([0-9a-f]{40,64})`?\s+(.+)$/.exec(t); if (!m) return null; diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index 7a5cf7c41..72d0d2580 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -580,3 +580,41 @@ describe('reading the REVIEWED_TREE line (GST-482 review fixes)', () => { expect(r.stderr).toContain('BRIEF_RENDER_FAILED: cannot read HEAD^{tree} and the toplevel of'); }); }); + +describe('the REVIEWED_TREE line as GBRA-218 readers wrote it (GST-776)', () => { + const top = fs.realpathSync(spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim()); + const tree = spawnSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + const command = `cd '${top}' && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"`; + // Value: protects=a reader that reviewed the right checkout is not failed for how it wrote its fingerprint; fails_when=the brief asks for "the line prints" (two Sonnet 5.5 low readers then wrote `printed: REVIEWED_TREE: …` and were failed as reported="none"), or a `printed:` prefix lets a wrong tree through; + // why_new=GBRA-218 pass restarts on gstack 1.92.25.0; seam=lib/review-brief.ts workspaceParagraph and checkReviewedTree via gstack-review-dispatch collect; no_claim=that every model now writes the bare line + test('the brief asks for the bare line first and gives the command only as the way to get it', () => { + const p = workspaceParagraph(top); + expect(p).not.toContain('the line `cd'); + const bare = p.indexOf('`REVIEWED_TREE: `'); + expect(bare).toBeGreaterThan(0); + expect(p.indexOf(command)).toBeGreaterThan(bare); + }); + + test('the echoed command then a printed: line is read; the same shape with a wrong tree or checkout is still refused', () => { + const reply = (fingerprint: string) => `${command}\nprinted: REVIEWED_TREE: ${fingerprint}\nNO FINDINGS\n`; + expect(checkReviewedTree(reply(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); + expect(checkReviewedTree(reply(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true, reported: `${'b'.repeat(40)} ${top}` }); + expect(checkReviewedTree(reply(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false, reported: `${tree} /elsewhere` }); + expect(checkReviewedTree(`Note: REVIEWED_TREE: ${tree} ${top}\nNO FINDINGS`, `${tree} ${top}`).reported).toBe('none'); + + const out = tmp(); + const ledger = path.join(out, 'review-ledger.jsonl'); + const merged = path.join(out, 'merged.jsonl'); + const file = field(run(['render', '--role', 'specialist', '--name', 'testing', '--out', out, '--diff-base', SHA]).stdout, 'file'); + run(['sent', '--ledger', ledger, '--role', 'specialist', '--name', 'testing', '--sent', file]); + const collectReply = (text: string) => { + const f = path.join(out, 'reply.out'); + fs.writeFileSync(f, text); + return spawnSync(BIN, ['collect', '--ledger', ledger, '--role', 'specialist', '--name', 'testing', '--output', f, '--merged', merged], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); + }; + const wrong = collectReply(reply(`${'b'.repeat(40)} ${top}`)); + expect([wrong.status, wrong.stdout.startsWith('COLLECT_TREE_MOVED: reader=specialist/testing')]).toEqual([1, true]); + const right = collectReply(reply(`${tree} ${top}`)); + expect(right.stdout).toStartWith('COLLECT: reader=specialist/testing findings=0 '); + }); +}); From 15c161a6e107cfa8f981faa237d8a6df16750fb0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 12:43:49 +0000 Subject: [PATCH 02/11] fix(ship): the Capy pass program refuses a foreign start checkout and takes Step 11's adversarial-review start (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- capy/skill/gstack/SKILL.md | 2 +- docs/review-policy.md | 7 ++++- lib/capy-review-pass.js | 11 ++++++- test/review-pass.test.ts | 60 +++++++++++++++++++++++++++++++------- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index b513fdbdf..4a3f6114a 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"` and omits `logStart`: the program takes the `adversarial-review` start token Step 11.5 binds and refuses a list without it. Readers start in the thread's working directory, so `cwd` must be that checkout; any other stops the pass before a reader is sent. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/docs/review-policy.md b/docs/review-policy.md index d05ecdda8..c18bd7d15 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -526,7 +526,12 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: `COLLECT_WRONG_TREE`, a later commit in the same checkout `COLLECT_TREE_MOVED`; nothing is merged, the reader becomes `failed`, and `verify` reports `NOT_COLLECTED`. An outside voice's tree failure is recorded `unavailable` only when its reply holds no finding. The line catches a reader that - worked elsewhere by accident; it is not proof of reading, since diff text can predict it. + worked elsewhere by accident; it is not proof of reading, since diff text can predict it. The brief asks for the bare + line and gives the command only as the way to get it; a `printed:` label before the line is still read, and its tree + and toplevel are still compared exactly (GST-776). Capy cannot start a subagent in a chosen directory, so before it + takes a start token or sends a reader, the Capy pass program compares the checkout with the one shared readers start + in (the thread's working directory) and stops on a mismatch. Step 11's voices take the `adversarial-review` start + token by default, and a `logStart` list without it stops before anything runs. ## Where this is enforced diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 17fad909b..c751f0448 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -26,6 +26,8 @@ const finish = (tail) => { return text; }; const stop = (why) => finish(['STOP: ' + why]); +const logStart = input.stage === 'voices' ? (input.logStart ?? ['adversarial-review']) : (input.logStart || []); +if (input.stage === 'voices' && !(logStart.length && logStart.every((s) => s.split(' ')[0] === 'adversarial-review'))) return stop('Step 11 (stage "voices") takes the adversarial-review start token, which Step 11.5 binds, so its logStart is ["adversarial-review"] (with any --reservation in input.reservation) or omitted, never ' + JSON.stringify(input.logStart) + '; nothing ran'); const passCmd = [bin, 'pass', '--base', q(input.base), '--out', q(input.out), '--pass', q(input.pass)]; if (input.ledger) passCmd.push('--ledger', q(input.ledger)); @@ -40,6 +42,13 @@ const pr = await sh(passCmd.join(' ')); if (pr.code !== 0) return stop('gstack-review-dispatch pass exited ' + pr.code + ': ' + pr.out.slice(-1500)); const passOut = pr.out.trimEnd().split('\n'); if (passOut[passOut.length - 1].startsWith('PASS: none ')) return finish(passOut); +if (input.cwd) { + const top = 'cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pwd -P'; + const ship = await sh(top); + const start = await tools.capy.bash({ command: top, description: 'Ship review pass ' + input.pass + ' reader start checkout', timeout: 300 }); + const want = ship.out.trim(), got = (start.output || '').trim(); + if (ship.code !== 0 || start.exit_code !== 0 || want !== got) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + ' (the thread\'s working directory) on this branch'); +} const resumed = passOut.some((l) => l.startsWith('MANIFEST: ') && l.endsWith(' resumed')); lines.push(...passOut.filter((l) => !resumed || l.startsWith('MANIFEST: '))); const mf = input.out.replace(/\/+$/, '') + '/pass-manifest.json'; @@ -82,7 +91,7 @@ const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', try { let m = await load(); if (input.stage === 'voices' && !m.readers.some((r) => r.stage === 'voices')) return finish(['VOICES: none (the pass plan lists no Step 11 voice)', 'NEXT: judge']); - for (const s of input.logStart || []) { + for (const s of logStart) { const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s; if ((m.log_starts || {})[key]) continue; const l = await sh('gstack-review-log --start ' + s.split(' ').map(q).join(' ') + (input.reservation ? ' --reservation ' + q(input.reservation) : '')); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index e3458effc..684d9517c 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -325,6 +325,27 @@ describe('Capy pass program (fake host, real bins)', () => { expect(h2.starts).toEqual([[1]]); }, 240_000); + // Value: protects=Step 11.5 always has an adversarial-review start token to bind; fails_when=Step 11 reuses the pass input's logStart: ["review"] and runs the voices with no adversarial-review start, or a missing token is found only after the voices ran; + // why_new=GBRA-218 reran a whole pass after the loader example's logStart was reused for stage voices; seam=lib/capy-review-pass.js log starts; no_claim=gstack-review-read's binding itself + test('Step 11 takes the adversarial-review start token by default and refuses a list without it before anything runs', async () => { + const { dir, env } = repo(); + const out = path.join(dir, '.state/run/p1'); + const h = host(dir, env); + const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } }; + const base = { base: 'main', out, pass: 'x12p1', planFlags: ['--depth-floor', 'full'], logStart: ['review'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + const created = h.creates.length; + const reused = await runPass(h, { ...base, stage: 'voices', models }); + expect(reused).toStartWith('STOP: Step 11 (stage "voices") takes the adversarial-review start token'); + expect(h.creates.length).toBe(created); + const voices = await runPass(h, { ...base, logStart: undefined, stage: 'voices', models }); + expect(voices).toMatch(/REVIEW_LOG_START: adversarial-review token=\S+/); + expect(voices.indexOf('REVIEW_LOG_START: adversarial-review')).toBeLessThan(voices.indexOf('DISPATCHED:')); + expect(voices).toContain('NEXT: judge the voices'); + const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8')); + expect(Object.keys(m.log_starts ?? {}).sort()).toEqual(['pass:review', 'voices:adversarial-review']); + }, 240_000); + test('Step 11 voices run from the same manifest; the outside voice needs a model of another family', async () => { const { dir, env } = repo(); const out = path.join(dir, '.state/run/p1'); @@ -441,11 +462,10 @@ describe('Capy pass program (fake host, real bins)', () => { // why_new=replay C2-34 (GSTA-193) on PR 2; seam=none; no_claim=the host's status timing beyond one late summary test('extras as JSON, the checkout as cwd, and a late summary all run through without a STOP', async () => { const { dir, env } = repo(); - const elsewhere = tmp('rp-cwd-'); const out = path.join(dir, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'qa.prompt.md'), 'QA brief'); - const h = host(elsewhere, env, { lateSummary: true }); + const h = host(dir, env, { lateSummary: true }); const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'qa', prompt_file: path.join(out, 'qa.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] }); expect(res).not.toContain('STOP:'); @@ -556,12 +576,30 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 sh('for i in $(seq 1 20); do echo "export const w$i = $i;" >> src/a.ts; done && git commit -qam allow', wt); return { dir, env, wt: fs.realpathSync(wt) }; } + // Value: protects=no reader is started where Capy will put it (the shared machine's start checkout) when /ship ships another checkout; fails_when=the program dispatches readers that start in the primary checkout while the pass ships a worktree (GBRA-218's outside voice ignored `cd &&` and reviewed the primary checkout, losing Step 11 outside coverage); + // why_new=GBRA-218 on gstack 1.92.25.0; Capy subagents take no working directory; seam=lib/capy-review-pass.js before any log start or create; no_claim=that Capy can start a reader in a chosen directory + test('a shared machine whose start checkout is not the worktree refuses before any start token or reader', async () => { + const { dir, env, wt } = worktree(); + const out = path.join(wt, '.state/run/p1'); + const h = host(dir, env); + const res = await runPass(h, { base: 'main', out, pass: 'w0p1', cwd: wt, planFlags: ['--depth-floor', 'full'], logStart: ['review'] }); + expect(res).toContain(`STOP: readers on the shared machine start in ${fs.realpathSync(dir)}, not in ${wt}`); + expect(res).not.toContain('REVIEW_LOG_START:'); + expect(h.creates.length).toBe(0); + const voices = await runPass(h, { base: 'main', out, pass: 'w0p1', cwd: wt, planFlags: ['--depth-floor', 'full'], stage: 'voices', models: { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } } }); + expect(voices).toContain('STOP: readers on the shared machine start in '); + expect(h.creates.length).toBe(0); + const started = host(wt, env); + expect(await runPass(started, { base: 'main', out: path.join(wt, '.state/run/p2'), pass: 'w0p2', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); + expect(started.creates.length).toBeGreaterThan(0); + }, 240_000); + // Value: protects=a reader that never left the machine's default checkout cannot hand /ship a review of another tree; fails_when=collect merges a reply whose REVIEWED_TREE names the primary checkout while the pass ships a worktree, or a reply with no such line; // why_new=GBRA-218: pass 1's specialist reviewed /workspace/gbrain while /ship shipped ~/.capy/work/wt-allow; seam=gstack-review-dispatch collect; no_claim=a reader that fingerprints the right checkout but reads another test('a reader that reviews the primary checkout instead of the worktree is refused and nothing it said is merged', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: () => dir, output: t => (t.includes('specialist') ? '[CRITICAL] (confidence: 9/10) src/other.ts:3 a file outside this PR' : cleanReply(t)) }); + const h = host(wt, env, { reportFrom: () => dir, output: t => (t.includes('specialist') ? '[CRITICAL] (confidence: 9/10) src/other.ts:3 a file outside this PR' : cleanReply(t)) }); const res = await runPass(h, { base: 'main', out, pass: 'w1p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).toContain(`reported="${treeLine(dir).slice(15).trim()}"`); @@ -576,20 +614,20 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('a reply that names no checkout is refused the same way', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const res = await runPass(host(dir, env, { reportFrom: () => '' }), { base: 'main', out, pass: 'w2p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); + const res = await runPass(host(wt, env, { reportFrom: () => '' }), { base: 'main', out, pass: 'w2p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).toContain('reported="none"'); expect(res).not.toContain('NEXT: judge'); }, 240_000); - // Value: protects=/ship from a worktree on a shared machine works when readers follow their brief, though they start in another checkout; fails_when=a brief does not name the shipped checkout, or a correct reply is refused; + // Value: protects=/ship from a worktree that is the shared machine's start checkout works, its readers' briefs naming it (GST-776 refuses any other start checkout up front); fails_when=a brief does not name the shipped checkout, or a correct reply is refused; // why_new=GBRA-218 (no brief named a directory); seam=lib/review-pass.ts renderStage and the Capy pass program; no_claim=model compliance with the paragraph test('a reader that works in the checkout its brief names passes, and every brief names the worktree', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'coverage.prompt.md'), 'Run Step 7 as written.\n'); - const h = host(dir, env); + const h = host(wt, env); const res = await runPass(h, { base: 'main', out, pass: 'w3p1', cwd: wt, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'coverage', prompt_file: path.join(out, 'coverage.prompt.md'), first: true }] }); expect(res).not.toContain('STOP:'); @@ -604,7 +642,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const out = path.join(wt, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'docs.prompt.md'), 'Run the Step 14.5 audit as written.\n'); - const h = host(dir, env, { reportFrom: t => (t.endsWith('audit/docs') ? dir : briefDirOf(h, t)) }); + const h = host(wt, env, { reportFrom: t => (t.endsWith('audit/docs') ? dir : briefDirOf(h, t)) }); const res = await runPass(h, { base: 'main', out, pass: 'w4p1', cwd: wt, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'docs', prompt_file: path.join(out, 'docs.prompt.md'), first: true }] }); expect(res).toContain('COLLECT_WRONG_TREE: reader=audit/docs'); @@ -615,7 +653,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('a rerun after a wrong-tree STOP stops on the failed reader and never collects its reply twice', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: () => dir }); + const h = host(wt, env, { reportFrom: () => dir }); const input = { base: 'main', out, pass: 'w5p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, input)).toContain('COLLECT_WRONG_TREE: reader='); const again = await runPass(h, input); @@ -629,7 +667,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const wt = path.join(tmp('rp-wt-'), 'same'); expect(spawnSync('git', ['worktree', 'add', '-q', '--detach', wt, 'feat'], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).status).toBe(0); const out = path.join(wt, '.state/run/p1'); - const res = await runPass(host(dir, env, { reportFrom: () => dir }), { base: 'main', out, pass: 'w6p1', cwd: fs.realpathSync(wt), planFlags: ['--depth-floor', 'full'] }); + const res = await runPass(host(wt, env, { reportFrom: () => dir }), { base: 'main', out, pass: 'w6p1', cwd: fs.realpathSync(wt), planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).not.toContain('COLLECT_TREE_MOVED'); }, 240_000); @@ -638,7 +676,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); let moved = false; - const h = host(dir, env, { reportFrom: t => { + const h = host(wt, env, { reportFrom: t => { if (!moved) { moved = true; spawnSync('bash', ['-c', 'echo "export const late = 1;" >> src/a.ts && git commit -qam late'], { cwd: wt, env, encoding: 'utf8', timeout: 60_000 }); } return briefDirOf(h, t); } }); @@ -650,7 +688,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('an outside voice that reviewed another tree is recorded as unavailable coverage, never clean, and the voices still finish', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: t => (t.endsWith('outside-voice') ? dir : briefDirOf(h, t)) }); + const h = host(wt, env, { reportFrom: t => (t.endsWith('outside-voice') ? dir : briefDirOf(h, t)) }); const base = { base: 'main', out, pass: 'w8p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' }, 'adversarial-native': { model_id: 'anthropic/claude-opus-5-5', reasoning_mode: 'high' } }; From a0975607660eac4312bba32dea455651d6a283bf Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 12:56:02 +0000 Subject: [PATCH 03/11] fix(ship): a start directory outside any checkout still dispatches; the Capy skill's Step 11 note fits its context budget (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- capy/skill/gstack/SKILL.md | 2 +- docs/review-policy.md | 3 ++- lib/capy-review-pass.js | 5 +++-- test/fixtures/context-budget.json | 2 +- test/review-pass.test.ts | 4 +++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 4a3f6114a..af5d7c684 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"` and omits `logStart`: the program takes the `adversarial-review` start token Step 11.5 binds and refuses a list without it. Readers start in the thread's working directory, so `cwd` must be that checkout; any other stops the pass before a reader is sent. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 swaps `logStart` for `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/docs/review-policy.md b/docs/review-policy.md index c18bd7d15..e1a28b54d 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -530,7 +530,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: line and gives the command only as the way to get it; a `printed:` label before the line is still read, and its tree and toplevel are still compared exactly (GST-776). Capy cannot start a subagent in a chosen directory, so before it takes a start token or sends a reader, the Capy pass program compares the checkout with the one shared readers start - in (the thread's working directory) and stops on a mismatch. Step 11's voices take the `adversarial-review` start + in (the thread's working directory) and stops when that is another checkout; a start directory outside any checkout, + such as a multi-repo workspace root, holds no tree to review by mistake and dispatches. Step 11's voices take the `adversarial-review` start token by default, and a `logStart` list without it stops before anything runs. ## Where this is enforced diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index c751f0448..9c9186cfd 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -43,11 +43,12 @@ if (pr.code !== 0) return stop('gstack-review-dispatch pass exited ' + pr.code + const passOut = pr.out.trimEnd().split('\n'); if (passOut[passOut.length - 1].startsWith('PASS: none ')) return finish(passOut); if (input.cwd) { - const top = 'cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pwd -P'; + const top = 'd=$(git rev-parse --show-toplevel 2>/dev/null) && cd "$d" && pwd -P'; const ship = await sh(top); const start = await tools.capy.bash({ command: top, description: 'Ship review pass ' + input.pass + ' reader start checkout', timeout: 300 }); const want = ship.out.trim(), got = (start.output || '').trim(); - if (ship.code !== 0 || start.exit_code !== 0 || want !== got) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + ' (the thread\'s working directory) on this branch'); + if (ship.code !== 0) return stop('cannot read the checkout ' + input.cwd + ' names: ' + ship.out.slice(-300)); + if (start.exit_code === 0 && want !== got) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + ' (the thread\'s working directory) on this branch'); } const resumed = passOut.some((l) => l.startsWith('MANIFEST: ') && l.endsWith(' resumed')); lines.push(...passOut.filter((l) => !resumed || l.startsWith('MANIFEST: '))); diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index ba51a7dba..0a41f3f39 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -9,7 +9,7 @@ "browser-skills/hackernews-frontpage": 371, "canary": 7738, "captain": 6551, - "capy/skill/gstack": 5312, + "capy/skill/gstack": 5315, "careful": 1239, "codex": 9890, "context-restore": 6287, diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 684d9517c..8a6cd991c 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -578,7 +578,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 } // Value: protects=no reader is started where Capy will put it (the shared machine's start checkout) when /ship ships another checkout; fails_when=the program dispatches readers that start in the primary checkout while the pass ships a worktree (GBRA-218's outside voice ignored `cd &&` and reviewed the primary checkout, losing Step 11 outside coverage); // why_new=GBRA-218 on gstack 1.92.25.0; Capy subagents take no working directory; seam=lib/capy-review-pass.js before any log start or create; no_claim=that Capy can start a reader in a chosen directory - test('a shared machine whose start checkout is not the worktree refuses before any start token or reader', async () => { + test('a shared machine whose start checkout is not the worktree refuses before any start token or reader; a start directory outside any checkout dispatches', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); const h = host(dir, env); @@ -592,6 +592,8 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const started = host(wt, env); expect(await runPass(started, { base: 'main', out: path.join(wt, '.state/run/p2'), pass: 'w0p2', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); expect(started.creates.length).toBeGreaterThan(0); + const outside = host(path.dirname(wt), env); + expect(await runPass(outside, { base: 'main', out: path.join(wt, '.state/run/p3'), pass: 'w0p3', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); }, 240_000); // Value: protects=a reader that never left the machine's default checkout cannot hand /ship a review of another tree; fails_when=collect merges a reply whose REVIEWED_TREE names the primary checkout while the pass ships a worktree, or a reply with no such line; From f9ff08110dd4c31d3c1b08f5a42574371cdbcb6b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 13:08:58 +0000 Subject: [PATCH 04/11] fix(ship): the start probe stops on an unreadable start directory, runs once per stage, and logStart is validated; brief wording stays main's (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/review-policy.md | 13 +++++++------ lib/capy-review-pass.js | 18 ++++++++++-------- lib/review-brief.ts | 6 ++++-- test/review-dispatch.test.ts | 16 ++++++---------- test/review-pass.test.ts | 14 ++++++++++++++ 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/docs/review-policy.md b/docs/review-policy.md index e1a28b54d..603122a85 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -526,12 +526,13 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: `COLLECT_WRONG_TREE`, a later commit in the same checkout `COLLECT_TREE_MOVED`; nothing is merged, the reader becomes `failed`, and `verify` reports `NOT_COLLECTED`. An outside voice's tree failure is recorded `unavailable` only when its reply holds no finding. The line catches a reader that - worked elsewhere by accident; it is not proof of reading, since diff text can predict it. The brief asks for the bare - line and gives the command only as the way to get it; a `printed:` label before the line is still read, and its tree - and toplevel are still compared exactly (GST-776). Capy cannot start a subagent in a chosen directory, so before it - takes a start token or sends a reader, the Capy pass program compares the checkout with the one shared readers start - in (the thread's working directory) and stops when that is another checkout; a start directory outside any checkout, - such as a multi-repo workspace root, holds no tree to review by mistake and dispatches. Step 11's voices take the `adversarial-review` start + worked elsewhere by accident; it is not proof of reading, since diff text can predict it. A `printed:` label + before the line (plain, bold or inside its backticks) is still read, and its tree and toplevel are still compared + exactly (GST-776). Capy cannot start a subagent in a chosen directory, so before a stage takes a start token or sends + its first reader, the Capy pass program compares the checkout with the one shared readers start in (the thread's + working directory) and stops when that is another checkout or cannot be read: run /ship in the thread's own checkout. + A start directory outside any checkout, such as a multi-repo workspace root, holds no tree to review by mistake and + dispatches. Step 11's voices take the `adversarial-review` start token by default, and a `logStart` list without it stops before anything runs. ## Where this is enforced diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 9c9186cfd..b68bb719e 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -27,6 +27,7 @@ const finish = (tail) => { }; const stop = (why) => finish(['STOP: ' + why]); const logStart = input.stage === 'voices' ? (input.logStart ?? ['adversarial-review']) : (input.logStart || []); +if (!Array.isArray(logStart) || !logStart.every((s) => typeof s === 'string' && s.trim())) return stop('input.logStart must be a list of gstack-review-log --start arguments, got ' + JSON.stringify(input.logStart) + '; nothing ran'); if (input.stage === 'voices' && !(logStart.length && logStart.every((s) => s.split(' ')[0] === 'adversarial-review'))) return stop('Step 11 (stage "voices") takes the adversarial-review start token, which Step 11.5 binds, so its logStart is ["adversarial-review"] (with any --reservation in input.reservation) or omitted, never ' + JSON.stringify(input.logStart) + '; nothing ran'); const passCmd = [bin, 'pass', '--base', q(input.base), '--out', q(input.out), '--pass', q(input.pass)]; @@ -42,14 +43,6 @@ const pr = await sh(passCmd.join(' ')); if (pr.code !== 0) return stop('gstack-review-dispatch pass exited ' + pr.code + ': ' + pr.out.slice(-1500)); const passOut = pr.out.trimEnd().split('\n'); if (passOut[passOut.length - 1].startsWith('PASS: none ')) return finish(passOut); -if (input.cwd) { - const top = 'd=$(git rev-parse --show-toplevel 2>/dev/null) && cd "$d" && pwd -P'; - const ship = await sh(top); - const start = await tools.capy.bash({ command: top, description: 'Ship review pass ' + input.pass + ' reader start checkout', timeout: 300 }); - const want = ship.out.trim(), got = (start.output || '').trim(); - if (ship.code !== 0) return stop('cannot read the checkout ' + input.cwd + ' names: ' + ship.out.slice(-300)); - if (start.exit_code === 0 && want !== got) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + ' (the thread\'s working directory) on this branch'); -} const resumed = passOut.some((l) => l.startsWith('MANIFEST: ') && l.endsWith(' resumed')); lines.push(...passOut.filter((l) => !resumed || l.startsWith('MANIFEST: '))); const mf = input.out.replace(/\/+$/, '') + '/pass-manifest.json'; @@ -92,6 +85,15 @@ const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', try { let m = await load(); if (input.stage === 'voices' && !m.readers.some((r) => r.stage === 'voices')) return finish(['VOICES: none (the pass plan lists no Step 11 voice)', 'NEXT: judge']); + if (input.cwd && !m.readers.some((r) => active.includes(r.stage) && !['planned', 'rendered'].includes(r.state))) { + const top = 'if d=$(git rev-parse --show-toplevel 2>&1); then cd "$d" && pwd -P; else case "$d" in *"not a git repository"*) echo NOT_A_CHECKOUT ;; *) echo "$d"; exit 1 ;; esac; fi'; + const ship = await sh(top); + const start = await tools.capy.bash({ command: top, description: 'Ship review pass ' + input.pass + ' reader start checkout', timeout: 300 }); + const want = ship.out.trim(), got = (start.output || '').trim(); + if (ship.code !== 0 || want === 'NOT_A_CHECKOUT') return stop('cannot read the checkout input.cwd names (' + input.cwd + '): ' + want.slice(-300)); + if (start.exit_code !== 0) return stop('cannot read the directory readers on the shared machine start in: ' + got.slice(-300) + '; no start token was taken and no reader was dispatched'); + if (got !== 'NOT_A_CHECKOUT' && got !== want) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + " (the thread's working directory) on this branch, with a fresh --out"); + } for (const s of logStart) { const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s; if ((m.log_starts || {})[key]) continue; diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 093f37c4b..428f2efbf 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -142,7 +142,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { */ export function workspaceParagraph(dir: string): string { const q = `'${dir.replace(/'/g, `'\\''`)}'`; - return `Workspace: the checkout under review is ${dir}. Your shell and file tools may start in another checkout, and every command runs in a fresh shell: begin each command with \`cd ${q} && \`, and give every file read, search, edit or write an absolute path under ${dir}. Start your final reply with the bare line \`REVIEWED_TREE: \`, even where this brief limits your output to findings: nothing before it, not the command and not a label. Get the line by running \`cd ${q} && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"\` and copy what it prints. A reply without that line, or from another checkout, does not count.`; + return `Workspace: the checkout under review is ${dir}. Your shell and file tools may start in another checkout, and every command runs in a fresh shell: begin each command with \`cd ${q} && \`, and give every file read, search, edit or write an absolute path under ${dir}. Make the first line of your final reply the line \`cd ${q} && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"\` prints, even where this brief limits your output to findings; a reply without it, or from another checkout, does not count.`; } export const withWorkspace = (text: string, dir: string) => `${text}\n\n${workspaceParagraph(dir)}`; /** The checkout a brief names: the toplevel of the directory the bins run in, where its diff and merge base come from. */ @@ -156,8 +156,10 @@ export function checkoutRoot(): string { * backtick wrapper, a bold label and backticks around the sha or the whole path are removed; the path's own characters are kept. */ function parseTreeLine(line: string): string | null { - let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, '').replace(/^[Pp]rinted:\s+/, ''); + const label = /^(?:\*\*)?[Pp]rinted:(?:\*\*)?\s+/; + let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, '').replace(label, ''); for (const w of ['**', '`']) if (t.length > 2 * w.length && t.startsWith(w) && t.endsWith(w)) t = t.slice(w.length, -w.length).trim(); + t = t.replace(label, ''); const m = /^(?:\*\*REVIEWED_TREE:\*\*|REVIEWED_TREE:)\s+`?([0-9a-f]{40,64})`?\s+(.+)$/.exec(t); if (!m) return null; const p = m[2]!.length > 2 && m[2]!.startsWith('`') && m[2]!.endsWith('`') ? m[2]!.slice(1, -1) : m[2]!; diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index 72d0d2580..de81db952 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -585,22 +585,18 @@ describe('the REVIEWED_TREE line as GBRA-218 readers wrote it (GST-776)', () => const top = fs.realpathSync(spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim()); const tree = spawnSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); const command = `cd '${top}' && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"`; - // Value: protects=a reader that reviewed the right checkout is not failed for how it wrote its fingerprint; fails_when=the brief asks for "the line prints" (two Sonnet 5.5 low readers then wrote `printed: REVIEWED_TREE: …` and were failed as reported="none"), or a `printed:` prefix lets a wrong tree through; - // why_new=GBRA-218 pass restarts on gstack 1.92.25.0; seam=lib/review-brief.ts workspaceParagraph and checkReviewedTree via gstack-review-dispatch collect; no_claim=that every model now writes the bare line - test('the brief asks for the bare line first and gives the command only as the way to get it', () => { - const p = workspaceParagraph(top); - expect(p).not.toContain('the line `cd'); - const bare = p.indexOf('`REVIEWED_TREE: `'); - expect(bare).toBeGreaterThan(0); - expect(p.indexOf(command)).toBeGreaterThan(bare); - }); - + // Value: protects=a reader that reviewed the right checkout is not failed for how it wrote its fingerprint; fails_when=a reply that echoes the command and then writes `printed: REVIEWED_TREE: …` (two Sonnet 5.5 low readers) is failed as reported="none", or a `printed:` label lets a wrong tree, toplevel or other label through; + // why_new=GBRA-218 pass restarts on gstack 1.92.25.0; seam=lib/review-brief.ts checkReviewedTree via gstack-review-dispatch collect; no_claim=the brief's wording, which stays main's until a no-loss replay test('the echoed command then a printed: line is read; the same shape with a wrong tree or checkout is still refused', () => { const reply = (fingerprint: string) => `${command}\nprinted: REVIEWED_TREE: ${fingerprint}\nNO FINDINGS\n`; expect(checkReviewedTree(reply(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); expect(checkReviewedTree(reply(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true, reported: `${'b'.repeat(40)} ${top}` }); expect(checkReviewedTree(reply(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false, reported: `${tree} /elsewhere` }); expect(checkReviewedTree(`Note: REVIEWED_TREE: ${tree} ${top}\nNO FINDINGS`, `${tree} ${top}`).reported).toBe('none'); + for (const line of ['`printed: REVIEWED_TREE: ' + tree + ' ' + top + '`', `**Printed:** REVIEWED_TREE: ${tree} ${top}`]) { + expect(checkReviewedTree(line, `${tree} ${top}`).ok, line).toBe(true); + expect(checkReviewedTree(line.replace(tree, 'c'.repeat(40)), `${tree} ${top}`).ok, line).toBe(false); + } const out = tmp(); const ledger = path.join(out, 'review-ledger.jsonl'); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 8a6cd991c..5d3fcb9b4 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -594,6 +594,20 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 expect(started.creates.length).toBeGreaterThan(0); const outside = host(path.dirname(wt), env); expect(await runPass(outside, { base: 'main', out: path.join(wt, '.state/run/p3'), pass: 'w0p3', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); + const broken = tmp('rp-broken-'); + fs.writeFileSync(path.join(broken, '.git'), 'garbage\n'); + const unreadable = host(broken, env); + expect(await runPass(unreadable, { base: 'main', out: path.join(wt, '.state/run/p4'), pass: 'w0p4', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('STOP: cannot read the directory readers on the shared machine start in: fatal: invalid gitfile format'); + expect(unreadable.creates.length).toBe(0); + const slow = host(wt, env, { pollsToFinish: 30 }); + let probes = 0; + const bash = slow.tools.capy.bash; + slow.tools.capy.bash = async (a) => { if (a.command.startsWith('if d=$(git rev-parse')) probes++; return bash(a); }; + const input = { base: 'main', out: path.join(wt, '.state/run/p5'), pass: 'w0p5', cwd: wt, planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(slow, input)).toContain('NEXT: poll'); + expect(await runPass(slow, input)).toContain('NEXT: poll'); + expect(probes).toBe(1); + expect(await runPass(host(wt, env), { ...input, logStart: [7] })).toStartWith('STOP: input.logStart must be a list'); }, 240_000); // Value: protects=a reader that never left the machine's default checkout cannot hand /ship a review of another tree; fails_when=collect merges a reply whose REVIEWED_TREE names the primary checkout while the pass ships a worktree, or a reply with no such line; From d2c69984d5e8f65152de8473277c1365598f4b60 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 13:12:21 +0000 Subject: [PATCH 05/11] docs(changelog): GST-776 review dispatch fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/capy-gst-776-review-dispatch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/capy-gst-776-review-dispatch.md diff --git a/changelog.d/capy-gst-776-review-dispatch.md b/changelog.d/capy-gst-776-review-dispatch.md new file mode 100644 index 000000000..51f7ff1c8 --- /dev/null +++ b/changelog.d/capy-gst-776-review-dispatch.md @@ -0,0 +1,5 @@ +### Fixed + +- **A reviewer that wrote `printed: REVIEWED_TREE: …` is no longer failed.** Two readers in GBRA-218 echoed the command and then wrote the fingerprint after a `printed:` label, so /ship read no line, failed them and restarted the pass twice. The label is now read, plain, bold or inside the line's backticks, and the tree and checkout it names are still compared exactly. A wrong tree or checkout is still refused. +- **The Capy pass program stops before it sends a reader into the wrong checkout.** Capy starts every shared reader in the thread's working directory and cannot start one anywhere else. In GBRA-218 an outside voice ignored its `cd` and reviewed that checkout, so Step 11 lost its outside coverage. Now, before a stage takes a start token or sends its first reader, the program compares that directory with the checkout /ship ships. It stops when they differ or the directory can't be read. **Run /ship in the thread's own checkout**, not in a worktree beside it. A start directory outside any checkout, such as a multi-repo workspace root, still dispatches. +- **Step 11 takes the right start token.** Reusing the pass input's `logStart: ["review"]` for `stage: "voices"` used to run the voices with no `adversarial-review` start, which left Step 11.5 nothing to bind. Step 11 now takes `adversarial-review` by default and stops before anything runs when given any other list. The gstack Capy skill says Step 11 swaps `logStart` for `stage: "voices"`. From 29ad9cbd2208ca430d4b6d1076ab25e9fc1f0215 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 13:12:52 +0000 Subject: [PATCH 06/11] fix(review): a wholly bold printed: REVIEWED_TREE line is read (GST-776 fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/review-brief.ts | 2 +- test/review-dispatch.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 428f2efbf..86fc7ea6a 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -156,7 +156,7 @@ export function checkoutRoot(): string { * backtick wrapper, a bold label and backticks around the sha or the whole path are removed; the path's own characters are kept. */ function parseTreeLine(line: string): string | null { - const label = /^(?:\*\*)?[Pp]rinted:(?:\*\*)?\s+/; + const label = /^(?:\*\*[Pp]rinted:\*\*|[Pp]rinted:)\s+/; let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, '').replace(label, ''); for (const w of ['**', '`']) if (t.length > 2 * w.length && t.startsWith(w) && t.endsWith(w)) t = t.slice(w.length, -w.length).trim(); t = t.replace(label, ''); diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index de81db952..2ffe37c02 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -593,7 +593,7 @@ describe('the REVIEWED_TREE line as GBRA-218 readers wrote it (GST-776)', () => expect(checkReviewedTree(reply(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true, reported: `${'b'.repeat(40)} ${top}` }); expect(checkReviewedTree(reply(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false, reported: `${tree} /elsewhere` }); expect(checkReviewedTree(`Note: REVIEWED_TREE: ${tree} ${top}\nNO FINDINGS`, `${tree} ${top}`).reported).toBe('none'); - for (const line of ['`printed: REVIEWED_TREE: ' + tree + ' ' + top + '`', `**Printed:** REVIEWED_TREE: ${tree} ${top}`]) { + for (const line of ['`printed: REVIEWED_TREE: ' + tree + ' ' + top + '`', `**Printed:** REVIEWED_TREE: ${tree} ${top}`, `**printed: REVIEWED_TREE: ${tree} ${top}**`]) { expect(checkReviewedTree(line, `${tree} ${top}`).ok, line).toBe(true); expect(checkReviewedTree(line.replace(tree, 'c'.repeat(40)), `${tree} ${top}`).ok, line).toBe(false); } From 9e9b20fe58283f29d0e3495bd28ffb470bf4366b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 13:26:32 +0000 Subject: [PATCH 07/11] fix(review): the GBRA-218 one-line shape (echoed command, then printed: REVIEWED_TREE) is read; a wrong tree is still refused (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/review-brief.ts | 7 +++++-- test/review-dispatch.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 86fc7ea6a..d7399a999 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -152,12 +152,15 @@ export function checkoutRoot(): string { } /** - * One REVIEWED_TREE line as " ", or null. A list marker, heading mark or `printed:` label (GST-776) may lead it, and a whole-line `**…**` or + * One REVIEWED_TREE line as " ", or null. A list marker, heading mark or `printed:` label (GST-776, also after the echoed `cd … && echo …` command on the same line) may lead it, and a whole-line `**…**` or * backtick wrapper, a bold label and backticks around the sha or the whole path are removed; the path's own characters are kept. */ function parseTreeLine(line: string): string | null { const label = /^(?:\*\*[Pp]rinted:\*\*|[Pp]rinted:)\s+/; - let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, '').replace(label, ''); + let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, ''); + const echoed = /\s(?:\*\*[Pp]rinted:\*\*|[Pp]rinted:)\s+(?=\S)/.exec(t); + if (echoed && t.startsWith('cd ')) t = t.slice(echoed.index + 1); + t = t.replace(label, ''); for (const w of ['**', '`']) if (t.length > 2 * w.length && t.startsWith(w) && t.endsWith(w)) t = t.slice(w.length, -w.length).trim(); t = t.replace(label, ''); const m = /^(?:\*\*REVIEWED_TREE:\*\*|REVIEWED_TREE:)\s+`?([0-9a-f]{40,64})`?\s+(.+)$/.exec(t); diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index 2ffe37c02..cdb238b7b 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -589,6 +589,11 @@ describe('the REVIEWED_TREE line as GBRA-218 readers wrote it (GST-776)', () => // why_new=GBRA-218 pass restarts on gstack 1.92.25.0; seam=lib/review-brief.ts checkReviewedTree via gstack-review-dispatch collect; no_claim=the brief's wording, which stays main's until a no-loss replay test('the echoed command then a printed: line is read; the same shape with a wrong tree or checkout is still refused', () => { const reply = (fingerprint: string) => `${command}\nprinted: REVIEWED_TREE: ${fingerprint}\nNO FINDINGS\n`; + const oneLine = (fingerprint: string) => `${command} printed: REVIEWED_TREE: ${fingerprint}\n\nNO FINDINGS\n`; + expect(checkReviewedTree(oneLine(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); + expect(checkReviewedTree(oneLine(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true }); + expect(checkReviewedTree(oneLine(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false }); + expect(checkReviewedTree(`${command} Note: REVIEWED_TREE: ${tree} ${top}`, `${tree} ${top}`).reported).toBe('none'); expect(checkReviewedTree(reply(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); expect(checkReviewedTree(reply(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true, reported: `${'b'.repeat(40)} ${top}` }); expect(checkReviewedTree(reply(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false, reported: `${tree} /elsewhere` }); From 320d64e9516463046325d83f558d52272e29f8e1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 13:47:08 +0000 Subject: [PATCH 08/11] fix(ship): the logStart STOP text no longer names gstack-review-log --start ahead of the pass call (reader-drift order test, GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/capy-review-pass.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index b68bb719e..a6ccf8457 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -27,7 +27,7 @@ const finish = (tail) => { }; const stop = (why) => finish(['STOP: ' + why]); const logStart = input.stage === 'voices' ? (input.logStart ?? ['adversarial-review']) : (input.logStart || []); -if (!Array.isArray(logStart) || !logStart.every((s) => typeof s === 'string' && s.trim())) return stop('input.logStart must be a list of gstack-review-log --start arguments, got ' + JSON.stringify(input.logStart) + '; nothing ran'); +if (!Array.isArray(logStart) || !logStart.every((s) => typeof s === 'string' && s.trim())) return stop('input.logStart must be a list of review-log start skills (strings), got ' + JSON.stringify(input.logStart) + '; nothing ran'); if (input.stage === 'voices' && !(logStart.length && logStart.every((s) => s.split(' ')[0] === 'adversarial-review'))) return stop('Step 11 (stage "voices") takes the adversarial-review start token, which Step 11.5 binds, so its logStart is ["adversarial-review"] (with any --reservation in input.reservation) or omitted, never ' + JSON.stringify(input.logStart) + '; nothing ran'); const passCmd = [bin, 'pass', '--base', q(input.base), '--out', q(input.out), '--pass', q(input.pass)]; From 61a9051b4ac1165d36e1a0aa2266ecc10674725a Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 14:35:37 +0000 Subject: [PATCH 09/11] test(budget): capy/skill/gstack ceiling 5316 after the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/fixtures/context-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index 0a41f3f39..3f5ec972e 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -9,7 +9,7 @@ "browser-skills/hackernews-frontpage": 371, "canary": 7738, "captain": 6551, - "capy/skill/gstack": 5315, + "capy/skill/gstack": 5316, "careful": 1239, "codex": 9890, "context-restore": 6287, From dd1eb62a96d8fa1f5b8f164c0c1f14d7e3e31b10 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 14:36:19 +0000 Subject: [PATCH 10/11] Revert "test(budget): capy/skill/gstack ceiling 5316 after the main merge" This reverts commit 61a9051b4ac1165d36e1a0aa2266ecc10674725a. --- test/fixtures/context-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index 3f5ec972e..0a41f3f39 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -9,7 +9,7 @@ "browser-skills/hackernews-frontpage": 371, "canary": 7738, "captain": 6551, - "capy/skill/gstack": 5316, + "capy/skill/gstack": 5315, "careful": 1239, "codex": 9890, "context-restore": 6287, From 38f1619a809d2233ce7355aac4d7e2c0ea843f46 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 14:37:55 +0000 Subject: [PATCH 11/11] docs(capy): trim the /ship paragraph to stay under the 5315 context ceiling after the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- capy/skill/gstack/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index f5bf07f48..8e52ceb71 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 swaps `logStart` for `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 swaps `logStart` for `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your transcript since /ship began. A small waiting child saves nothing. ## Plan, approve, ship