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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 18:57:41 +00:00
1 parent 2d1b55d762
commit abd7b1f8bd
17 files changed
+651 -93

No files matched your search

+31 -10
View File
@@ -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 <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>]
[--checklist-root <dir>] [-- <gstack-review-plan flags>]
pass --base <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>] [--audit-only]
[--checklist-root <dir>] [--fix-list <f> --hunks <f> --tests <f> [--callers <f>]] [-- <gstack-review-plan flags>]
runs gstack-review-plan and writes <out>/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 <f> MANIFEST_JSON: <the manifest on one line>
stage --manifest <f> --stage <first|specialists|red-team|voices>
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 <f> --reader <r> --state <intent|created|queued|collected|failed> [--path <p>] [--output <f>] [--error <e>]
an audit reader is collected with --output, whose REVIEWED_TREE line must match its render (else failed, COLLECT_WRONG_TREE)
mark --manifest <f> --before <tokens>,<credits>
mark --manifest <f> --before <tokens>,<credits> [--voices] the pass's reading at its first dispatch; --voices keeps Step 11's own
mark --manifest <f> --log-start <key> --token <t> 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 <f>]
@@ -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/<base> 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 <tokens>,<credits>\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 <tokens>,<credits> [--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'));
+1 -1
View File
@@ -38,7 +38,7 @@ const ctx = { env: process.env, cwd: process.cwd() };
const commands: Record<string, () => 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');
+10
View File
@@ -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);
+4 -4
View File
@@ -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>/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>/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>/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=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/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/<name>` (`--out ~/.capy/work/<project>/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/<name>` (`--out ~/.capy/work/<project>/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 <dir>/gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent:<id> --owner-reply "<their words>"`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out <new dir> --from <that dir>` restores the newest valid checkpoint.
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam 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 <n> --credits <c>`, 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: ". '<launch file>' && 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: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", 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 <file>` 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: ". '<launch file>' && 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: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", 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: "<RANGE_BASE>"`. 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 <file>` on your jam's transcript since /ship began. A small waiting child saves nothing.
## Plan, approve, ship
+16
View File
@@ -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 <run dir> --phase <n> --room <bytes>`** 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=<RANGE_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/<base>, 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/<base>` 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.
+5 -1
View File
@@ -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/<base>` 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=<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.
+1 -1
View File
@@ -41,7 +41,7 @@ unless `--label` is given.
**Counting calls.** `gstack-ship-budget calls --events <file> [--since <iso>] [--until <iso>]` 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 `<file>` 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
+34 -12
View File
@@ -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 <run dir>/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 <run dir>/<pass dir>)', 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)');
}
+16 -4
View File
@@ -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<string, string>;
/** 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<string, Stage> = { specialist: 'specialists', 'red-team': 'red-team', 'adversarial-native': 'voices', 'outside-voice': 'voices', targeted: 'voices' };
const STAGE_OF: Record<string, Stage> = { 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');
+5 -3
View File
@@ -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 <file of the host\'s transcript items>');
const raw = str(o, 'events');
if (!raw) throw usageError('calls needs --events <file of the host\'s transcript items>');
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 };
+149
View File
@@ -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<string, Buffer>();
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 <run dir> --phase <n> [--room <bytes>] [--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 <its phase + 1>\` 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: <run dir>/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 <dir> and --phase <n>; --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 };
}
+27 -27
View File
@@ -2,28 +2,27 @@
<!-- Regenerate: bun run gen:skill-docs -->
# /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 `<root>/bin/` (`<root>` holds `ship/`); run each with
the `GSTACK_LAUNCH` assignments your installer printed (`. <the GSTACK_LAUNCH_FILE it printed>`), never
the `GSTACK_LAUNCH` assignments your installer printed (`. <its GSTACK_LAUNCH_FILE>`), 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 <last step> --reading R --credits C --record <rec>`, 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 <R> --dispatched-credits <C> --readers <n>` 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 <run dir> --phase <n+1> --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 <base>`
with the pass's flags, then its staged manifest (Step 14.5 has one reader, `docs`); (2) `gstack-review-log --start
<skill>` once per row the step logs (Step 14.5 takes none); (3) `BEFORE`; (4) `sent` (`BRIEF_MISMATCH` voids it), then one
<skill>` 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 <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (the test commands AGENTS.md or
CLAUDE.md names). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (AGENTS.md or CLAUDE.md test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
is `count unavailable`. In-branch failures stop the ship; after a repair rerun with `--tests-only`.
is `count unavailable`. In-branch failures stop the ship; after a repair, rerun `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Steps 6.5 and 10 run only if `gstack-greptile-early detect` finds
record `evals: none affected`. Steps 6.5 and 10 run if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map, the
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read
`sections/review-army.md`, `sections/adversarial.md`, `sections/documentation.md`, the todos and commits
files, and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is `needed`.
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read the todos and
commits files and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is
`needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Step 9's plan and start, Step 9.1's
specialists and Step 9.2's Red Team as written. `Coverage:` and Step 8's line come from `--audit` when no
`sections/review-army.md` as its brief) and the pass (above): Steps 9-9.2 as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
@@ -88,7 +87,8 @@ 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; `done`: the same program runs Step 11 from
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase`: RANGE_BASE; `fixCheck` for a planned fix-check); `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)`).
@@ -110,7 +110,7 @@ One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
Commit bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
@@ -120,8 +120,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; put both in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
- Step 20: the metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (put `BUDGET_FINAL:` and `BUDGET_TIMELINE:`, tokens and credits, in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
## Where each STOP sends you (paths under `<root>/ship/`)
+27 -27
View File
@@ -1,27 +1,26 @@
# /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 `<root>/bin/` (`<root>` holds `ship/`); run each with
the `GSTACK_LAUNCH` assignments your installer printed (`. <the GSTACK_LAUNCH_FILE it printed>`), never
the `GSTACK_LAUNCH` assignments your installer printed (`. <its GSTACK_LAUNCH_FILE>`), 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.
@@ -31,20 +30,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 <last step> --reading R --credits C --record <rec>`, 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 <R> --dispatched-credits <C> --readers <n>` 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 <run dir> --phase <n+1> --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 <base>`
with the pass's flags, then its staged manifest (Step 14.5 has one reader, `docs`); (2) `gstack-review-log --start
<skill>` once per row the step logs (Step 14.5 takes none); (3) `BEFORE`; (4) `sent` (`BRIEF_MISMATCH` voids it), then one
<skill>` 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,
@@ -53,31 +54,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 <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (the test commands AGENTS.md or
CLAUDE.md names). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (AGENTS.md or CLAUDE.md test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
is `count unavailable`. In-branch failures stop the ship; after a repair rerun with `--tests-only`.
is `count unavailable`. In-branch failures stop the ship; after a repair, rerun `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Steps 6.5 and 10 run only if `gstack-greptile-early detect` finds
record `evals: none affected`. Steps 6.5 and 10 run if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map, the
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read
`sections/review-army.md`, `sections/adversarial.md`, `sections/documentation.md`, the todos and commits
files, and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is `needed`.
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read the todos and
commits files and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is
`needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Step 9's plan and start, Step 9.1's
specialists and Step 9.2's Red Team as written. `Coverage:` and Step 8's line come from `--audit` when no
`sections/review-army.md` as its brief) and the pass (above): Steps 9-9.2 as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
@@ -86,7 +85,8 @@ 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; `done`: the same program runs Step 11 from
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase`: RANGE_BASE; `fixCheck` for a planned fix-check); `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)`).
@@ -108,7 +108,7 @@ One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
Commit bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
@@ -118,8 +118,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; put both in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
- Step 20: the metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (put `BUDGET_FINAL:` and `BUDGET_TIMELINE:`, tokens and credits, in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
## Where each STOP sends you (paths under `<root>/ship/`)
+190 -2
View File
@@ -27,7 +27,7 @@ function manifest(dispatches: Dispatch[], o: Partial<Parameters<typeof buildMani
const collectAll = (m: Manifest, stage: string) => { for (const r of m.readers.filter(x => x.stage === stage)) { markReader(m, r.reader, 'intent'); markReader(m, r.reader, 'created', { path: '9' }); markReader(m, r.reader, 'collected'); } };
describe('pass manifest', () => {
test('stages follow the plan: specialists, Red Team, voices; batched light depth keeps its checklists and exclusions; fix-check is refused', () => {
test('stages follow the plan: specialists, Red Team, voices; batched light depth keeps its checklists and exclusions; a fix-check needs its files', () => {
const m = manifest([spec('testing'), { role: 'red-team', name: 'red-team', effort: 'full', exclude: [] }, { role: 'adversarial-native', name: 'adversarial', effort: 'full', exclude: [] }, { role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]);
expect(m.readers.map(r => [r.reader, r.stage, r.title])).toEqual([
['specialist/testing', 'specialists', 'ship p1 specialist/testing'], ['red-team', 'red-team', 'ship p1 red-team'],
@@ -36,7 +36,7 @@ describe('pass manifest', () => {
expect(m.test_fw).toBe('unknown');
const light = manifest([{ role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'red-team'] }]);
expect(light.readers[0]).toMatchObject({ reader: 'specialist-batched', role: 'specialist-batched', exclude: "-- . ':(exclude)docs/a.md'", checklists: ['testing', 'red-team'] });
expect(() => manifest([{ role: 'fix-check', name: 'fix-check', effort: 'full', exclude: [] }])).toThrow(/fix-check runs on its own/);
expect(() => manifest([{ role: 'fix-check', name: 'fix-check', effort: 'full', exclude: [] }])).toThrow(/the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests/);
// PR #3665 review: the batched reviewer reads each checklist by path, as the render CLI is given them, never by bare name.
renderStage(light, 'specialists', texts, () => '');
expect(fs.readFileSync(light.readers[0]!.file!, 'utf8')).toContain(`Read each checklist at ${path.join(ROOT, 'review/specialists/testing.md')}, ${path.join(ROOT, 'review/specialists/red-team.md')} (tag each finding`);
@@ -403,6 +403,41 @@ describe('Capy pass program (fake host, real bins)', () => {
expect(res).not.toContain('NEXT: judge');
}, 240_000);
test('Step 14.5 runs as an audit-only pass: its docs reader alone, verbatim, with no reviewer plan', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/docs');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, 'docs.prompt.md'), 'Run Step 14.5 as written.\n');
const extras = [{ name: 'docs', prompt_file: path.join(out, 'docs.prompt.md'), first: true }];
const h = host(dir, env, { output: t => (t.endsWith('audit/docs') ? 'DOCS: README updated' : 'NO FINDINGS') });
const res = await runPass(h, { base: 'main', out, pass: 'x16docs', step: '14.5', extras });
expect(h.creates.map(c => c.title)).toEqual(['ship x16docs audit/docs']);
expect(h.creates[0]!.prompt).toBe(withWorkspace('Run Step 14.5 as written.\n', fs.realpathSync(dir)));
expect(res).toContain('DOCS: README updated');
expect(res).toContain('NEXT: apply the docs audit (Step 14.5)');
expect(bin(dir, env, ['pass', '--base', 'main', '--out', path.join(dir, '.state/run/d2'), '--pass', 'x16b', '--audit-only']).status).not.toBe(0);
}, 240_000);
test('Step 11 notes its own growth from its first dispatch, not from the pass\'s starting reading', async () => {
const { dir, env } = repo();
const rec = path.join(dir, '.state/rec.md');
fs.mkdirSync(path.dirname(rec), { recursive: true });
expect(spawnSync('bash', ['-c', 'gstack-ship-budget start --record "$1" --mode observe --reading 1000 --credits 1', '_', rec], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).status).toBe(0);
const h = host(dir, env, { pollsToFinish: 3 });
const get = h.tools.thread.get;
let last = 0;
h.tools.thread.get = async () => { const r = await get(); last = r.cost.tokens; return r; };
const out = path.join(dir, '.state/run/p1');
const base = { base: 'main', out, pass: 'x17p1', planFlags: ['--depth-floor', 'full'], record: rec, jam: 'jam_test' };
expect(await runPass(h, { ...base, step: '9' })).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' } };
const res = await runPass(h, { ...base, step: '11', stage: 'voices', models });
const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8'));
expect(m.before_voices.tokens).toBeGreaterThan(m.before.tokens);
const note = res.split('\n').find(l => l.startsWith('NOTE: '))!;
expect(Number(note.match(/readers_tokens=(\d+)/)![1])).toBe(last - m.before_voices.tokens);
}, 240_000);
test('effort=medium readers run the lane model at medium reasoning; without laneModel or models the pass stops before creating any', async () => {
const { dir, env } = repo();
const h = host(dir, env);
@@ -418,6 +453,31 @@ describe('Capy pass program (fake host, real bins)', () => {
expect(m.readers.some(r => r.effort === 'medium')).toBe(true);
}, 180_000);
// Value: protects=once Phase 2 starts the run's parts, the pass program carries the next phase's section parts in the room its own return leaves, so Phase 4 needs no read call of its own, and it never adds them to a run that did not start them; fails_when=the pass returns parts without a started run, overruns 32 KB, puts them after its NEXT line, or repeats a part a poll return already carried;
// why_new=GSTA-167 calls plan C5 in code; seam=none; no_claim=how much room real reader outputs leave
test('a started run gets the next parts inside each poll and the finished pass, once each and before NEXT', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
const h = host(dir, env, { pollsToFinish: 30 });
const input = { base: 'main', out, pass: 'x12p1', planFlags: ['--depth-floor', 'full'] };
expect(await runPass(h, input)).not.toContain('PART: ');
fs.mkdirSync(path.join(dir, '.state/run/parts'), { recursive: true });
fs.writeFileSync(path.join(dir, '.state/run/parts/state.json'), JSON.stringify({ returned: ['review-9', 'review-9.1', 'review-red-team', 'review-9.2.1'] }));
const ids: string[] = [];
let res = '';
for (let i = 0; i < 8 && !res.includes('NEXT: judge'); i++) {
res = await runPass(h, input);
expect(res.length).toBeLessThanOrEqual(32000);
const got = [...res.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!);
if (got.length) expect(res.lastIndexOf('PART_END: ')).toBeLessThan(res.lastIndexOf('\nNEXT: '));
ids.push(...got);
}
expect(res).toContain('NEXT: judge (Phase 4)');
expect(ids.length).toBeGreaterThan(0);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe('plan-8.1-8.2');
}, 240_000);
// Value: protects=the loader's input is filled as SKILL.md reads it: extras as JSON, the checkout named by cwd, and a reader whose summary lands a tick after its done status is collected, not stopped; fails_when=extras must be a file path (replay C2-34: BRIEF_RENDER_FAILED [object Object]), the bins run in the jam's directory instead of the checkout (C2-34: a wrapper launch file), or a done reader without its summary yet stops the pass (C2-34: "finished with no output", one extra call);
// 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 () => {
@@ -434,6 +494,105 @@ describe('Capy pass program (fake host, real bins)', () => {
expect(h.creates.find(c => c.title === 'ship x13p1 audit/qa')!.prompt).toBe(withWorkspace('QA brief', fs.realpathSync(dir)));
}, 180_000);
test('decision 3.5 runs in the program: one fix-check reader whose prompt is the render CLI brief byte for byte, sent and collected', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/fc');
const files = path.join(dir, '.state/fc');
fs.mkdirSync(files, { recursive: true });
const fc: Record<string, string> = {};
for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n'], ['callers', 'none\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); }
const planFlags = ['--pass', '2', '--last', 'full', '--fixed-blocking'];
const missing = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fc0'), pass: 'x19fc0', planFlags });
expect(missing).toContain('STOP: gstack-review-dispatch pass exited');
expect(missing).toContain('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests');
const h = host(dir, env);
const res = await runPass(h, { base: 'main', out, pass: 'x19fc', planFlags, fixCheck: fc, logStart: ['review-fix-check'] });
expect(res).toMatch(/REVIEW_LOG_START: review-fix-check token=\S+/);
expect(res).toContain('NEXT: judge (Phase 4)');
expect(h.creates.map(c => c.title)).toEqual(['ship x19fc fix-check']);
const cli = bin(dir, env, ['render', '--role', 'fix-check', '--out', path.join(dir, '.state/cli'), '--fix-list', fc.fix_list!, '--hunks', fc.hunks!, '--tests', fc.tests!, '--callers', fc.callers!]);
expect(cli.status).toBe(0);
expect(h.creates[0]!.prompt).toBe(fs.readFileSync(path.join(dir, '.state/cli/fix-check.prompt.md'), 'utf8'));
expect(h.creates[0]!.prompt).toContain('You are the fix-check reviewer');
const v = bin(dir, env, ['verify', '--ledger', path.join(out, 'review-ledger.jsonl')]).stdout;
expect(v).toMatch(/^BRIEF fix-check MATCH rendered=(\w+) sent=\1$/m);
expect(v).toMatch(/^OUTPUT fix-check MATCH /m);
}, 240_000);
test('Step 11 records one start per row it logs, even when two rows share a skill (native and outside adversarial-review)', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
const h = host(dir, env);
const base = { base: 'main', out, pass: 'x20p1', 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' } };
const res = await runPass(h, { ...base, stage: 'voices', models, logStart: ['adversarial-review', 'adversarial-review'] });
const starts = res.split('\n').filter(l => l.startsWith('REVIEW_LOG_START: adversarial-review'));
expect(starts.length).toBe(2);
expect(new Set(starts.map(l => l.split('token=')[1])).size).toBe(2);
const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8'));
expect(Object.keys(m.log_starts).sort()).toEqual(['voices:adversarial-review', 'voices:adversarial-review#2']);
const again = await runPass(h, { ...base, stage: 'voices', models, logStart: ['adversarial-review', 'adversarial-review'] });
expect(again).not.toContain('REVIEW_LOG_START:');
}, 240_000);
test('a repeat pass reads the range: diffBase reaches the specialists and the Red Team, byte for byte the render CLI at that base', async () => {
const { dir, env } = repo();
const sh = (c: string) => spawnSync('bash', ['-c', c], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 });
const rangeBase = sh('git rev-parse HEAD').stdout.trim();
expect(sh('echo "export const fix = 1;" >> src/a.ts && git commit -qam fix').status).toBe(0);
const out = path.join(dir, '.state/run/p2');
const h = host(dir, env);
expect(await runPass(h, { base: 'main', out, pass: 'x21p2', diffBase: rangeBase, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)');
const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8'));
expect(m.diff_base).toBe(rangeBase);
const spec = m.readers.find((r: { role?: string }) => r.role === 'specialist-batched');
const cli = bin(dir, env, ['render', '--role', 'specialist-batched', '--out', path.join(dir, '.state/cli'), '--diff-base', rangeBase, '--checklists', spec.checklists.join(','), '--stack', m.stack, '--test-fw', m.test_fw]);
expect(cli.status, cli.stderr).toBe(0);
expect(h.creates.find(c => c.title === 'ship x21p2 specialist-batched')!.prompt).toBe(fs.readFileSync(path.join(dir, '.state/cli/specialist-batched.prompt.md'), 'utf8'));
expect(h.creates.find(c => c.title === 'ship x21p2 red-team')!.prompt).toContain('DIFF_BASE=' + rangeBase);
}, 240_000);
// Value: protects=a scoped repeat runs its fix-check reader from decision 3.5's files and, without them, stops naming what is missing; fails_when=the program drops fixCheck on a scoped plan or a scoped plan without files dispatches a reader;
// why_new=#3670 Step 11 (adversarial 1, red team 1): the scoped path was never exercised; seam=none; no_claim=the parent writing the files
test('a scoped repeat runs with fixCheck files and stops naming them without', async () => {
const { dir, env } = repo();
const sh = (c: string) => spawnSync('bash', ['-c', c], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 });
const rangeBase = sh('git rev-parse HEAD').stdout.trim();
expect(sh('echo "export const fix = 1;" >> src/a.ts && git commit -qam fix').status).toBe(0);
const planFlags = ['--pass', '1', '--last', 'full', '--fixed-blocking', '--pass1-depth', 'standard', '--range-base', rangeBase];
const files = path.join(dir, '.state/fc');
fs.mkdirSync(files, { recursive: true });
const fc: Record<string, string> = {};
for (const [k, v] of [['fix_list', '1. src/a.ts: fix\n'], ['hunks', sh('git diff ' + rangeBase).stdout], ['tests', 'bun test: 1 pass, 0 fail\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); }
const none = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/s0'), pass: 'x22s0', diffBase: rangeBase, planFlags });
expect(none).toContain('STOP: gstack-review-dispatch pass exited');
expect(none).toContain('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests');
const h = host(dir, env);
const res = await runPass(h, { base: 'main', out: path.join(dir, '.state/run/s1'), pass: 'x22s1', diffBase: rangeBase, planFlags, fixCheck: fc });
expect(res).toContain('next=scoped');
expect(res).not.toContain('STOP:');
expect(h.creates.map(c => c.title)).toContain('ship x22s1 fix-check');
}, 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 <run dir>/<pass dir> 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 () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
fs.mkdirSync(path.join(dir, '.state/run/parts'), { recursive: true });
fs.writeFileSync(path.join(dir, '.state/run/parts/state.json'), JSON.stringify({ returned: [] }));
const big = host(dir, env, { output: () => 'x'.repeat(15_900) + '\nNO FINDINGS' });
const res = await runPass(big, { base: 'main', out, pass: 'x23p1', planFlags: ['--depth-floor', 'full'] });
expect(res.length).toBeLessThanOrEqual(32000);
expect(res.trimEnd().split('\n').at(-1)).toBe('NEXT: judge (Phase 4)');
const odd = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/deep/p1'), pass: 'x23p2', planFlags: ['--depth-floor', 'full'] });
expect(odd).toMatch(/^PARTS: skipped \(/m);
expect(odd.trimEnd().split('\n').at(-1)).toBe('NEXT: judge (Phase 4)');
}, 240_000);
test('a plan with no reviewer pass dispatches nothing and passes NEXT through', async () => {
const { dir, env } = repo();
const h = host(dir, env);
@@ -443,6 +602,35 @@ describe('Capy pass program (fake host, real bins)', () => {
}, 120_000);
});
describe('the pass resolves --base the way preflight does', () => {
function withOrigin(stale: boolean) {
const { dir, env } = repo();
const sh = (c: string) => { const r = spawnSync('bash', ['-c', c], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(c + ': ' + r.stderr); return r.stdout.trim(); };
const origin = tmp('rp-origin-');
sh('git init -q --bare "$0" && git remote add origin "$0"'.split('$0').join(origin));
if (stale) {
sh('git checkout -q main && echo "# base" > BASE.md && git add BASE.md && git commit -qm m2 && git push -q origin main && git reset -q --hard HEAD~1 && git checkout -q feat && git merge -q --no-edit origin/main');
} else {
sh('git push -q origin main && git fetch -q origin && git branch -D main');
}
return { dir, env, originMain: sh('git rev-parse origin/main') };
}
// Value: protects=a checkout whose local main is missing or behind origin/main reviews the same fork point preflight merged, not a stale one or none; fails_when=pass resolves --base against the local branch only (replay C5-36: BRIEF_RENDER_FAILED: no merge base between main and HEAD);
// why_new=identity checks ran with local main equal to origin/main; seam=gstack-review-dispatch pass; no_claim=hosts whose remote is not named origin
test.each([['no local main', false], ['a local main behind origin/main', true]])('%s: diff base is the fork point with origin/main, briefs byte for byte the render CLI there', (_, stale) => {
const { dir, env, originMain } = withOrigin(stale as boolean);
const out = path.join(dir, '.state/run/p1');
const r = bin(dir, env, ['pass', '--base', 'main', '--out', out, '--pass', 'x22p1', '--', '--depth-floor', 'full']);
expect(r.status, r.stdout + r.stderr).toBe(0);
const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8'));
expect(m.diff_base).toBe(originMain);
const spec = m.readers.find((x: { role?: string }) => x.role === 'specialist-batched');
const cli = bin(dir, env, ['render', '--role', 'specialist-batched', '--out', path.join(dir, '.state/cli'), '--diff-base', originMain, '--checklists', spec.checklists.join(','), '--stack', m.stack, '--test-fw', m.test_fw]);
expect(cli.status, cli.stderr).toBe(0);
expect(fs.readFileSync(spec.file, 'utf8')).toBe(fs.readFileSync(path.join(dir, '.state/cli/specialist-batched.prompt.md'), 'utf8'));
}, 120_000);
});
describe('the Capy skill loader', () => {
// The loader is copied from capy/skill/gstack/SKILL.md with only its placeholders filled, so a drift in either breaks here.
test('runs the served program unchanged and the pass notes its readers in the budget', async () => {
+8
View File
@@ -55,4 +55,12 @@ describe('gstack-ship-budget calls', () => {
const missing = spawnSync(BIN, ['calls', '--events', path.join(tmp, 'nope.json')], { encoding: 'utf8', timeout: 30_000 });
expect(missing.status).toBe(2);
});
test('a literal ~ in --events is the home directory, as a shell would expand it (GST-438)', () => {
const home = fs.mkdtempSync(path.join(tmp, 'home-'));
fs.writeFileSync(path.join(home, 'events.json'), JSON.stringify({ items: ITEMS, cursor: 'c1' }));
const r = spawnSync(BIN, ['calls', '--events', '~/events.json'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, HOME: home } });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^CALLS: total=8 decision=4 keepalive=4 .* source=c1$/m);
});
});
+1 -1
View File
@@ -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', 'Step 7 may write tests, so it settles first',
'one program runs the failing test, fix, `--tests-only` and Phase 3, each gated', 'RANGE_BASE; `fixCheck` for a planned fix-check', '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 <rec>');
});
+126
View File
@@ -0,0 +1,126 @@
/**
* GSTA-167 calls plan C5 in code: gstack-ship-parts returns the sections /ship's next phase uses,
* verbatim, split at headings, each byte once per run, inside the program before that phase.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { PARTS, PART_MAX, ROOM_DEFAULT, WHOLE, block, resolveParts } from '../lib/ship-parts';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-ship-parts');
const run = (args: string[]) => spawnSync(BIN, args, { encoding: 'utf8', timeout: 30_000 });
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ship-parts-'));
const parts = resolveParts(ROOT);
describe('the parts manifest', () => {
// Value: protects=the parent gets every byte of review-army.md, adversarial.md, documentation.md and review/checklist.md exactly once, and Steps 8.1-8.2 whole, so moving the reads into phase programs loses nothing; fails_when=a heading split skips or repeats bytes, a section grows past what one return can carry, or a part is scheduled after the phase that first uses it;
// why_new=C5 moved section reads from prose into a bin; seam=none; no_claim=what the parent does with the text
test('whole files are covered byte for byte once, Steps 8.1-8.2 are exact, parts fit one return and run in phase order', () => {
for (const f of WHOLE) {
const own = parts.filter(p => p.file === f).sort((a, b) => a.start - b.start);
expect(own[0]!.start).toBe(0);
for (let i = 1; i < own.length; i++) expect(own[i]!.start).toBe(own[i - 1]!.end);
expect(Buffer.concat(own.map(p => Buffer.from(p.text))).equals(fs.readFileSync(path.join(ROOT, f)))).toBe(true);
}
const plan = fs.readFileSync(path.join(ROOT, 'ship/sections/plan-completion.md'), 'utf8');
expect(parts.find(p => p.id === 'plan-8.1-8.2')!.text).toBe(plan.slice(plan.indexOf('\n## Step 8.1: Plan Verification\n') + 1, plan.indexOf('\n## Prior Learnings\n') + 1));
expect(new Set(parts.map(p => p.id)).size).toBe(parts.length);
for (const p of parts) expect(Buffer.byteLength(block(p))).toBeLessThanOrEqual(PART_MAX);
expect(parts.map(p => p.phase)).toEqual([...parts.map(p => p.phase)].sort((a, b) => a - b));
for (const p of parts.filter(p => p.file === 'ship/sections/adversarial.md')) expect(p.phase, p.id).toBe(4);
const phase3 = parts.filter(p => p.phase === 3).reduce((n, p) => n + Buffer.byteLength(block(p)), 0);
expect(phase3).toBeLessThanOrEqual(PART_MAX);
});
// Value: protects=a section edit that renames a split heading stops the run with the file and heading named, instead of silently shifting bytes between parts; fails_when=a missing heading falls back to the file start or end;
// why_new=headings are the manifest's keys; seam=none; no_claim=which headings a section should have
test('a split heading missing from its section stops with the heading and file named', () => {
const root = tmp();
for (const f of new Set(PARTS.map(p => p.file))) {
fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true });
fs.copyFileSync(path.join(ROOT, f), path.join(root, f));
}
const ra = path.join(root, 'ship/sections/review-army.md');
fs.writeFileSync(ra, fs.readFileSync(ra, 'utf8').replace('### Step 9.3: Cross-review finding dedup', '### Step 9.3: Dedup'));
expect(() => resolveParts(root)).toThrow('PARTS_MANIFEST: heading "### Step 9.3: Cross-review finding dedup" not found in ship/sections/review-army.md');
});
});
describe('gstack-ship-parts next', () => {
// Value: protects=each program's return stays within the room it passes, no part is returned twice in a run, and a part due by the next phase that did not fit is named with the one call that fetches it; fails_when=next repeats a part, overruns --room, drops a due part silently, or the run's later calls restart from the top;
// why_new=the bin is new; seam=none; no_claim=the parent computing --room correctly
test('returns parts in run order within --room, each once, and names what is due before the phase', () => {
const dir = tmp();
const seen: string[] = [];
let text = '';
for (const [phase, room] of [[3, 26000], [4, 8000], [4, ROOM_DEFAULT], [4, ROOM_DEFAULT], [4, ROOM_DEFAULT], [5, ROOM_DEFAULT], [6, ROOM_DEFAULT], [7, ROOM_DEFAULT]] as const) {
const r = run(['next', '--run', dir, '--phase', String(phase), '--room', String(room)]);
expect(r.status, r.stderr).toBe(0);
const ids = [...r.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!);
const body = r.stdout.slice(0, r.stdout.indexOf('PARTS: '));
expect(Buffer.byteLength(body)).toBeLessThanOrEqual(room);
for (const id of ids) {
const p = parts.find(x => x.id === id)!;
expect(body).toContain(block(p));
text += p.text;
}
seen.push(...ids);
const due = r.stdout.match(/^PARTS: phase=\d+ returned=\S+ due=(\S+) left=\d+$/m)![1]!;
expect(due).toBe(parts.filter(p => p.phase <= phase && !seen.includes(p.id)).map(p => p.id).join(',') || 'none');
expect(r.stdout.includes("NEXT: parts (run `gstack-ship-parts next --run '" + dir + "' --phase " + phase + '` alone before Phase ' + phase + ')')).toBe(due !== 'none');
}
expect(seen).toEqual(parts.map(p => p.id));
expect(Buffer.byteLength(text)).toBe(parts.reduce((n, p) => n + p.end - p.start, 0));
expect(run(['next', '--run', dir, '--phase', '7']).stdout).toBe('PARTS: phase=7 returned=none due=none left=0\n');
});
});
describe('gstack-ship-parts next under loss and odd inputs (PR #3670 review)', () => {
// Value: protects=a section batch whose return never reached the parent is delivered again, the whole output (trailer included) stays inside --room, the printed recovery command survives a run dir with spaces or quotes, and a corrupt state file stops with its path instead of a crash; fails_when=next marks parts returned before delivery with no resend, budgets only the part blocks, prints the run dir unquoted, or throws on bad JSON;
// why_new=Step 11 findings on #3670 (outside voice 1-3, adversarial 5, QA); seam=none; no_claim=the host noticing that a return was lost
test('a lost return is sent again with --resend, and a plain next promotes it to returned', () => {
const dir = tmp();
const first = run(['next', '--run', dir, '--phase', '3', '--room', '4000']);
const ids = [...first.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!);
expect(ids.length).toBeGreaterThan(0);
const again = run(['next', '--run', dir, '--phase', '3', '--room', '4000', '--resend']);
expect(again.status, again.stderr).toBe(0);
expect([...again.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!)).toEqual(ids);
const later = run(['next', '--run', dir, '--phase', '3', '--room', '4000']);
expect([...later.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!).filter(id => ids.includes(id))).toEqual([]);
expect(JSON.parse(fs.readFileSync(path.join(dir, 'parts', 'state.json'), 'utf8')).returned).toEqual(expect.arrayContaining(ids));
});
test('the PARTS: and NEXT: parts trailer counts against --room, and the run dir is shell-quoted', () => {
const dir = path.join(tmp(), "run with 'spaces' and $(x)");
const room = Buffer.byteLength(block(parts[0]!)) + 10;
const r = run(['next', '--run', dir, '--phase', '3', '--room', String(room)]);
expect(r.status, r.stderr).toBe(0);
expect(Buffer.byteLength(r.stdout)).toBeLessThanOrEqual(room);
const cmd = r.stdout.match(/^NEXT: parts \(run `(.+)` alone before Phase 3\)$/m)![1]!;
const echo = spawnSync('bash', ['-c', cmd.replace('gstack-ship-parts next', 'printf "%s\\n"')], { encoding: 'utf8', timeout: 30_000 });
expect(echo.stdout.split('\n')).toContain(dir);
});
test('a corrupt state file stops with its path named', () => {
const dir = tmp();
fs.mkdirSync(path.join(dir, 'parts'));
fs.writeFileSync(path.join(dir, 'parts', 'state.json'), '{bad');
const r = run(['next', '--run', dir, '--phase', '3']);
expect(r.status).toBe(3);
expect(r.stdout).toStartWith('STOP: ' + path.join(dir, 'parts', 'state.json'));
});
});
describe('the driver and the pass program use it', () => {
// Value: protects=the parent learns the one call that carries the sections and the pass program carries them for Phase 3's returns, so no phase spends a call reading a section; fails_when=the driver drops the call or its NEXT rule, or the pass program stops calling the bin;
// why_new=C5 in code; seam=none; no_claim=the parent's adherence, which a replay's CALLS: line measures
test('ship/DRIVER.md names the call and its NEXT rule, and the pass program runs it', () => {
const doc = fs.readFileSync(path.join(ROOT, 'ship/DRIVER.md'), 'utf8').replace(/\s+/g, ' ');
expect(doc).toContain('phase programs end with `gstack-ship-parts next --run <run dir> --phase <n+1> --room <32000 - its output>`; on `NEXT: parts` run it alone');
expect(fs.readFileSync(path.join(ROOT, 'lib/capy-review-pass.js'), 'utf8')).toContain("'gstack-ship-parts next --run '");
});
});