mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
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:
1 parent
2d1b55d762
commit
abd7b1f8bd
17 files changed
+651
-93
No files matched your search
+31
-10
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
Executable
+10
@@ -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);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
|
||||
@@ -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 '");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user