From 7e9d544680b5197cefa1a5e558e9e50072974e41 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 02:56:26 +0000 Subject: [PATCH] fix(ship): a scoped pass keeps the merge base for its pending Step 11 voices (GST-619 model; #3670 merge fix-check) The driver passed diffBase: RANGE_BASE on scoped passes too, and --diff-base won in pass(), so the voices read only the fix range. pass() now ignores --diff-base on a scoped step, and the driver and Capy skill name diffBase for full repeat passes only. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-review-dispatch | 2 +- capy/skill/gstack/SKILL.md | 2 +- changelog.d/gsta-167-calls-3.md | 2 +- ship/DRIVER.md | 2 +- ship/DRIVER.md.tmpl | 2 +- test/review-pass.test.ts | 20 ++++++++++++++++++++ test/ship-driver.test.ts | 2 +- 7 files changed, 26 insertions(+), 6 deletions(-) diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index bdd42baa7..c07bb2985 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -188,7 +188,7 @@ function pass(): number { const rangeBase = step === 'scoped' ? git(['rev-parse', '--verify', '--quiet', `${p.fix_range?.base ?? ''}^{commit}`])?.trim() : undefined; if (step === 'scoped' && !rangeBase) throw new BriefError('a scoped plan names no readable fix range base'); const mergeBase = forkPoint(base); - const diffBase = flag('--diff-base') ?? (planArgs.includes('--adversarial-ran') ? rangeBase : undefined) ?? mergeBase; + const diffBase = (step === 'scoped' ? undefined : flag('--diff-base')) ?? (planArgs.includes('--adversarial-ran') ? rangeBase : undefined) ?? mergeBase; if (!diffBase) throw new BriefError(`no merge base between ${base} and HEAD`); const root = git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd(); const extrasFile = flag('--extras'); diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 58f747108..12e9d01a2 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 adds `stage: "voices"`; a 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 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 adds `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. ## Plan, approve, ship diff --git a/changelog.d/gsta-167-calls-3.md b/changelog.d/gsta-167-calls-3.md index e4c06b574..65daa9801 100644 --- a/changelog.d/gsta-167-calls-3.md +++ b/changelog.d/gsta-167-calls-3.md @@ -13,4 +13,4 @@ - **Step 11 records a start for each row it logs.** Two `logStart` entries for one skill (the native and outside `adversarial-review` rows) now each take their own `gstack-review-log --start` token. Before, the second was dropped as a repeat. - **The pass finds its fork point against origin/, as preflight does.** With no local `main`, or one behind origin/main, `gstack-review-dispatch pass` used to stop on "no merge base" or review from the stale branch. It now tries `origin/` first, the way `render` and preflight already do (GSTA-198 replay). - **A section return that never reached the parent can be fetched again.** `gstack-ship-parts next` now marks what it returns as sent, the next call promotes it, and `next --resend` (the pass program's `resendParts`) sends it again. Its `PARTS:` and `NEXT: parts` lines count against `--room`, the run directory in the `NEXT: parts` command is shell-quoted, a corrupt `state.json` stops with its path, and Step 11's sections are due by Phase 4, where Step 11 runs. The pass program keeps its last `NEXT:` line when its return is capped and prints `PARTS: skipped (…)` when it finds no parts state. Before, a lost or capped return dropped section text or the next step without a trace (PR #3670 review). -- **A scoped repeat pass leaves its fix-check to Step 3.5.** After main's GST-619, a scoped pass stages its readers on the fix range and names its fix-check outside the manifest; the pass program takes `fixCheck` files only on a `fix-check` step. +- **A scoped repeat pass leaves its fix-check to Step 3.5.** After main's GST-619, a scoped pass stages its readers on the fix range and names its fix-check outside the manifest; the pass program takes `fixCheck` files only on a `fix-check` step, and on a scoped step it ignores `--diff-base`, so the pending Step 11 voices keep the merge base and only the Red Team reads the fix range. diff --git a/ship/DRIVER.md b/ship/DRIVER.md index effae65f4..1c798b20d 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -87,7 +87,7 @@ Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core 9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included; `JUDGMENT: ok` before any fix), fixes and persistence in one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or -`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase`: RANGE_BASE; a scoped fix-check runs as Step 3.5); `fix-check`: the +`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the pass (above) with `fixCheck: {fix_list, hunks, tests, callers}` files; `done`: the same program runs Step 11 from `sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither prints `ADVERSARIAL: skipped (review plan)`). diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index 40f1150c0..2286f5de9 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -85,7 +85,7 @@ Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core 9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included; `JUDGMENT: ok` before any fix), fixes and persistence in one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or -`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase`: RANGE_BASE; a scoped fix-check runs as Step 3.5); `fix-check`: the +`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the pass (above) with `fixCheck: {fix_list, hunks, tests, callers}` files; `done`: the same program runs Step 11 from `sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither prints `ADVERSARIAL: skipped (review plan)`). diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 6592e2e4f..06435f7e5 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -591,6 +591,26 @@ describe('Capy pass program (fake host, real bins)', () => { expect(h.creates.map(c => c.title)).toContain('ship x22s1 red-team'); }, 240_000); + // Value: protects=a scoped pass run the old way, with the Capy driver's diffBase set to the fix range, still gives the pending Step 11 voices the merge base and only the Red Team the range (GST-619); fails_when=--diff-base on a scoped step overrides the plan's bases and narrows the voices to the fix range; + // why_new=#3670 merge fix-check (row 454): the driver's diffBase reached the voices on a scoped pass; seam=none; no_claim=a scoped pass after Step 11 ran, which reads the range throughout + test('a scoped pass given diffBase keeps the merge base for the Step 11 voices; only the Red Team reads the fix range', async () => { + const { dir, env } = repo(); + const sh = (c: string) => spawnSync('bash', ['-c', c], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }); + const mergeBase = sh('git merge-base main HEAD').stdout.trim(); + const rangeBase = sh('git rev-parse HEAD').stdout.trim(); + expect(sh('echo "export const fix = 2;" >> src/a.ts && git commit -qam fix').status).toBe(0); + const out = path.join(dir, '.state/run/s2'); + const planFlags = ['--pass', '1', '--last', 'full', '--fixed-blocking', '--pass1-depth', 'standard', '--range-base', rangeBase]; + const res = await runPass(host(dir, env), { base: 'main', out, pass: 'x22s2', diffBase: rangeBase, planFlags }); + expect(res).toContain('next=scoped'); + const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8')) as { diff_base: string; readers: { role?: string; stage: string; diff_base?: string }[] }; + expect(m.diff_base).toBe(mergeBase); + expect(m.readers.find(r => r.role === 'red-team')!.diff_base).toBe(rangeBase); + const voices = m.readers.filter(r => r.stage === 'voices'); + expect(voices.length).toBeGreaterThan(0); + for (const v of voices) expect(v.diff_base ?? m.diff_base).toBe(mergeBase); + }, 240_000); + // Value: protects=the program's final NEXT line survives a return that its reader outputs push to the 32 KB cap, and a pass whose out is not / says it skipped the parts; fails_when=finish() cuts the tail line, the room ignores the parts trailer, or parts are skipped without a line; // why_new=#3670 Step 11 (outside voice 2, adversarial 2 and 6); seam=none; no_claim=what the parent does with a capped return test('a near-cap return still ends with its NEXT line, and an odd out prints PARTS: skipped', async () => { diff --git a/test/ship-driver.test.ts b/test/ship-driver.test.ts index 7de99809b..5d39b35b3 100644 --- a/test/ship-driver.test.ts +++ b/test/ship-driver.test.ts @@ -194,7 +194,7 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST test('returns stay at or under 32 KB, a fix is one gated program, Step 7 settles first, and unattended push defers the pre-push offer', () => { const f = flat(doc); for (const s of ['Returns: ≤32 KB per program; split longer reads at headings, each byte once, before first use', - 'one program runs the failing test, fix, `--tests-only` and Phase 3, each gated', 'RANGE_BASE; a scoped fix-check runs as Step 3.5', 'Step 7 may write tests, so it settles first', + 'one program runs the failing test, fix, `--tests-only` and Phase 3, each gated', '(`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5)', 'Step 7 may write tests, so it settles first', 'Unattended, push defers the pre-push offer; else its STOP runs Step 17\'s guard block', 'plus a keep-alive per 200 s waited']) expect(f).toContain(s); expect(flat(doc.slice(doc.indexOf('**Budget: observe.**')))).toContain('--reading R --credits C --record '); });