From a00149e86eddf0f5f5387185c9214ef285a468b9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 03:45:58 -0800 Subject: [PATCH] v1.92.73.0 fix(review): read printed: fingerprint lines, stop on a foreign start checkout, give Step 11 its start token (GST-776) (#3798) * 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> * 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> * 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> * 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(changelog): GST-776 review dispatch fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * 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> * 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> * 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> * Revert "test(budget): capy/skill/gstack ceiling 5316 after the main merge" This reverts commit 61a9051b4ac1165d36e1a0aa2266ecc10674725a. * 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> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- capy/skill/gstack/SKILL.md | 2 +- docs/review-policy.md | 9 +++- lib/capy-review-pass.js | 14 +++++- lib/review-brief.ts | 7 ++- package.json | 2 +- test/fixtures/context-budget.json | 2 +- test/review-dispatch.test.ts | 39 ++++++++++++++++ test/review-pass.test.ts | 76 ++++++++++++++++++++++++++----- 11 files changed, 144 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237f5c19e..0d90faced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.92.73.0] - 2026-11-02 + +### 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"`. + ## [1.92.72.0] - 2026-11-02 **The sealed guard can now refuse a published record that puts a label next to an identifier.** gstack-evals hides which test units are decoys. Its /ship on garrytan/gstack-evals#99 published a panel where each unit's `stratum`, `tags` and `decoy_set` sat next to its `head`, `tree_sha256`, `plan_bytes` and `files`, values a model under test can compute from its own copy of the work. A model could look itself up and learn it was the decoy. No answer leaked, so `gstack-pregate sealed` said `clear` (GSTA-2, GST-620). The new rule is opt-in. A repo that sets `sealed.label_keys` gets it, and every other repo sees the same output as before, byte for byte. diff --git a/VERSION b/VERSION index 8d5fb4735..4633cc70e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.72.0 +1.92.73.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 5c5850c4e..a86825dff 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.72.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.73.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/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index bb617b0c0..1e3263109 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; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `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 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, pinned in `voice-models.json`); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 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" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else 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. +/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, pinned in `voice-models.json`); Step 11 swaps `logStart` for `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 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" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else 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 diff --git a/docs/review-policy.md b/docs/review-policy.md index 2cf6a453b..c8238d7d6 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -593,7 +593,14 @@ 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. 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 9511695e9..886976f34 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -30,6 +30,9 @@ const finish = (tail) => { return all.slice(0, -1).join('\n').slice(0, Math.max(0, RETURN_BYTES - last.length)) + last; }; 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 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 runDir = input.out.replace(/\/+$/, '').replace(/\/[^/]+$/, ''); const nextPhase = input.stage === 'voices' ? 5 : input.step === '14.5' ? 7 : 4; const withParts = async (tail) => { @@ -133,8 +136,17 @@ try { if (voices.laneModel) laneFamily = family(voices.laneModel); } 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"); + } const seen = {}; - for (const s of input.logStart || []) { + for (const s of logStart) { const nth = (seen[s] = (seen[s] || 0) + 1); const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s + (nth > 1 ? '#' + nth : ''); if ((m.log_starts || {})[key]) continue; diff --git a/lib/review-brief.ts b/lib/review-brief.ts index b233aa40b..30a9565f3 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -172,12 +172,17 @@ 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, 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+)/, ''); + 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); 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/package.json b/package.json index 2a2d10ae8..ad4e3a294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.72", + "version": "1.92.73", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index 5f0d85790..619885589 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-dispatch.test.ts b/test/review-dispatch.test.ts index f6874e886..d0c01e345 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -794,3 +794,42 @@ 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=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`; + 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` }); + 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}`, `**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'); + 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 '); + }); +}); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index dc367ddc0..10ffa4f8d 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -332,6 +332,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'); @@ -630,11 +651,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, 'plan.prompt.md'), 'Plan completion 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: 'plan', prompt_file: path.join(out, 'plan.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] }); expect(res).not.toContain('STOP:'); @@ -914,12 +934,46 @@ 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; 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); + 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); + 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; // 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()}"`); @@ -934,20 +988,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:'); @@ -962,7 +1016,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'); @@ -973,7 +1027,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); @@ -987,7 +1041,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); @@ -996,7 +1050,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); } }); @@ -1008,7 +1062,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' } };