From abd7b1f8bd13f73865fd0cd53fc18086f34ccdd1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 18:51:27 +0000 Subject: [PATCH] ship: each phase program returns the next phase's sections once, in code (GSTA-167 calls PR 3) Rebased onto main 2d1b55d76 (GST-482) as one commit. Drops the pass program's cwd STOP and its test: GST-482's per-reader REVIEWED_TREE check replaces it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-review-dispatch | 41 +++++-- bin/gstack-ship-budget | 2 +- bin/gstack-ship-parts | 10 ++ capy/skill/gstack/SKILL.md | 8 +- changelog.d/gsta-167-calls-3.md | 16 +++ docs/review-policy.md | 6 +- docs/ship-budget.md | 2 +- lib/capy-review-pass.js | 46 ++++++-- lib/review-pass.ts | 20 +++- lib/ship-budget-cli.ts | 8 +- lib/ship-parts.ts | 149 +++++++++++++++++++++++++ ship/DRIVER.md | 54 ++++----- ship/DRIVER.md.tmpl | 54 ++++----- test/review-pass.test.ts | 192 +++++++++++++++++++++++++++++++- test/ship-calls.test.ts | 8 ++ test/ship-driver.test.ts | 2 +- test/ship-parts.test.ts | 126 +++++++++++++++++++++ 17 files changed, 651 insertions(+), 93 deletions(-) create mode 100755 bin/gstack-ship-parts create mode 100644 changelog.d/gsta-167-calls-3.md create mode 100644 lib/ship-parts.ts create mode 100644 test/ship-parts.test.ts diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index e426f442a..09a30075e 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -59,12 +59,14 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads - pass --base --out --pass [--ledger ] [--diff-base ] [--learnings-file ] [--extras ] - [--checklist-root ] [-- ] + pass --base --out --pass [--ledger ] [--diff-base ] [--learnings-file ] [--extras ] [--audit-only] + [--checklist-root ] [--fix-list --hunks --tests [--callers ]] [-- ] runs gstack-review-plan and writes /pass-manifest.json: stage first (audit readers marked first in --extras), specialists (with the other --extras readers), red-team, voices (Step 11). Renders first and specialists now; prints - the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full or scoped prints PASS: none. + the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full, scoped or fix-check prints PASS: none. + A fix-check reader (stage specialists) renders from decision 3.5's --fix-list, --hunks, --tests and --callers files. An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}] + --audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit) show --manifest MANIFEST_JSON: stage --manifest --stage renders a stage; the Red Team only after every earlier reader is collected, its findings file being the @@ -73,7 +75,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free BRIEF_CHUNK: reader=… index=i of=n sha256=…, then one JSON string line of at most 65,536 brief characters mark --manifest --reader --state [--path

] [--output ] [--error ] an audit reader is collected with --output, whose REVIEWED_TREE line must match its render (else failed, COLLECT_WRONG_TREE) - mark --manifest --before , + mark --manifest --before , [--voices] the pass's reading at its first dispatch; --voices keeps Step 11's own mark --manifest --log-start --token records a review-log start token once, so a rerun starts only what is missing reader states move forward only; intent is recorded before the subagent is created, its path right after program [--ledger ] @@ -139,6 +141,9 @@ const PROGRAM_FILE = path.join(ROOT, 'lib', 'capy-review-pass.js'); const PROGRAM_VERSION = 1; const git = (a: string[]) => { const r = spawnSync('git', a, { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; const readerLine = (m: Manifest, r: PassReader) => `READER: ${r.reader} stage=${r.stage} state=${r.state} title="${r.title}"${r.sha256 ? ` sha256=${r.sha256} bytes=${r.bytes} chunks=${r.chunks}` : ''}${r.path ? ` path=${r.path}` : ''}`; +/** The fork point the way preflight and render find it: origin/ first, so a missing or stale local branch never decides it. */ +const forkPoint = (base: string) => [`origin/${base.replace(/^origin\//, '')}`, base].map(ref => git(['merge-base', ref, 'HEAD'])?.trim()).find(Boolean); + const branchDiff = (base: string) => { const d = git(['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', base]); if (d === null) throw new BriefError(`git diff ${base} failed`); @@ -156,6 +161,20 @@ function pass(): number { return EXIT.ok; } const planArgs = args.includes('--') ? args.slice(args.indexOf('--') + 1) : []; + if (args.includes('--audit-only')) { + const extrasFile = flag('--extras'); + if (!extrasFile) { process.stderr.write('gstack-review-dispatch pass --audit-only: needs --extras (the audit readers, e.g. Step 14.5\'s docs reader)\n'); return EXIT.usage; } + const extras = JSON.parse(readFile(extrasFile, 'extras')!) as Extra[]; + const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase: forkPoint(base) ?? 'HEAD', root: git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd(), + checklistRoot: flag('--checklist-root') ?? ROOT, dispatches: [], next: 'audit', planLines: ['PASS: audit-only (no reviewer plan; its extras run as one batch)'], extras }); + fs.mkdirSync(m.out, { recursive: true, mode: 0o700 }); + const t = sectionTexts(ROOT); + renderStage(m, 'first', t, branchDiff); + renderStage(m, 'specialists', t, branchDiff); + writeManifest(file, m); + process.stdout.write([...m.plan_lines, `MANIFEST: file=${file} pass=${id} readers=${m.readers.length} audit-only`, ...m.readers.map(r => readerLine(m, r))].join('\n') + '\n'); + return EXIT.ok; + } const plan = (json: boolean) => runScriptSync(path.join(ROOT, 'bin', 'gstack-review-plan'), ['--base', base, ...planArgs, ...(json ? ['--json'] : [])], { encoding: 'utf8', timeout: 180_000, maxBuffer: 64 * 1024 * 1024 }); const text = plan(false); if (text.status !== 0) { process.stdout.write(text.stdout ?? ''); process.stderr.write(text.stderr ?? ''); return EXIT.fail; } @@ -164,15 +183,16 @@ function pass(): number { const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] } }; const planLines = text.stdout.trimEnd().split('\n'); const step = p.next.step; - if (step !== 'full' && step !== 'scoped') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; } - const diffBase = flag('--diff-base') ?? git(['merge-base', base, 'HEAD'])?.trim(); + if (step !== 'full' && step !== 'scoped' && step !== 'fix-check') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; } + const diffBase = flag('--diff-base') ?? forkPoint(base); 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'); const extras = extrasFile ? JSON.parse(readFile(extrasFile, 'extras')!) as Extra[] : []; const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT, dispatches: p.dispatches, next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras, - targetedClasses: p.targeted?.classes, mergeBase: git(['merge-base', base, 'HEAD'])?.trim() }); + targetedClasses: p.targeted?.classes, mergeBase: forkPoint(base), + fixCheck: flag('--fix-list') || flag('--hunks') || flag('--tests') ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined }); fs.mkdirSync(m.out, { recursive: true, mode: 0o700 }); const t = sectionTexts(ROOT); renderStage(m, 'first', t, branchDiff); @@ -219,10 +239,11 @@ function passStep(cmd: string): number { const before = flag('--before'); if (before) { const [tokens, credits] = before.split(',').map(Number); - if (!Number.isFinite(tokens) || !Number.isFinite(credits)) { process.stderr.write('gstack-review-dispatch mark: --before ,\n'); return EXIT.usage; } - m.before ??= { tokens: tokens!, credits: credits! }; + if (!Number.isFinite(tokens) || !Number.isFinite(credits)) { process.stderr.write('gstack-review-dispatch mark: --before , [--voices]\n'); return EXIT.usage; } + const key = args.includes('--voices') ? 'before_voices' : 'before'; + m[key] ??= { tokens: tokens!, credits: credits! }; writeManifest(file, m); - process.stdout.write(`BEFORE: tokens=${m.before.tokens} credits=${m.before.credits}\n`); + process.stdout.write(`BEFORE: ${key === 'before_voices' ? 'stage=voices ' : ''}tokens=${m[key]!.tokens} credits=${m[key]!.credits}\n`); return EXIT.ok; } const audit = m.readers.find(x => x.reader === flag('--reader')); diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget index fd9c5119f..102bb839a 100755 --- a/bin/gstack-ship-budget +++ b/bin/gstack-ship-budget @@ -38,7 +38,7 @@ const ctx = { env: process.env, cwd: process.cwd() }; const commands: Record Outcome> = { start: () => cmdStart(rest, ctx), note: () => cmdNote(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true), handoff: () => cmdHandoff(rest, ctx), launch: () => cmdLaunch(rest, ctx), 'verify-handoff': () => cmdVerifyHandoff(rest, ctx), return: () => cmdReturn(rest, ctx), - reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest), + reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest, ctx), }; if (!command || command === '--help' || command === '-h' || !commands[command]) { const lines = (await Bun.file(import.meta.path).text()).split('\n'); diff --git a/bin/gstack-ship-parts b/bin/gstack-ship-parts new file mode 100755 index 000000000..07ee7d572 --- /dev/null +++ b/bin/gstack-ship-parts @@ -0,0 +1,10 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-ship-parts — the section text /ship's next phase uses, verbatim, split at headings and + * returned once per run inside the phase program before it (GSTA-167 C5). Logic: lib/ship-parts.ts. + */ +import { shipParts } from '../lib/ship-parts'; + +const out = shipParts(process.argv.slice(2)); +(out.exit === 2 ? process.stderr : process.stdout).write(out.text); +process.exit(out.exit); diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index b513fdbdf..58f747108 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -28,7 +28,7 @@ With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session che ## Load the right workflow -For Capy running GPT-6 Astra, read `${CODEX_HOME:-$HOME/.codex}/skills/gstack/SKILL.md` for routing, then `${CODEX_HOME:-$HOME/.codex}/skills/gstack-/SKILL.md` for the selected workflow. These are the generated GPT-6 Astra instructions, not the Claude source files. +For Capy running GPT-6 Astra, read `${CODEX_HOME:-$HOME/.codex}/skills/gstack/SKILL.md` for routing, then `${CODEX_HOME:-$HOME/.codex}/skills/gstack-/SKILL.md` for the selected workflow. These are generated for GPT-6 Astra, not the Claude source files. For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills//SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness. @@ -38,15 +38,15 @@ If no specific skill was requested, use the router's task-to-skill mapping. Read For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=

`. `~/.local/share/gstack/bin/gstack-artifact validate /run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract. -For `/ship` driven from Capy, read `ship/DRIVER.md` in the checkout (`~/.local/share/gstack/ship/DRIVER.md`): the phase loop at one or two calls per phase, and the section each STOP needs. A checkout without that file predates it; follow its `ship/SKILL.md` instead. +For `/ship` driven from Capy, read `ship/DRIVER.md` in the checkout (`~/.local/share/gstack/ship/DRIVER.md`): one or two calls per phase, and the section each STOP needs. A checkout without that file predates it; follow its `ship/SKILL.md` instead. -Run directories and scratch on Capy live under `~/.capy/work/` (`--out ~/.capy/work//autoplan`), never `/tmp`: Capy wipes `/tmp` when a machine sleeps, and a vanished run cannot be resumed, answered or exported. The same holds for any file a later turn reads. +Run directories and scratch on Capy live under `~/.capy/work/` (`--out ~/.capy/work//autoplan`), never `/tmp`: Capy wipes `/tmp` when a machine sleeps, and a vanished run cannot be resumed, answered or exported. Likewise any file a later turn reads. Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render /gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent: --owner-reply ""`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out --from ` restores the newest valid checkpoint. 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 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. ## Plan, approve, ship diff --git a/changelog.d/gsta-167-calls-3.md b/changelog.d/gsta-167-calls-3.md new file mode 100644 index 000000000..22d933ca6 --- /dev/null +++ b/changelog.d/gsta-167-calls-3.md @@ -0,0 +1,16 @@ +**/ship on Capy no longer spends calls reading its review sections.** In the GSTA-193 replay of PR #34 (C1-34), 13 of 42 parent calls only read part of a section (review-army.md, the core checklist, Steps 8.1-8.2, adversarial.md, documentation.md) or ran a step that belonged in the next program. Each phase program now ends by returning the next phase's sections, unchanged and split at headings, in the space its own 32 KB return has left. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged. + +### Added +- **`gstack-ship-parts next --run --phase --room `** returns the section parts the next phase uses, in run order, each byte once per run. A part due before that phase that did not fit is named, with `NEXT: parts` and the one call that fetches it. review-army.md, adversarial.md, documentation.md and review/checklist.md are covered whole, and Steps 8.1-8.2 of plan-completion.md exactly. A renamed split heading stops the run with the heading and file named. +- **The Capy pass program** adds those parts to each poll and finished-pass return once Phase 2 has started them, so Phase 4's judgment and Step 11's synthesis need no read call of their own. + +### Fixed +- **Step 14.5 runs through the pass program.** `gstack-review-dispatch pass --audit-only` makes the docs reader the whole pass, with no reviewer plan, and the program uses it when `step` is `14.5`. Before, the driver named a one-reader program the bin could not build (GSTA-196 replay of PR #36). +- **Decision 3.5's fix-check runs through the pass program.** `gstack-review-dispatch pass` now builds a fix-check plan, rendering the reader from `--fix-list`, `--hunks`, `--tests` and `--callers`, byte for byte the brief `render --role fix-check` writes. The program takes them as `fixCheck` and runs the plan's voices in the same call. Before, the parent walked the fix-check by hand. +- **Step 11's budget note counts only Step 11.** The voices stage keeps its own starting reading (`mark --before … --voices`). Before, it reused the pass's reading, so the GSTA-196 replay's Step 11 note showed 16.64M tokens against 4.17M of real growth. +- **`gstack-ship-budget calls --events ~/…` finds the file.** A leading `~` or `~/` now means the home directory. Before, a literal `~` from a transcript path failed the read, so the run reported no `CALLS:` line (gstack-evals#94, GST-438). +- **A repeat pass reads the range, as the sections say.** The pass program takes `diffBase` and passes it as `--diff-base`; the driver and the Capy loader set it to RANGE_BASE on pass 2 and later, so specialists, the Red Team and Step 11's voices get `DIFF_BASE=` byte for byte as `render` writes it. The targeted reader keeps the merge base. Before, every pass read the whole branch. The GSTA-197 replay's pass 2 re-sent pass 1's brief and its readers used 10.42M tokens against pass 1's 4.68M. +- **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 takes its fix-check files.** The driver now passes `fixCheck` on a `full` or `scoped` pass whose plan lists a fix-check. Before, every scoped pass stopped on the missing files. diff --git a/docs/review-policy.md b/docs/review-policy.md index 0986de1de..2d45ecd5e 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -513,7 +513,10 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: create whose result was lost by its title and never starts a reader twice; a missing or doubled title stops the pass. On Capy the pass runs as one call to the checked-in program `lib/capy-review-pass.js`, served by `program` and loaded - unchanged by the gstack Capy skill. + unchanged by the gstack Capy skill. A repeat pass takes `--diff-base` (RANGE_BASE), a + fix-check plan renders its reader from `--fix-list`, `--hunks`, `--tests` and `--callers`, + `--audit-only` runs just the `--extras` readers (Step 14.5's docs audit), and the fork + point is found against `origin/` first, as preflight does. 7. Every brief, audit readers' included, ends with a workspace paragraph naming the checkout the bins run in (GST-482): a reader on a shared machine starts in the machine's working directory, so it is told to `cd` there on every command, to give @@ -529,6 +532,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: ## Where this is enforced - `lib/review-brief.ts`, `lib/review-ledger.ts`, `lib/review-pass.ts`, `lib/capy-review-pass.js`, `bin/gstack-review-dispatch`: [verbatim dispatch](#verbatim-dispatch) (`test/review-dispatch.test.ts`, `test/review-pass.test.ts`). +- `lib/ship-parts.ts`, `bin/gstack-ship-parts`: the section text each /ship phase uses, returned once per run inside the phase program before it; `next --resend` sends an unreceived batch again (`test/ship-parts.test.ts`). - `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. - `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 3de382f18..c00dab63a 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -41,7 +41,7 @@ unless `--label` is given. **Counting calls.** `gstack-ship-budget calls --events [--since ] [--until ]` reads the host's transcript items (a JSON array, or `{ "items": [...] }` as Capy's `tools.thread.get` -transcript returns) and prints +transcript returns; a leading `~` or `~/` in `` means the home directory) and prints `CALLS: total= decision= keepalive= idle_gaps_over_300s= max_gap_s= first= last= source=`. Every tool item is one parent call; a `wait`, or a program that only reads status (plus at most one bash for its note), is a keep-alive, and a gap over 300 s between calls outlives the prompt cache diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 17fad909b..e65aeacc0 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -1,10 +1,13 @@ // gstack /ship review pass program for Capy (lib/capy-review-pass.js, GSTA-167 calls PR 2). // The body of an async function (tools, sleep, now, input), loaded verbatim by the loader in -// ship/DRIVER.md from `gstack-review-dispatch program`; never retyped. One call runs one pass or Step 11's -// voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`, +// ship/DRIVER.md from `gstack-review-dispatch program`; never retyped. One call runs one pass (decision 3.5's +// fix-check with its plan's voices included) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`, // intent before create, create, poll, collect. It returns 200 s after the loader's first line // (input.startedAt) at most; the next call resumes from the manifest. A failed, stuck or ambiguous // reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped. +// Once Phase 2 has started /parts (the parent of input.out), each poll or finished pass also +// returns the next phase's section parts that fit its 32 KB (`gstack-ship-parts next`, GSTA-167 C5); +// after a return that never reached the parent, rerun with input.resendParts to get that batch again. const T0 = input.startedAt ?? (await now()); const deadline = T0 + (input.budgetMs ?? 200000); const tick = input.tickMs ?? 20000; @@ -22,10 +25,21 @@ const bin = 'gstack-review-dispatch'; const finish = (tail) => { const all = [...lines, ...tail]; let text = all.join('\n'); - if (text.length > RETURN_BYTES) text = text.slice(0, RETURN_BYTES) + '\n(return capped at 32 KB; the rest is in the files named above)'; - return text; + if (text.length <= RETURN_BYTES) return text; + const last = '\n(return capped at 32 KB; the rest is in the files named above)\n' + all[all.length - 1]; + return all.slice(0, -1).join('\n').slice(0, Math.max(0, RETURN_BYTES - last.length)) + last; }; const stop = (why) => finish(['STOP: ' + why]); +const runDir = input.out.replace(/\/+$/, '').replace(/\/[^/]+$/, ''); +const nextPhase = input.stage === 'voices' ? 5 : input.step === '14.5' ? 7 : 4; +const withParts = async (tail) => { + const s = await sh('test -f ' + q(runDir + '/parts/state.json') + ' && echo started'); + if (!s.out.startsWith('started')) return finish([...tail.slice(0, -1), 'PARTS: skipped (no ' + runDir + '/parts/state.json: Phase 2 started no parts here, or input.out is not /)', tail[tail.length - 1]]); + const room = RETURN_BYTES - [...lines, ...tail].join('\n').length - 1; + const p = await sh('gstack-ship-parts next --run ' + q(runDir) + ' --phase ' + nextPhase + ' --room ' + Math.max(0, room) + (input.resendParts ? ' --resend' : '')); + const body = p.out.trimEnd().split('\n'); + return finish(p.code === 0 ? [...tail.slice(0, -1), ...body, tail[tail.length - 1]] : [...tail.slice(0, -1), ...body]); +}; 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)); @@ -35,6 +49,9 @@ if (input.extras && typeof input.extras !== 'string') { passCmd.push('--extras', q(file)); } else if (input.extras) passCmd.push('--extras', q(input.extras)); if (input.learnings) passCmd.push('--learnings-file', q(input.learnings)); +if (input.diffBase) passCmd.push('--diff-base', q(input.diffBase)); +if (input.step === '14.5') passCmd.push('--audit-only'); +for (const [k, v] of Object.entries(input.fixCheck || {})) passCmd.push('--' + k.replace('_', '-'), q(v)); passCmd.push('--', ...(input.planFlags || []).map(q)); const pr = await sh(passCmd.join(' ')); if (pr.code !== 0) return stop('gstack-review-dispatch pass exited ' + pr.code + ': ' + pr.out.slice(-1500)); @@ -78,19 +95,23 @@ const modelOf = (r) => r.model || (input.models || {})[r.role === 'specialist-ba || (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined); const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_'); const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team']; +const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before'; try { let m = await load(); + if (input.stage !== 'voices' && m.next === 'fix-check') active.push('voices'); 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']); + const seen = {}; for (const s of input.logStart || []) { - const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s; + 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; const l = await sh('gstack-review-log --start ' + s.split(' ').map(q).join(' ') + (input.reservation ? ' --reservation ' + q(input.reservation) : '')); if (l.code !== 0) return stop('gstack-review-log --start ' + s + ' exited ' + l.code + ': ' + l.out.slice(-500)); const token = l.out.trim(); const k = await sh(bin + ' mark --manifest ' + q(mf) + ' --log-start ' + q(key) + ' --token ' + q(token)); if (k.code !== 0) return stop('recording the ' + s + ' start token exited ' + k.code + ': ' + k.out.slice(-500)); - lines.push('REVIEW_LOG_START: ' + s + ' token=' + token); + lines.push('REVIEW_LOG_START: ' + s + (nth > 1 ? ' #' + nth : '') + ' token=' + token); } for (;;) { const failed = m.readers.find((x) => active.includes(x.stage) && x.state === 'failed'); @@ -159,9 +180,9 @@ try { if (s.code !== 0) return stop(s.out.trim()); lines.push(s.out.trim()); } - if (!m.before) { + if (!m[beforeKey]) { const b = await reading(); - if (b) { await sh(bin + ' mark --manifest ' + q(mf) + ' --before ' + b.tokens + ',' + b.credits); m.before = b; } + if (b) { await sh(bin + ' mark --manifest ' + q(mf) + ' --before ' + b.tokens + ',' + b.credits + (beforeKey === 'before_voices' ? ' --voices' : '')); m[beforeKey] = b; } } await mark(r.reader, 'intent', {}); const res = await tools.subagent.create({ tasks: [{ title: r.title, prompt: text }], start: { machine: 'shared', ...(model ? { model } : {}) } }); @@ -173,15 +194,16 @@ try { m = await load(); if ((await now()) + tick >= deadline) { const open = m.readers.filter((r) => active.includes(r.stage) && r.state !== 'collected'); - return finish([...open.map((r) => 'RUNNING: ' + r.reader + ' state=' + r.state + (r.path ? ' path=' + r.path : '')), 'NEXT: poll (run this same program again now; it resumes from the manifest)']); + return await withParts([...open.map((r) => 'RUNNING: ' + r.reader + ' state=' + r.state + (r.path ? ' path=' + r.path : '')), 'NEXT: poll (run this same program again now; it resumes from the manifest)']); } await sleep(tick); } const done = m.readers.filter((r) => active.includes(r.stage)); const after = await reading(); let noteExit = 0; - if (after && m.before && input.record && input.step) { - const n = await sh('gstack-ship-budget note --step ' + q(input.step) + ' --reading ' + after.tokens + ' --credits ' + after.credits + ' --record ' + q(input.record) + ' --dispatched-at ' + m.before.tokens + ' --dispatched-credits ' + m.before.credits + ' --readers ' + done.length); + const before = m[beforeKey]; + if (after && before && input.record && input.step) { + const n = await sh('gstack-ship-budget note --step ' + q(input.step) + ' --reading ' + after.tokens + ' --credits ' + after.credits + ' --record ' + q(input.record) + ' --dispatched-at ' + before.tokens + ' --dispatched-credits ' + before.credits + ' --readers ' + done.length); lines.push(n.out.trim()); noteExit = n.code; } @@ -191,7 +213,7 @@ try { outs.push('OUTPUT: ' + r.reader + ' file=' + r.output, o.out.trimEnd()); } if (noteExit !== 0) return finish([...outs, 'STOP: gstack-ship-budget note exited ' + noteExit + ' (its lines above say why); judge nothing further until the budget allows it']); - return finish([...outs, input.stage === 'voices' ? 'NEXT: judge the voices' : 'NEXT: judge (Phase 4)']); + return await withParts([...outs, input.stage === 'voices' ? 'NEXT: judge the voices' : input.step === '14.5' ? 'NEXT: apply the docs audit (Step 14.5)' : 'NEXT: judge (Phase 4)']); } catch (e) { return stop('pass program error: ' + (e && e.message ? e.message : String(e)) + ' (the manifest keeps every recorded reader; rerun resumes)'); } diff --git a/lib/review-pass.ts b/lib/review-pass.ts index e4187eb0d..7c7154aeb 100644 --- a/lib/review-pass.ts +++ b/lib/review-pass.ts @@ -39,8 +39,13 @@ export interface Manifest { version: 1; pass: string; out: string; ledger: string; merged: string; base: string; diff_base: string; stack: string; test_fw: string; learnings: string | null; checklist_root: string; next: string; plan_lines: string[]; readers: PassReader[]; before?: { tokens: number; credits: number }; + /** Step 11's own reading at its first dispatch, so its note never counts the pass's readers again. */ + before_voices?: { tokens: number; credits: number }; log_starts?: Record; + /** Decision 3.5's files for a fix-check reader: the fix list, touched hunks, test results and callers. */ + fix_check?: FixCheckFiles; } +export interface FixCheckFiles { fix_list: string; hunks: string; tests: string; callers?: string } export interface Extra { name: string; prompt_file: string; first?: boolean; model?: PassModel } /** Brief characters per transport chunk: even with every character escaped, one chunk line stays far under 1 MiB. */ @@ -68,18 +73,19 @@ export function detectTestFw(root: string): string { } const safe = (reader: string) => reader.replace(/[^a-zA-Z0-9._-]/g, '_'); -const STAGE_OF: Record = { specialist: 'specialists', 'red-team': 'red-team', 'adversarial-native': 'voices', 'outside-voice': 'voices', targeted: 'voices' }; +const STAGE_OF: Record = { specialist: 'specialists', 'fix-check': 'specialists', 'red-team': 'red-team', 'adversarial-native': 'voices', 'outside-voice': 'voices', targeted: 'voices' }; -/** The manifest for one pass from the plan's dispatches. A fix-check is not part of a pass, so it is refused. */ +/** The manifest for one pass from the plan's dispatches. A fix-check reader needs decision 3.5's files. */ export function buildManifest(o: { pass: string; out: string; ledger: string; base: string; diffBase: string; root: string; checklistRoot: string; dispatches: Dispatch[]; next: string; planLines: string[]; learnings?: string | null; extras?: Extra[]; - targetedClasses?: readonly string[]; mergeBase?: string; + targetedClasses?: readonly string[]; mergeBase?: string; fixCheck?: FixCheckFiles; }): Manifest { const readers: PassReader[] = []; for (const d of o.dispatches) { const stage = STAGE_OF[d.role]; - if (!stage) throw new BriefError(`a pass dispatches no ${d.role} reader (fix-check runs on its own)`); + if (!stage) throw new BriefError(`a pass dispatches no ${d.role} reader`); + if (d.role === 'fix-check' && !o.fixCheck) throw new BriefError('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests (decision 3.5\'s files; --callers optional)'); const role: BriefRole = d.role === 'specialist' && d.checklists ? 'specialist-batched' : d.role as BriefRole; const name = role === 'specialist' ? d.name : undefined; const reader = readerKey(role, name); @@ -97,6 +103,7 @@ export function buildManifest(o: { version: 1, pass: o.pass, out, ledger: path.resolve(o.ledger), merged: path.join(out, 'merged.jsonl'), base: o.base, diff_base: o.diffBase, stack: detectStack(o.root), test_fw: detectTestFw(o.root), learnings: o.learnings ?? null, checklist_root: o.checklistRoot, next: o.next, plan_lines: o.planLines, readers, + ...(o.fixCheck ? { fix_check: Object.fromEntries(Object.entries(o.fixCheck).filter(([, v]) => v).map(([k, v]) => [k, path.resolve(v as string)])) as unknown as FixCheckFiles } : {}), }; } @@ -154,6 +161,11 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor: Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) }); } if (role === 'outside-voice') slots.diff = diffFor(m.diff_base); + if (role === 'fix-check') { + const read = (f: string | undefined, what: string) => { if (!f) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read the fix-check ${what} ${f}`); } }; + const fc = m.fix_check!; + Object.assign(slots, { fix_list: read(fc.fix_list, 'fix list'), hunks: read(fc.hunks, 'hunks'), tests: read(fc.tests, 'tests'), callers: read(fc.callers, 'callers') }); + } let targeted = {}; if (role === 'targeted') { const checklist = path.join(m.checklist_root, 'review/specialists/targeted.md'); diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 848450ef5..99c10d167 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -9,6 +9,7 @@ */ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { resultLine } from './result-codes'; import { @@ -400,10 +401,11 @@ export function cmdNote(argv: string[], ctx: Ctx): Outcome { } /** calls: count parent calls from the host's transcript items (lib/ship-calls.ts). */ -export function cmdCalls(argv: string[]): Outcome { +export function cmdCalls(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['events', 'since', 'until']); - const file = str(o, 'events'); - if (!file) throw usageError('calls needs --events '); + const raw = str(o, 'events'); + if (!raw) throw usageError('calls needs --events '); + const file = raw === '~' || raw.startsWith('~/') ? path.join(ctx.env.HOME || os.homedir(), raw.slice(1)) : raw; let doc: unknown; try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { throw usageError(`calls --events ${file}: ${e.message}`); } const d = doc as { items?: unknown; cursor?: unknown }; diff --git a/lib/ship-parts.ts b/lib/ship-parts.ts new file mode 100644 index 000000000..328b52f4b --- /dev/null +++ b/lib/ship-parts.ts @@ -0,0 +1,149 @@ +/** + * /ship section parts (GSTA-167 calls plan C5, in code): the driver's phase programs end with + * `gstack-ship-parts next`, which returns the section text the next phase's steps use, verbatim, + * split at headings, each byte once per run, before its first use, within the room the program has + * left under its 32 KB return. The parent never reads these sections in a call of its own. + * + * PARTS is in run order. A part runs from its heading line to the next start heading listed for the + * same file, its `until` heading, or the end of the file; a part with no heading starts at byte 0, + * so review-army.md, adversarial.md, documentation.md and review/checklist.md are returned whole. + */ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export const ROOT = path.resolve(import.meta.dir, '..'); +export const PART_MAX = 24 * 1024; +export const ROOM_DEFAULT = 32000; + +type PartSpec = { id: string; file: string; from: string | null; until?: string; phase: number }; + +const RA = 'ship/sections/review-army.md'; +const ADV = 'ship/sections/adversarial.md'; +const DOCS = 'ship/sections/documentation.md'; +export const WHOLE = [RA, ADV, DOCS, 'review/checklist.md']; + +export const PARTS: PartSpec[] = [ + { id: 'review-9', file: RA, from: null, phase: 3 }, + { id: 'review-9.1', file: RA, from: '## Step 9.1: Review Army — Specialist Dispatch', phase: 3 }, + { id: 'review-red-team', file: RA, from: '### Red Team dispatch (conditional)', phase: 3 }, + { id: 'review-9.2.1', file: RA, from: '### Step 9.2.1: Exploratory QA (before Fix-First)', phase: 3 }, + { id: 'plan-8.1-8.2', file: 'ship/sections/plan-completion.md', from: '## Step 8.1: Plan Verification', until: '## Prior Learnings', phase: 4 }, + { id: 'review-calibration', file: RA, from: '## Confidence Calibration', phase: 4 }, + { id: 'review-core', file: RA, from: '### Core checklist', phase: 4 }, + { id: 'checklist', file: 'review/checklist.md', from: null, phase: 4 }, + { id: 'review-design', file: RA, from: '## Design Review (conditional, diff-scoped)', phase: 4 }, + { id: 'review-9.2', file: RA, from: '### Step 9.2: Collect and merge findings', phase: 4 }, + { id: 'review-9.3', file: RA, from: '### Step 9.3: Cross-review finding dedup', phase: 4 }, + { id: 'review-9.4', file: RA, from: '## Step 9.4: Fix-First and persistence', phase: 4 }, + { id: 'adversarial-11', file: ADV, from: null, phase: 4 }, + { id: 'adversarial-native', file: ADV, from: '### Claude adversarial subagent (on `DISPATCH: adversarial-native`)', phase: 4 }, + { id: 'adversarial-codex', file: ADV, from: '### Codex adversarial challenge (runs whenever `CODEX_MODE` is `ready` or `unverified`)', phase: 4 }, + { id: 'adversarial-structured', file: ADV, from: '### Codex structured review (large diffs only, 200+ lines)', phase: 4 }, + { id: 'adversarial-finish', file: ADV, from: '### Persist the review result', phase: 4 }, + { id: 'adversarial-learnings', file: ADV, from: '## Capture Learnings', phase: 4 }, + { id: 'documentation', file: DOCS, from: null, phase: 6 }, +]; + +export type Part = { id: string; file: string; phase: number; start: number; end: number; text: string }; + +/** Byte offset of a heading line outside code fences, or -1. */ +function headingAt(buf: Buffer, heading: string): number { + let fence = false; + let at = 0; + for (const line of buf.toString('utf8').split('\n')) { + if (line.startsWith('```')) fence = !fence; + else if (!fence && line === heading) return at; + at += Buffer.byteLength(line) + 1; + } + return -1; +} + +export function resolveParts(root = ROOT): Part[] { + const bufs = new Map(); + const read = (f: string) => { + if (!bufs.has(f)) bufs.set(f, fs.readFileSync(path.join(root, f))); + return bufs.get(f)!; + }; + const find = (f: string, h: string) => { + const at = headingAt(read(f), h); + if (at < 0) throw new Error(`PARTS_MANIFEST: heading "${h}" not found in ${f}; update lib/ship-parts.ts with the section`); + return at; + }; + const starts = PARTS.map(p => (p.from === null ? 0 : find(p.file, p.from))); + return PARTS.map((p, i) => { + const buf = read(p.file); + const later = PARTS.map((q, j) => (q.file === p.file && starts[j] > starts[i] ? starts[j] : Infinity)); + const end = Math.min(buf.length, ...later, p.until ? find(p.file, p.until) : Infinity); + return { id: p.id, file: p.file, phase: p.phase, start: starts[i], end, text: buf.subarray(starts[i], end).toString('utf8') }; + }); +} + +const sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 12); +const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; +export const block = (p: Part) => + `PART: ${p.id} file=${p.file} phase=${p.phase} bytes=${p.end - p.start} sha256=${sha(p.text)}\n${p.text}${p.text.endsWith('\n') ? '' : '\n'}PART_END: ${p.id}\n`; + +/** The next parts in run order that fit `room` bytes, and the parts due by `phase` that did not fit. */ +export function nextParts(parts: Part[], returned: readonly string[], phase: number, room: number) { + const out: Part[] = []; + let used = 0; + for (const p of parts) { + if (returned.includes(p.id)) continue; + const b = Buffer.byteLength(block(p)); + if (used + b > room) break; + out.push(p); + used += b; + } + const given = new Set([...returned, ...out.map(p => p.id)]); + return { + out, + due: parts.filter(p => p.phase <= phase && !given.has(p.id)).map(p => p.id), + left: parts.filter(p => !given.has(p.id)).length, + }; +} + +const HELP = `gstack-ship-parts — the section text /ship's next phase uses, verbatim, each byte once (GSTA-167 C5) + + next --run --phase [--room ] [--resend] print the next parts that fit, in run order + list every part with its phase and size + +Each phase program ends with \`next --phase \` and --room set to 32000 less the +program's other output; the room covers the whole output, the PARTS: and NEXT: lines included. +\`NEXT: parts\` means a part due by that phase did not fit: run \`next\` alone before the phase. +A batch is \`sent\` until the next \`next\` call promotes it to returned; after a return that never +reached the parent, \`next --resend\` sends that batch again. State: /parts/state.json. +`; + +export function shipParts(argv: readonly string[], root = ROOT): { text: string; exit: number } { + const [cmd, ...rest] = argv; + if (!cmd || cmd === '--help' || cmd === '-h') return { text: HELP, exit: cmd ? 0 : 2 }; + if (cmd !== 'next' && cmd !== 'list') return { text: `unknown command: ${cmd}\n${HELP}`, exit: 2 }; + let parts: Part[]; + try { parts = resolveParts(root); } catch (e) { return { text: `STOP: ${(e as Error).message}\n`, exit: 3 }; } + if (cmd === 'list') return { text: parts.map(p => `${p.id} phase=${p.phase} bytes=${p.end - p.start} file=${p.file}`).join('\n') + '\n', exit: 0 }; + const flag = (n: string) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; }; + const run = flag('--run'); + const phase = Number(flag('--phase')); + const room = flag('--room') === undefined ? ROOM_DEFAULT : Number(flag('--room')); + if (!run || !Number.isInteger(phase) || !Number.isFinite(room)) return { text: 'next needs --run and --phase ; --room is a byte count\n', exit: 2 }; + const state = path.join(run, 'parts', 'state.json'); + let saved: { returned: string[]; sent?: string[] } = { returned: [] }; + try { + if (fs.existsSync(state)) saved = JSON.parse(fs.readFileSync(state, 'utf8')); + if (!Array.isArray(saved.returned) || (saved.sent !== undefined && !Array.isArray(saved.sent))) throw new Error('returned and sent must be id lists'); + } catch (e) { + return { text: `STOP: ${state} is not a parts state file (${(e as Error).message}); delete it and rerun to start the run's parts again\n`, exit: 3 }; + } + const returned = rest.includes('--resend') ? saved.returned : [...saved.returned, ...(saved.sent ?? [])]; + const trailer = (ids: string[], due: string[], left: number) => { + const lines = [`PARTS: phase=${phase} returned=${ids.join(',') || 'none'} due=${due.join(',') || 'none'} left=${left}`]; + if (due.length) lines.push(`NEXT: parts (run \`gstack-ship-parts next --run ${quote(run)} --phase ${phase}\` alone before Phase ${phase})`); + return lines.join('\n') + '\n'; + }; + const open = parts.filter(p => !returned.includes(p.id)).map(p => p.id); + const { out, due, left } = nextParts(parts, returned, phase, room - Buffer.byteLength(trailer(open, open, open.length))); + fs.mkdirSync(path.dirname(state), { recursive: true }); + fs.writeFileSync(state, JSON.stringify({ returned, sent: out.map(p => p.id) }, null, 2) + '\n'); + return { text: out.map(block).join('') + trailer(out.map(p => p.id), due, left), exit: 0 }; +} diff --git a/ship/DRIVER.md b/ship/DRIVER.md index bea60744f..6a2186eee 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -2,28 +2,27 @@ # /ship driver: run /ship from a parent agent -Read this instead of `ship/SKILL.md` when a parent agent on any host runs /ship +Read this, not `ship/SKILL.md`, when a parent agent on any host runs /ship (`SESSION_KIND: unattended`). Commands live in `/bin/` (`` holds `ship/`); run each with -the `GSTACK_LAUNCH` assignments your installer printed (`. `), never +the `GSTACK_LAUNCH` assignments your installer printed (`. `), never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence. **Rules.** Each phase below is one call: one program (Capy: an execute program; else a script) that runs the bins, reads, dispatches and waits, returning only result lines and reader -outputs; print them verbatim. The model keeps only lead judgment, fixes, CHANGELOG and PR prose, the +outputs; print them verbatim. The model keeps only lead judgment, fixes, CHANGELOG, PR prose, gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names the next act. Read a section only when a phase or the list at the end sends you, never up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it -there, come back. Unattended, a question with a recommended option takes it (log it); a gate -disposition and every approval stay pending and end the run. Keep a private invocation record (the -`--record` file): write its six headings at Phase 1, then fill each as its phase ends: **Checks** +there, come back. Unattended, take a question's recommended option (log it); gate +dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, then fill each as its phase ends: **Checks** (Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Scope Check:`), **Decisions** (any -phase: each answered or auto-taken question, in its words), **Reviews** (Phases 3-5: the plan line, +phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line, start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7: version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (every phase: the next act). **Reviewers are the sections' own.** Every audit, specialist, Red Team, QA, fix-check, adversarial, -outside-voice and documentation brief, its severity and confidence rules and its gates come from its -section verbatim, with only its placeholders filled; this file +outside-voice and documentation brief, its severity and confidence rules and gates come from its +section verbatim (placeholders filled); this file never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render` file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body. @@ -33,20 +32,22 @@ section read or a fix-check, plus a keep-alive per 200 s waited. A fix repeats P **Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends with `gstack-ship-budget note --step --reading R --credits C --record `, R and C read in that program -(Capy: your jam's `cost.tokens`, `cost.credits`). No check per step, no reservation, no meter-only +(Capy: `cost.tokens`, `cost.credits`). No check per step, no reservation, no meter-only call, no handoff; under `--token-cap` a note past the cap prints `verdict=stop`, the one stop check. -A dispatching program takes `BEFORE` (R and C) as its readers start and passes +A dispatching program takes `BEFORE` (R and C) as readers start and passes `--dispatched-at --dispatched-credits --readers ` with the reading after the last settles. **Waits.** No call waits past 200 s: a program returns what still runs 200 s after its first line; the next call resumes the poll. Lanes, pregate and `finish` run in background. Never end the turn while readers run (each wakes it). A small waiting child saves nothing. Past 100 minutes, stop. Returns: -≤32 KB per program; split longer reads at headings, each byte once, before first use. +≤32 KB per program; split longer reads at headings, each byte once, before first use: phase programs +end with `gstack-ship-parts next --run --phase --room <32000 - its output>`; on +`NEXT: parts` run it alone. **One program per pass.** Each review pass, Step 11 and the Step 14.5 audit is one program, and the parent makes no other call while it runs: (1) `gstack-review-dispatch pass`: `gstack-review-plan --base ` with the pass's flags, then its staged manifest (Step 14.5 has one reader, `docs`); (2) `gstack-review-log --start -` once per row the step logs (Step 14.5 takes none); (3) `BEFORE`; (4) `sent` (`BRIEF_MISMATCH` voids it), then one +` once per row the step logs; (3) `BEFORE`; (4) `sent` (`BRIEF_MISMATCH` voids it), then one subagent per reader on its rendered file; (5) a poll until every reader settles (status `done` or `failed`, or a final summary; a fresh `idle` has not run); (6) `note` with the reading pair; a later batch (the Red Team, @@ -55,31 +56,29 @@ final output and status, via `collect`; it never walks the dispatch steps call b ## 1. Load: Step 0 -The install check and this file. +Install check, this file. ## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10 `gstack-ship-budget start --record --mode observe [--token-cap ] --reading R --credits C`, then -`gstack-ship-preflight --base --lane