v1.91.345.0 feat(ship): compact driver plus preflight/release bins over the restored reviewers (GSTA-167) (#3648)

* feat(ship): GSTA-167 turn levers on the restored reviewers: ship preflight/release bins, own-jam meter, dispatch-pair metering, ship/DRIVER.md

Ports the non-reviewer parts of #3633 (gsta-167-pr34) and #3637 (gsta-167-pr5 @2914400)
onto main ae3d58c (v1.91.331.0). Reviewer briefs, checklists, the fix-check prompt and
the depth rules are byte-identical to main and now pinned in
test/review-packets-removed.test.ts.

Left out: gstack-review-packet and its libs, parallel/batched fanout, --like, collect,
the audit prompt split, the docs-audit packet, review-army/adversarial/outside-voice
edits, the gate-scan summary-only block and the review-depth scope-error change.
Packet-bytes reader pricing is dropped from gstack-ship-budget; handoff READ ranges
send each section whole; the driver runs the audit, review, adversarial and
documentation sections as written.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(context-budget): raise the capy/skill/gstack eager ceiling to its captured value after the main merge (own-jam meter, driver and reviewer-batch prose, GSTA-167)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ship-driver): name ship/DRIVER.md.tmpl so the pregate maps the template to its test lane (GSTA-167)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-driver): keep the skill generator off lib/ship-budget so a minimal setup checkout still renders (GSTA-167)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(windows): keep test/ship-release.test.ts off the Windows subset; it launches the shebang bin and sh stubs directly (GSTA-167)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship): variant E orchestration: one program per review pass and Step 14.5 audit, the review child starts at Step 9, a metered dispatch:docs role, and the driver's record headings (GSTA-167)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship): variant G orchestration: one call per driver phase (9 for a clean pass, at most 12), gstack-ship-preflight --audit for single reads and deterministic audits, an observe-mode ledger with note and credits, 240 s wait pings, and small-model audit readers (GSTA-167)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-budget): a Capy handoff child needs no jam id: verify-handoff with no reading makes it parent-metered (METER: parent), it never delegates on an unknown charge, and reconcile charges the parent's counter delta (GSTA-167 replay 7e)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): a poll settles a reader only at done, failed or a final summary, never a fresh idle; return settles the child's open step reservation (GSTA-167 replay 7e)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-budget): return releases an open step reservation under the estimate meter instead of booking its projection; measured meters still settle it (GSTA-167 replay 7e)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review): a clean fix-check after below-floor fixes is done; Step 11 and fix-check below-floor findings are notes at pass 1

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship): verbatim reviewer briefs and loss-free collection (gstack-review-dispatch render/sent/collect/judge/verify)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

- verify requires each output's brief rendered and sent verbatim, reruns judge
  against the recorded judged.jsonl hash, and flags outputs collected after judge
- judge drops self-folds, fold cycles and chains
- prose findings parse a leading label or a label heading, not any severity word
- releasePaths: manifests count as release metadata only for version-only edits,
  lockfiles never; TODOS.md stays out of lane-evidence exemptions
- push stops when the sealed-output guard did not run
- ship-audit reads the test tree once instead of one git grep per file
- drop the uncalled --delegate measured mode; doc/help step-number fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (pass 2)

- collect keeps a finding wherever its label stands (headings, table rows,
  mid-line, Informational and upper-case labels); only top-level list items
  inherit a label heading, and any heading or bold-only line ends it
- judge treats a merged file missing any collected id as a drop; verify flags
  merged_ids_missing and a sent reader never collected (NOT_COLLECTED)
- finish computes release paths after --build, so a build that rewrites
  package.json is a build-input change

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review-dispatch): verify per render, voided superseded mismatches

- verify treats each render as one dispatch of its reader, so a two-pass
  ledger compares pass-1 sends with pass-1 briefs (later renders print as
  <reader>@2); outputs bind to the latest send of their own render
- a mismatched send re-sent byte-exact before anything was collected is void
  and counted (voided=N on the verdict line); one that produced an output
  stays MISMATCH
- an indented bold sub-line no longer ends a label section (fix-check)
- docs: a failed reader is collected from its failure text

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review-dispatch): only the newest render of a path checks its file; absolute ledger paths

- a later render into the same briefs directory overwrites the earlier brief by
  design, so verify checks a brief file only for the newest render of that path
- collect and judge record absolute output, merged and judged paths, so verify
  reads them from any directory
- docs: a failed reader's failure text is parsed like any output

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: changelog and TODOS

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-release): the changelog_fragments fragment is release metadata

Fix-check 4 (CRITICAL) after the main merge: releasePaths omitted
<changelog_fragments.dir>/<branch-slug>.md, so finish stopped on the
fragment Step 13 writes under gstack's policy, or routed it to re-review.
version now names the fragment in its Step 13 NEXT line instead of
[Unreleased], DRIVER.md Step 13 writes and lints where NEXT says, and the
preflight POLICY line says Steps 12-16.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: changelog and TODOS

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 01:09:31 +00:00
1 parent c6e781f4a9
commit 21277291c8
74 files changed
+4626 -691

No files matched your search

+26
View File
@@ -1,5 +1,31 @@
# Changelog
## [1.91.345.0] - 2026-10-31
**A capped `/ship` on Capy now spends fewer agent turns and counts only its own tokens, while every reviewer still gets exactly the brief 1.91.331.0 gives it.** Two new commands do `/ship`'s fixed shell work, a short driver file replaces reading the whole skill when a parent agent runs `/ship`, and the budget measures each reviewer batch on its own instead of across the parent's work. Replays of gstack-evals #34 and #36 (GSTA-167) spent 12.19M tokens on Steps 12-17 and 0.25-0.4M on each parent turn; these changes cut turns, not reviewer context.
### Added
- **`gstack-ship-preflight`** runs Steps 1-3 (branch guard, status, fetch, diff stat, PR and receipt carry, scope, base merge, distribution, policy, runtime lines) and Step 5 (pregate, then the test lanes in parallel) in one call. **`gstack-ship-release`** runs Step 12's version, Steps 15-16's release commit and verification, and Step 17's push. Each prints at most 30 lines; `STOP:` names the decision left to the agent and `NEXT:` the step to run. Under `queue_mode stamp-at-merge` or `changelog_fragments` the version step prints `VERSION: skipped (policy: ...)` and writes nothing; under `changelog_fragments` its `NEXT:` line names the branch's own fragment, and `finish` commits that fragment as release metadata.
- **`ship/DRIVER.md`**, a compact host-neutral driver for a parent agent running `/ship` unattended: one or two calls per phase, and the section each STOP needs. Its audit, review, adversarial and documentation phases run their sections as written, so it never restates or shortens a reviewer's brief.
- **`gstack-review-dispatch`** gates every `/ship` reviewer brief and finding. `render` builds a reader's brief from its section with only declared slots filled, `sent` checks the prompt a dispatch passed against that brief by sha256 (`BRIEF_MISMATCH` voids the review), `collect` is the only way findings reach `merged.jsonl` (each with its label as written), and `judge` refuses a judged set that drops or downgrades a collected finding. `verify --ledger` replays all of it per pass from the run's ledger and prints `VERIFY: clean` only when nothing was lost; a mismatched send that was re-sent exact before anything was collected shows as `voided=N`.
- **Small runs keep their segment.** `check --runtime-lines <n>` continues at the Step 9 and Step 12 boundaries with a `SMALL_RUN:` line when the diff has at most 50 runtime lines and the segment would cost less than a child's fixed startup.
### Changed
- **The Capy meter reads the agent's own thread**, its subagents included, inside the same program as the budget command, never in a call of its own. A handoff child counts from its own `verify-handoff` reading, and `reconcile` charges the `used=` its return prints.
- **Steps are priced at the run's own per-call charge** once three calls are measured; a prior reading older than the run's last reading is ignored (`CALL_UNKNOWN:`).
- **A reviewer batch is measured only by its own program's reading pair** (`settle --dispatched-at <before> --reading <after> --readers <n>`); anything else prints `UNMEASURED:` and leaves the factor alone. `check` prints the reservation id alone on a `RESERVATION_ID:` line, a multi-role reservation admits one review start per role, and `--finish` settles a later Red Team batch of the same pass.
- **A handoff brief lists the child's `READ:` line ranges**: its own steps of `ship/SKILL.md` and each of its sections whole, and names the last step it returns at.
- **The Capy launch file appends the checkout's `bin/` to PATH**, so a bare `gstack-ship-budget` resolves.
### Fixed
- **A failed child stops its parent** until a regenerated child reconciles complete or the owner resumes, and `reconcile --record` finds the parent run after a `--continue` child.
- **`close` counts review starts reserved in handoff children**, so a dispatch reserved inside a child is no longer flagged as unreserved at the root.
- **A dependency or script change after review is re-reviewed.** `gstack-ship-release finish` counts package.json and package-lock.json as release metadata only when the version alone changed, never counts bun.lock, and decides after any `--build` runs. TODOS.md edits rerun the test lanes.
- **`push` stops when the sealed-output guard could not run**, not only when it refuses.
### Tests
- `test/review-packets-removed.test.ts` now also pins the reviewer briefs, checklists, fix-check prompt and depth rules by content, so a change to what a reviewer sees fails until its no-loss replay is recorded.
## [1.91.344.0] - 2026-10-31
**A PR that moves its CHANGELOG entry into `changelog.d/` keeps its /ship receipt (GST-343).** Since #3654 turned on changelog fragments for gstack, a base merge that moved a PR's `[Unreleased]` entry into `changelog.d/<slug>.md` made `gstack-ship-receipt carry` refuse with RECEIPT_CARRY_PRODUCT_CHANGED. The PR then needed a new review pass for a changelog edit.
+1 -1
View File
@@ -1 +1 @@
1.91.344.0
1.91.345.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.344.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.345.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+5 -1
View File
@@ -656,13 +656,16 @@ WARN behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind
[ -n "$NOTES" ] && TRAILER="$TRAILER ($NOTES)" ;;
esac
# The launch file: the same assignments as a sourceable script, mode 0600,
# beside the verdict. `. <file>` applies them without ever printing them; the
# beside the verdict. It also appends the checkout's bin directory to PATH
# (after everything already there, so it shadows nothing): a skill's bare
# `gstack-ship-budget` resolves in a shell that sourced only this file. `. <file>` applies them without ever printing them; the
# line carries no secret, but a caller that re-typed it as `export $(...)`
# ran a bare `export` when the substitution came back empty and dumped the
# whole environment, API keys included, into its tool output (GSTA-96). The
# file is part of the ok verdict: a passing run that cannot write it fails,
# so no caller is left with only the line and the re-typing path.
LAUNCH_FILE="${VERDICT%.json}.env"
BIN_DIR=$(cd "$ROOT/bin" 2>/dev/null && pwd -P)
write_launch_file() {
local tmp
mkdir -p "$(dirname "$LAUNCH_FILE")" || return 1
@@ -672,6 +675,7 @@ write_launch_file() {
echo "# Apply it with: . <this file>"
echo "# Never print it or the environment: no \`export \$(...)\`, \`env\`, \`printenv\` or bare \`set\` in a tool fence."
[ -n "$LAUNCH_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$LAUNCH_DIR"):*) ;; *) PATH=$(shq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH"
[ -n "$BIN_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$BIN_DIR"):*) ;; *) PATH=\"\${PATH:+\$PATH:}\"$(shq "$BIN_DIR") ;; esac; export PATH"
echo "export GSTACK_SESSION_KIND=unattended"
echo "export GSTACK_STATE_ROOT=$(shq "$GSTACK_STATE_ROOT")"
echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-review-dispatch — /ship's reviewers get their briefs byte for byte and the parent changes
* nothing they return (GSTA-167 no-loss gate; docs/review-policy.md#verbatim-dispatch).
*
* gstack-review-dispatch render --role <role> [--name <n>] --out <dir> [slots] write the brief, print its sha256
* gstack-review-dispatch sent --ledger <f> --role <role> [--name <n>] --sent <file> the prompt actually passed; mismatch fails
* gstack-review-dispatch collect --ledger <f> --role <role> [--name <n>] --output <file> --merged <file>
* gstack-review-dispatch judge --ledger <f> --merged <file> --judged <file> no drop, no downgrade
* gstack-review-dispatch verify --ledger <f> replay every hash and the judgment
*
* Logic: lib/review-brief.ts (rendering from the sections) and lib/review-ledger.ts (ledger, collect, judge, verify).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { BRIEF_ROLES, BriefError, briefFile, promptHash, renderBrief, sectionTexts, type BriefRole, type Slots } from '../lib/review-brief';
import { collect, readJsonl, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine } from '../lib/review-ledger';
const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free collection for /ship
render --role <role> [--name <n>] --out <dir> [--ledger <f>] [slots]
roles: ${BRIEF_ROLES.join(', ')}
slots: --diff-base <sha> (every role but fix-check) --exclude '<DIFF_CMD tail>' --stack <s> --test-fw <s>
--learnings-file <f> --checklist <path> (default <root>/review/specialists/<name>.md; red-team.md for red-team)
--checklists <a,b> (specialist-batched) --findings-file <f> --specialists <n> (red-team)
--fix-list <f> --hunks <f> --tests <f> [--callers <f>] (fix-check)
writes <out>/<role>[-<name>].prompt.md, records it in the ledger (default <out>/review-ledger.jsonl) and prints
BRIEF: reader=<role/name> sha256=<h> bytes=<n> file=<path>. Pass that file's content as the reader's prompt, unchanged.
sent --ledger <f> --role <role> [--name <n>] --sent <file>
the exact prompt a dispatch passed: BRIEF_SENT: … match, or BRIEF_MISMATCH: … (exit 1; the pass is not reviewed)
collect --ledger <f> --role <role> [--name <n>] --output <file> --merged <merged.jsonl>
the reader's raw output: hashes it and appends each finding to merged.jsonl with its label as written
judge --ledger <f> --merged <merged.jsonl> --judged <judged.jsonl>
judged lines {"id","severity","reason"?,"duplicate_of"?}: every merged id kept at its severity or above (a
duplicate folds into one at least as severe); added and promoted findings need a reason. JUDGMENT: ok …, or
JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1)
verify --ledger <f>
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)
${exitTableHelp()}`;
const args = process.argv.slice(2);
const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
const readFile = (f: string | undefined, what: string) => {
if (!f) return undefined;
try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read ${what} file ${f}`); }
};
const ROOT = path.resolve(import.meta.dir, '..');
function render(): number {
const role = flag('--role') as BriefRole | undefined;
const out = flag('--out');
if (!role || !BRIEF_ROLES.includes(role) || !out) { process.stderr.write(`gstack-review-dispatch render: needs --role <${BRIEF_ROLES.join('|')}> and --out <dir>\n`); return EXIT.usage; }
const name = flag('--name');
const diffBase = flag('--diff-base');
const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined);
if (checklist && (role === 'specialist' || role === 'red-team') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`);
let diff: string | undefined;
if (role === 'outside-voice' && diffBase) {
const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase], { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0) throw new BriefError(`git diff ${diffBase} failed: ${(r.stderr ?? '').trim().split('\n')[0]}`);
diff = r.stdout;
}
const slots: Slots = {
name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'),
learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: flag('--checklists'),
findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff,
fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'),
};
const text = renderBrief(role, slots, sectionTexts(ROOT));
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
const file = path.join(path.resolve(out), briefFile(role, name));
fs.writeFileSync(file, text, { mode: 0o600 });
const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl');
const sha = promptHash(text);
appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text) });
process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`);
return EXIT.ok;
}
function main(): number {
const cmd = args[0];
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (cmd === 'render') return render();
const ledger = flag('--ledger');
if (!ledger) { process.stderr.write(`gstack-review-dispatch ${cmd}: --ledger <file> is required\n`); return EXIT.usage; }
const reader = flag('--role') ? readerKey(flag('--role')!, flag('--name')) : '';
if (cmd === 'sent') {
const sent = readFile(flag('--sent'), 'sent prompt');
if (!reader || sent === undefined) { process.stderr.write('gstack-review-dispatch sent: needs --role and --sent <file>\n'); return EXIT.usage; }
const r = recordSent(ledger, reader, sent);
process.stdout.write(r.ok ? `BRIEF_SENT: reader=${reader} sha256=${r.sent} match\n`
: `BRIEF_MISMATCH: reader=${reader} rendered=${r.rendered ?? 'none'} sent=${r.sent} (dispatch the rendered file unchanged; this reader's review does not count)\n`);
return r.ok ? EXIT.ok : EXIT.fail;
}
if (cmd === 'collect') {
const outFile = flag('--output');
const output = readFile(outFile, 'reader output');
const merged = flag('--merged');
if (!reader || output === undefined || !merged) { process.stderr.write('gstack-review-dispatch collect: needs --role, --output <file> and --merged <file>\n'); return EXIT.usage; }
const r = collect(ledger, reader, output, merged, path.resolve(outFile!));
process.stdout.write(`COLLECT: reader=${reader} findings=${r.lines.length} sha256=${r.sha} merged=${merged}\n`);
return EXIT.ok;
}
if (cmd === 'judge') {
const merged = flag('--merged');
const judged = flag('--judged');
if (!merged || !judged) { process.stderr.write('gstack-review-dispatch judge: needs --merged <file> and --judged <file>\n'); return EXIT.usage; }
const r = recordJudgment(ledger, readJsonl<MergedLine>(merged), readJsonl<JudgedLine>(judged), judged, merged);
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one)\n`);
return r.ok ? EXIT.ok : EXIT.fail;
}
if (cmd === 'verify') {
const v = verify(ledger);
for (const r of v.rows) process.stdout.write(`${r.kind} ${r.reader} ${r.status} ${r.detail}\n`);
if (!v.briefs) { process.stdout.write('VERIFY: no brief records (this run\'s ledger kept no prompts; dispatched-vs-rendered cannot be checked)\n'); return EXIT.refused; }
process.stdout.write(`VERIFY: ${v.clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}\n`);
return v.clean ? EXIT.ok : EXIT.fail;
}
process.stderr.write(`gstack-review-dispatch: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
try { process.exit(main()); } catch (e) {
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exit(EXIT.fail); }
throw e;
}
+3 -2
View File
@@ -31,7 +31,8 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
--pass <n> full review passes already run in this invocation (default 0)
--last none|full|fix-check the step that just finished (default none: plan pass 1)
--fixed-blocking the finished step fixed a blocking finding (raises light to standard)
--fixed-below the finished step fixed findings below the floor
--fixed-below the finished step fixed findings below the floor (ignored after a fix-check: below-floor
fixes never repeat review, so only --fixed-blocking or --blocking-open stops there)
--blocking-open a blocking finding is still open in fix scope
--touched-gate a fix touched a gate-class path
--critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard)
@@ -131,7 +132,7 @@ function main(): number {
const plan = planReview({
...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor,
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines },
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines },
});
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
+14 -8
View File
@@ -3,12 +3,17 @@
* gstack-ship-budget — the /ship token budget and fresh-context handoff
* (docs/ship-budget.md). An admission cap: no step or dispatch starts unless
* used + open reservations + unknown + its projection + the tail and stop
* reserve fit under the cap.
* reserve fit under the cap. --mode observe (ship/DRIVER.md) only records:
* note at each program's end, and the cap is one hard stop. Every --reading
* takes --credits <cost.credits> beside it.
*
* start --record <path> [--token-cap <n|none>] [--meter capy|claude|estimate] [--reading <n>] [--prior-reading <n>]
* [--session <id>|--nonce <v>] [--reserve <n>] [--delegate auto|never] [--calibration <file>] [--ledger <path>]
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--prior-reading <n>] [--returned <id>[,<id>...]]
* settle --run|--record --reservation <id> [--unknown] [--reading <n>] release --run|--record --reservation <id>
* start --record <path> [--token-cap <n|none>] [--mode admit|observe] [--meter capy|claude|estimate] [--reading <n>] [--credits <c>]
* [--prior-reading <n>] [--session <id>|--nonce <v>] [--reserve <n>] [--delegate auto|never] [--calibration <file>] [--ledger <path>]
* note --run|--record --step <id> [--label <text>] --reading <n> [--credits <c>] [--dispatched-at <n> [--dispatched-credits <c>] --readers <k>]
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--credits <c>] [--prior-reading <n>]
* [--returned <id>[,<id>...]] [--runtime-lines <n>] [--dispatched-at <n> --readers <k>]
* settle --run|--record --reservation <id> [--unknown] [--reading <n>] [--credits <c>] [--dispatched-at <n> --readers <k>]
* release --run|--record --reservation <id>
* handoff --run|--record --from <step> --to <step> --base origin/<base> [--reviewed-tree <t>] [--trail <f> --trail-run <id>]
* [--sub-cap <n>] [--reading <n>] [--child-session <id>]
* launch --in <brief> (Claude Code: background headless child with explicit permissions)
@@ -24,18 +29,19 @@
* lib/ship-handoff.ts.
*/
import { BudgetError } from '../lib/ship-budget';
import { cmdCheck, cmdClose, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli';
import { cmdCheck, cmdClose, cmdNote, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli';
import { cmdHandoff, cmdLaunch, cmdReconcile, cmdReturn, cmdVerifyHandoff } from '../lib/ship-handoff';
const [command, ...rest] = process.argv.slice(2);
const ctx = { env: process.env, cwd: process.cwd() };
const commands: Record<string, () => Outcome> = {
start: () => cmdStart(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true),
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),
};
if (!command || command === '--help' || command === '-h' || !commands[command]) {
const text = (await Bun.file(import.meta.path).text()).split('\n').slice(2, 26).map(l => l.replace(/^ \* ?/, '')).join('\n');
const lines = (await Bun.file(import.meta.path).text()).split('\n');
const text = lines.slice(2, lines.indexOf(' */')).map(l => l.replace(/^ \* ?/, '')).join('\n');
(command && command !== '--help' && command !== '-h' ? console.error : console.log)(text);
process.exit(command && command !== '--help' && command !== '-h' ? 2 : 0);
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call, with
* STOP:/NEXT: lines wherever the agent must judge. Logic: lib/ship-preflight.ts.
*/
import { preflight } from '../lib/ship-preflight';
const out = await preflight(process.argv.slice(2), process.cwd());
(out.exit === 2 ? process.stderr : process.stdout).write(out.text);
process.exit(out.exit);
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-ship-release — /ship Step 12 (version), Steps 15-16 (finish) and Step 17 (push),
* with STOP:/NEXT: lines wherever the agent must judge. Logic: lib/ship-release.ts.
*/
import { release } from '../lib/ship-release';
const out = await release(process.argv.slice(2), process.cwd());
(out.exit === 2 ? process.stderr : process.stdout).write(out.text);
process.exit(out.exit);
+6 -2
View File
@@ -22,7 +22,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito
## Launch every gstack command with the GSTACK_LAUNCH line
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: <file>`: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. <that file>` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is part of the ok verdict: a passing run that cannot write it fails with `gstack: fail launch-file …`, and a failing run that rewrites the verdict removes it. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: <file>`: the same assignments as a sourceable script, plus the checkout's `bin/` appended to PATH (so a bare `gstack-ship-budget` resolves in a shell that sourced only this file), mode 0600, under the state root beside the install verdict. Apply them with `. <that file>` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is part of the ok verdict: a passing run that cannot write it fails with `gstack: fail launch-file …`, and a failing run that rewrites the verdict removes it. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session checks the install verdict the last run wrote for this checkout, HEAD and workflow, and the Bun on PATH. When either fails it prints an `INSTALL: fail …` row with its fix and `GSTACK_RESULT: skill=<s> status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for <that skill>` first.
@@ -38,11 +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. It starts the budget with `--mode observe`, and its review phases run their sections as written.
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.
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` from `tools.thread.get({ thread_id: "this" })` (one execute program returning only that number) into `gstack-ship-budget --reading <n>` and obeys `verdict=` (`docs/ship-budget.md`).
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`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation <id>` from check's `RESERVATION_ID:` line); (3) `BEFORE`, the counter's tokens and credits; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, its brief copied verbatim from the section that dispatches it; audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s for at most 240 s, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); (6) `gstack-ship-budget note --step <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (7) return only each reader's final output, or what still runs. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. During tests, pregate, readers and CI, re-issue `wait` with `duration_seconds: 240` and the operation ids: a call after a longer gap bills the whole context fresh (about 40x a cached call), and a ping costs a few percent of that. A small waiting child saves nothing: the parent's cache expires during its wait too.
## Plan, approve, ship
+3
View File
@@ -423,6 +423,9 @@ GSTACK_LAUNCH_FILE: <verdict file without .json>.env
`GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode
0600, beside the verdict under the state root; the PATH prepend is idempotent).
The file also appends the checkout's `bin/` directory to PATH, after everything
already there and only once, so a shell that sourced only this file runs a bare
`gstack-ship-budget` (GSTA-167: a /ship child got `command not found`, exit 127).
When the session starts from the Drive skill (a bootstrapper outside the
checkout), the file also exports `GSTACK_PREFERENCES_FILE`, the path of
`preferences.yaml` beside that skill's SKILL.md, which the `GSTACK_LAUNCH` line
+42 -1
View File
@@ -115,7 +115,7 @@ incomplete coverage, which blocks until the reviewer is rerun.
| a full pass that fixed only below-floor findings | one fix-check |
| a full pass at the cap that fixed a blocking finding | one fix-check |
| a full pass that leaves a blocking finding open (fixed or not) | stop: name it for the owner |
| a fix-check that found nothing blocking | done |
| a fix-check that found nothing blocking (below-floor fixes before it included) | done |
| a fix-check that found a blocking finding | stop: name it for the owner |
A **fix-check** is one reviewer reading the touched hunks, the fix list, the
@@ -357,8 +357,49 @@ email case-folded, so running it twice changes nothing. The `COAUTHORS: <n> in
<range>` line on stderr is the receipt; `--json` prints `{ range, authors,
trailers, appended }`.
## Verbatim dispatch
A driven `/ship` (ship/DRIVER.md) never lets the model write a reviewer's brief
or merge a reviewer's findings. The no-loss replays showed why: with briefs the
parent filled in by hand, only 37 of 112 dispatched prompts matched the rendered
ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
1. `render --role <role> [--name <n>] --out <dir>` cuts the brief out of
`ship/sections/review-army.md` or `ship/sections/adversarial.md` (the fix-check
from `scripts/resolvers/fix-check.ts`) and fills only declared slots: the
literal diff base, the plan's `DIFF_CMD` excludes, stack, test framework,
learnings, checklist paths, the Red Team's merged findings and specialist count,
and the outside voice's branch diff. A section that lost an anchor or placeholder
refuses (`BRIEF_RENDER_FAILED`) instead of rendering something else.
2. The dispatch program passes that file's content unchanged as the reader's
prompt and runs `sent` on what it passed: a hash that differs from the rendered
one is `BRIEF_MISMATCH`, and that reader's review does not count.
3. `collect` hashes each reader's raw output and appends its findings to
`merged.jsonl` with the label exactly as written (a JSON line's `severity`, or a
prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE wherever it
stands, headings and table rows included; a top-level list item under a label-only
heading takes that label). A phantom finding costs a fold; a lost one costs the gate.
4. Lead judgment writes `judged.jsonl`; `judge` refuses a set that drops a merged
finding, lowers its severity, folds it into a less severe duplicate, or adds or
promotes one without a reason (`JUDGMENT_REJECTED`). A merged file missing any id
the ledger collected is a drop. Each addition and promotion is logged with its reason.
5. `verify --ledger <f>` replays all of it from the run's ledger: rendered against
dispatched hashes per render (each render is one dispatch of its reader, so a
two-pass ledger checks pass-1 sends against pass-1 briefs; a later render prints as
`<reader>@2`), a collected output for every sent reader (`NOT_COLLECTED`
otherwise; a reader that failed is collected from its failure text, where any
label word becomes a finding the judge folds, and its coverage is logged
`unavailable` in the review-log row). A mismatched send re-sent
byte-exact before anything was collected is void and counted (`voided=N` on the
verdict line); one that produced an output stays `MISMATCH`. Then output hashes
against the merged lines they produced, and the judgment rerun against the
recorded judged set, covering every collected id. A ledger from a run that kept no
prompts prints `VERIFY: no brief records` (exit 3), never clean.
## Where this is enforced
- `lib/review-brief.ts`, `lib/review-ledger.ts`, `bin/gstack-review-dispatch`: [verbatim dispatch](#verbatim-dispatch) (`test/review-dispatch.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.
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
+93 -23
View File
@@ -21,23 +21,54 @@ room for the receipt steps, and the stop overhead (calibrated) keeps room to sto
the run has measured a step, the tail reserve scales with its measured step cost (a `--reserve`
you set stays as given); the stop overhead never scales.
## Observe mode (ship/DRIVER.md)
`start --mode observe` turns the ledger into a record. No step or dispatch is admitted, reserved or
delegated, so a run spends no call on the meter: each program the driver runs ends with
gstack-ship-budget note --run <id> --step <last step> [--label <what ran>] --reading <tokens> --credits <credits> \
[--dispatched-at <tokens before the readers> --dispatched-credits <credits before> --readers <n>]
which prints `NOTE: <step>[:<label>] used=<n> credits=<c> [readers=<n> readers_tokens=<n> readers_credits=<c>]`
and the `BUDGET:` line. The cap is one hard stop: a note (or a check) whose `used + unknown` is past
`--token-cap` prints the stop block and exits 3, and nothing else stops the run. No review-log marker
is written, so `gstack-review-log --start` takes no `--reservation`, and `close` audits no
unreserved dispatch. `close` prints `BUDGET_TIMELINE:`, one entry per note. The admission mode
below is what `ship/SKILL.md` runs; it is unchanged.
**Credits.** Every command that takes `--reading` also takes `--credits <cost.credits>` from the
same `thread.get`. Tokens count a cached read the same as a fresh one; credits are what the run
costs, including the full-context re-bill after a gap over five minutes (about 40x a cached call,
handoffs/GSTA-167/cache-probe.md). The first credits reading is the baseline; the `BUDGET:`,
`BUDGET_FINAL:` and record-block lines print `credits=<used>` once one exists, and a reading below
the last is refused like a token reading ([BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected)).
## Quickstart
**Capy.** Read the root counter in one execute program that returns only that number:
**Capy.** Read your own jam's counter inside the execute program that runs the command, never as a
call of its own:
```js
return (await tools.thread.get({ thread_id: "this" })).cost.tokens;
const n = (await tools.thread.get({ thread_id: "<your own jam id>" })).cost.tokens;
```
then
then pass it to
```bash
gstack-ship-budget start --record <record> --token-cap 25M --reading <n>
gstack-ship-budget check --run <id> --step 6 --next 7 --reading <n> [--prior-reading <n from your previous call>]
```
The counter rolls up every subagent of the thread, outside voices included; Capy exposes no
per-subagent counter, so other work in the same thread counts too (it only stops earlier).
`--prior-reading` must come from the call just before this one; a prior older than the run's last
reading spans several calls, so it is ignored (`CALL_UNKNOWN:`, `call=unknown`). Two readings in
one program measure nothing: Capy books a call after its program ends.
A jam's own counter includes its own subagents, outside voices too (measured 2026-10-30: +10.92M
across a six-reader dispatch with two ~0.35M parent calls between). `"this"` is the root thread,
whose counter also holds every sibling tree, so a subagent running /ship passes its own jam id;
a root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it
verifies with no reading and prints `METER: parent`; the parent's handoff and reconcile readings,
taken on its own counter (which holds the child), measure it.
**Claude Code.** `gstack-ship-budget start --record <record> --token-cap 25M --session <id>`. Without the
session id, `start --meter claude` prints a nonce: echo it once, then rerun with `--nonce <value>`.
@@ -59,12 +90,12 @@ With a cap set and no readable meter, start refuses
| `unknown` | Charges the meter cannot see (an external-CLI outside voice on Claude Code), counted at their reservation |
| `admission_cap` | The cap, or `none` |
| `tail` | Tail reserve (scaled by the step factor once measured) plus stop overhead, held back from every admission |
| `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (root counter), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch |
| `call` | The per-call charge: two Capy readings in consecutive calls, or the last Claude Code call's usage |
| `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (the agent's own jam counter, subagents included), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch |
| `call` | The per-call charge: two Capy readings in consecutive calls (a prior older than the run's last reading is ignored), or the last Claude Code call's usage |
| `call_floor`, `above_floor` | The host's per-call floor (see Delegation) and the charge above it, max(0, call − floor); the delegate rule compares `above_floor` |
| `next`, `projected` | What the check was for and its projection: the prior times the run's factor for that kind |
| `calibration` | The run's step and dispatch factors (see Calibration), or `prior` before the first measurement |
| `verdict` | `continue` (exit 0, prints `RESERVATION:` under a cap), `delegate` (exit 0), `stop` (exit 3) |
| `verdict` | `continue` (exit 0, prints `RESERVATION:` and the bare id as `RESERVATION_ID: <id>` under a cap), `delegate` (exit 0), `stop` (exit 3) |
Any exit other than 0 means the next step does not start. Exit 2 is a usage or validation
error on stderr with the code, the value and the repair. GSTACK_RESULT appears only on a stop,
@@ -80,18 +111,26 @@ gstack-ship-budget check --run <id> --step 9 --next dispatch:specialist,red-team
gstack-review-log --start review --reservation <id>
```
Each reader is priced at max(its projection, the run's measured mean for that role over settled
dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and
Name one role per `DISPATCH:` line (six specialists are `specialist` six times): drift and the
measured means are per reader. A reader's prior is the calibrated per-dispatch constant. The run's dispatch factor scales that prior, but never above
the larger of the prior and the run's clean per-reader mean, and the price never drops below the
role's own measured mean. When the whole batch does not fit, `check` admits the largest prefix that does and
prints `ADMIT: k of n (remaining=<roles>)`: dispatch those k, then re-check the remaining roles
once they settle. Only a batch whose first reader does not fit stops, and `close` flags remaining
roles no later check requested (`DROPPED:`, status=incomplete until the owner accepts).
Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`,
`fix-check`. `--finish <token>` is the pass's completion receipt: it settles that reservation and
`fix-check`, plus `docs`, the Step 14.5 documentation audit, which takes no review-log start and
settles in its program like the others. `--finish <token>` is the pass's completion receipt: it settles that reservation and
every other dispatch reservation started in the same step, so reviewers admitted one at a time
do not stay reserved. Name a reviewer that came back in your next check
(`check ... --returned <id>[,<id>...]`), or, when other work comes first, `settle --reservation <id>
--reading <n>` as it returns so its cost is measured alone. A check that moves to the next step
settles every dispatch a reviewer started in the step before.
do not stay reserved. The program that dispatches a batch measures it: it reads the counter right
before it starts the readers and again right after the last one settles, then runs `settle
--reservation <id> --dispatched-at <before> --reading <after> --readers <n>` (or names the batch in
a check with `--returned <id> --dispatched-at <before>`). The parent makes no model call inside that
program, so the pair holds only the readers. A check that moves to the next step settles every
dispatch a reviewer started in the step before. Only that pair is measured: `--finish`, a step
change, `--returned` or a settle without `--dispatched-at` (a later program), and a
`--dispatched-at` older than another command's reading, all settle without measuring, print
`UNMEASURED:` and leave the factor alone (their interval also holds the parent's own work).
`close` audits after the fact: each review-log row of the run with no reservation gets one
BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to accept
(`close --approved-by <who> --words "<their words>"`); a reservation is never created after the fact.
@@ -103,14 +142,19 @@ fresh session with the ship skill loaded, before any /ship work. The floor is `c
unit in `lib/ship-budget-calibration.json` (150k on Capy, where a fresh thread's calls cost
150–230k; 40k on Claude Code; 0 for the estimate meter, whose charge is always unknown), or
`start --call-floor <n>`, which the ledger records and every child inherits. At start, 100k or
more above the floor delegates the whole /ship. Before Step 7 and before Step 12, 150k or more
above the floor (or an unknown charge) delegates the next segment (7–11.5, 12–22). The BUDGET
more above the floor delegates the whole /ship. Before Step 9 and before Step 12, 150k or more
above the floor (or an unknown charge) delegates the next segment (9–11.5, 12–22). The BUDGET
line prints `call`, `call_floor` and `above_floor`, so each verdict can be checked:
1. `gstack-ship-budget handoff --run <id> --from 12 --to 22 --base origin/<base> --reading <n>` writes a
brief (mode 0600, under the run directory) with the pins (HEAD, merge base, working tree,
reviewed tree), the record with its six headings and start tokens, the decisions in scope
with the owner's words, the decision trail, the budget and the return contract. It reserves
with the owner's words, the decision trail, the budget and the return contract. Its `READ:`
lines are the child's whole reading list as line ranges: ship/SKILL.md's contract, the
segment's own steps, the self-check and Important Rules (never the preamble or section
index), and each segment section whole, so every reviewer brief is the section's own text.
The return contract names `--to` as the
segment's last step, so a 12-22 child returns `--to 22`. It reserves
the child's sub-cap: min(cap − used − reserved − unknown − the calibrated tail reserve and stop
overhead, the segment estimate), or `--sub-cap`. A child handing off reserves its whole
remaining sub-cap instead (cap − used − reserved − unknown − its stop overhead).
@@ -119,7 +163,12 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check
with explicit permission flags and the return file named.
3. The child runs `verify-handoff` first; a moved pin refuses
([HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved)). It admits against its sub-cap
with child_used = reading − reading at handoff and is the only writer of the record. The
with child_used = its own counter's reading − its reading at `verify-handoff` (a parent-metered
Capy child takes no reading, never delegates on an unknown charge, and returns `METER: parent`,
so `reconcile` charges the parent's counter delta since `handoff`) and is the only
writer of the record; `reconcile` records the `BUDGET used=` its return prints as the
handoff's child_used and settles its reservation (only `meter=estimate` adds it to the
parent's used; the Capy and Claude Code meters already hold it, step 4). The
context rule never stops a child. When it fires at one of the child's boundaries:
- once the child has completed a step since `verify-handoff`, `verdict=delegate` hands its
remaining steps (the next step through the end of its segment) to a fresh grandchild
@@ -135,7 +184,8 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check
grandchild writing after the parent moves on.
4. `gstack-ship-budget reconcile --run <id> --return <file> --reading <n>`; idempotent per handoff hash,
so a duplicate return charges nothing twice. A refused, incomplete or interrupted child
exits 3: regenerate the handoff or stop. Up a chain each token is charged once: the Capy
exits 3 and stops the run (`reason=child_<status>`): every check refuses until a regenerated
child reconciles complete or `resume` runs; regenerate the handoff or stop. Up a chain each token is charged once: the Capy
counter already includes the whole subtree, a session `launch` starts joins the Claude Code
meter of every ancestor, and under `meter=estimate` a child's returned `used` carries its
grandchild's.
@@ -143,6 +193,14 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check
`--delegate never` turns delegate into stop for a parent on a host without children; a child
continues with a `CONTEXT_HIGH:` note instead.
**Small runs keep their segment.** `check --runtime-lines <n>` (runtime_lines from
`gstack-review-plan --json`, which `gstack-ship-preflight` prints) continues at the Step 9 and
Step 12 boundaries, with a `SMALL_RUN:` line, when the diff is light (at most 50 runtime lines)
and the segment's calibrated calls at this run's per-call charge cost at most 12M. A child costs
a fixed 2–4.5M (gstack-evals #34: boot, install check, verify-handoff, section reads,
reconcile) and saves at most about 3/8 of each parent call, so a segment under 4.5M ÷ 0.375
cannot pay for one. Without the flag the rule above applies unchanged.
## Stop and resume
A stop prints
@@ -177,16 +235,28 @@ Claude-metered /ship completes. Each unit's `call_floor` is calibrated separatel
evidence in `call_floor_why`; a floor is the largest 10,000 multiple at or below its smallest
observed fresh call.
These constants are the prior. Each settled step or dispatch is measured on the next fresh
reading (a step from its check's reading to the next one; dispatches settled together as one
group, split by role), and prints
These constants are the prior. A dispatch is measured from its dispatch program's
`--dispatched-at` reading to the reading it was settled with in that program (dispatches with one
pair split it per reader, `--readers` or one per role). A step is
measured at the next step change, from its check's reading minus the dispatches reserved inside
it, so the parent's interleaved calls stay in the step; a step holding an unmeasured dispatch is
not measured. Each measurement prints
DRIFT: id=<reservation> kind=step|dispatch next=<id> projected=<n> actual=<n> ratio=<actual/projected>
and each reservation that cannot be measured prints
`UNMEASURED: id=<reservation> kind=step|dispatch next=<id> reason=settled_without_reading|no_dispatch_reading|reading_between|dispatch_unmeasured`.
From then on the run projects steps and dispatches with its own factor: the largest
actual/prior ratio among its last three measurements of that kind, times 1.5. The step factor
also scales the default tail reserve. Dispatches keep the prior until the first
dispatch is measured, so under a tight cap admit one reviewer first and let it calibrate the rest.
On the Capy meter a step's prior is its calibrated call count (`step_calls`) times the run's own
median per-call charge over its last five measured calls (each `--reading` minus a fresh
`--prior-reading`), once three are measured, unscaled (`calibration=…,call:<median>`); until then
it uses the calibrated `per_call`. A step prior never carries a dispatch: each audit or reviewer
batch is reserved by its own check, and a child's segment estimate counts them through
`segment_dispatches`.
`close` prints `BUDGET_DRIFT:` with the run's projected and actual totals per kind. The estimate
meter has no readings, so it always projects at the prior.
+4 -2
View File
@@ -994,8 +994,10 @@ construction:
body, so a finding that quotes a credential stays `unfiled (publication scan
refused: …)`; `unfiled (<reason>)` without `gh`) and `render` writes it under
`## Deferred findings` in the PR body. Below-floor findings outside the diff,
and every below-floor finding from pass 2 on, are `scope: note` and go under
`## Review notes` in the PR body.
every below-floor finding from pass 2 on, and every below-floor finding from
Step 11's voices or a fix-check (`raised_by` `adversarial-native`,
`outside-voice` or `fix-check`, even classified at `--pass 1`) are
`scope: note` and go under `## Review notes` in the PR body.
2. **Pass 2 and later review the range since the previous pass.** Each pass saves
`PASS_HEAD`; the next pass sets `RANGE_BASE` to it, diffs `RANGE_BASE..HEAD`
for every reviewer (the core checklist, specialists, Red Team, the outside
+5 -5
View File
@@ -1846,17 +1846,17 @@ owner's call: run the printed `RECOVER:` line; its resume needs
<a id="budget-reading-missing"></a>
### `gstack-ship-budget: ... (BUDGET_READING_MISSING)`
**Meaning.** The run's meter is the Capy root counter, a cap is set, and the command got no
**Meaning.** The run's meter is the agent's own Capy jam counter, a cap is set, and the command got no
`--reading`. Exit 2. No gstack binary can call Capy's API, so the agent reads it.
**Fix.** In one execute program return only
`(await tools.thread.get({ thread_id: "this" })).cost.tokens` and pass it as `--reading <n>`.
**Fix.** In the execute program that runs the command, read
`(await tools.thread.get({ thread_id: "<your own jam id>" })).cost.tokens` and pass it as `--reading <n>`.
<a id="budget-reading-rejected"></a>
### `gstack-ship-budget: ... (BUDGET_READING_REJECTED)`
**Meaning.** The reading breaks the run's meter contract: it is lower than the last reading
of the same epoch, `--prior-reading` is above `--reading`, or `--reading` went to a meter that
of the same epoch, `--prior-reading` or `--dispatched-at` is above `--reading`, or `--reading` went to a meter that
reads its own source (Claude Code journals, the estimate). Exit 2; nothing changed.
**Fix.** Pass the counter the run started with (the BUDGET line names meter, unit and epoch).
@@ -1908,7 +1908,7 @@ and actual value. Main moving alone keeps the merge base and never refuses. Exit
wrote its return with status=refused.
**Fix.** The parent reconciles the refusal and regenerates the handoff at the current tree
(`gstack-ship-budget handoff ...`) or stops.
(`gstack-ship-budget handoff ...`) or stops. The reconcile stops the run (`reason=child_refused`): every check refuses until a regenerated child reconciles complete or the owner runs `gstack-ship-budget resume`.
## Browser
+1 -1
View File
@@ -192,7 +192,7 @@ never on polling wakes.
A parent bounds a /ship run with `--token-cap <n>` (or `GSTACK_SHIP_TOKEN_CAP`).
It is an admission cap: /ship meters usage, reserves every step and dispatch
batch before it starts, and stops `status=incomplete` with a `RECOVER:` line
instead of starting work that would cross the cap. Long runs hand Steps 7–11.5
instead of starting work that would cross the cap. Long runs hand Steps 9–11.5
and 12–22 to fresh-context children. The flow, the BUDGET line and the result
codes are in [ship-budget.md](ship-budget.md).
+1 -1
View File
@@ -17,7 +17,7 @@ export const BUDGET_RESULT_CODES = {
BUDGET_READING_MISSING: {
anchor: 'budget-reading-missing',
summary: 'the Capy meter needs a fresh counter reading and none was passed',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "this" }) in one execute program and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
},
BUDGET_READING_REJECTED: {
anchor: 'budget-reading-rejected',
+130
View File
@@ -0,0 +1,130 @@
/**
* review-brief — every /ship reviewer brief rendered from its section's own bytes (GSTA-167
* no-loss gate). The text comes from ship/sections/review-army.md and ship/sections/adversarial.md
* at render time, cut out between fixed anchors; only the declared slots below are filled. The
* dispatch program passes the rendered file verbatim as the reader's prompt, and
* lib/review-ledger.ts proves it did by hash. Nothing here is composed by the model.
*
* Slots by role:
* specialist name, checklist, diff_base, exclude, stack, test_fw, learnings (security adds the defensive framing)
* specialist-batched checklists (comma list), diff_base, exclude, stack, test_fw, learnings
* red-team checklist, diff_base, findings (the merged specialist lines), specialists (their count)
* adversarial-native diff_base
* outside-voice diff_base, diff (the branch diff, which the bin reads from git)
* fix-check fix_list, hunks, tests, callers (scripts/resolvers/fix-check.ts)
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
export const BRIEF_ROLES = ['specialist', 'specialist-batched', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check'] as const;
export type BriefRole = typeof BRIEF_ROLES[number];
export type Slots = Record<string, string | undefined>;
/** The hash every brief and every dispatched prompt is compared by: sha256 of the text without trailing whitespace. */
export function promptHash(text: string): string {
return createHash('sha256').update(text.replace(/\r\n/g, '\n').replace(/\s+$/, '')).digest('hex');
}
export class BriefError extends Error {}
/** The text strictly between start and end, end searched after start; both anchors are required. */
function between(text: string, start: string, end: string, what: string): string {
const i = text.indexOf(start);
if (i < 0) throw new BriefError(`${what}: start anchor not found in the section (${JSON.stringify(start.slice(0, 40))})`);
const j = text.indexOf(end, i + start.length);
if (j < 0) throw new BriefError(`${what}: end anchor not found in the section (${JSON.stringify(end.slice(0, 40))})`);
return text.slice(i + start.length, j);
}
/** A quoted prompt body as the reader receives it: the section escapes its inner quotes and line breaks. */
const unquote = (s: string) => s.replace(/\\"/g, '"').replace(/\\n/g, '\n');
export interface SectionTexts { specialist: string; framing: string; fixtures: string; redTeam: string; native: string; outside: string }
/** Cut every brief body out of the installed sections under root (the directory holding ship/). */
export function sectionTexts(root: string): SectionTexts {
const read = (rel: string) => {
try { return fs.readFileSync(path.join(root, rel), 'utf8').replace(/\r\n/g, '\n'); } catch { throw new BriefError(`cannot read ${rel} under ${root}`); }
};
const army = read('ship/sections/review-army.md');
const adv = read('ship/sections/adversarial.md');
const specialist = unquote(between(army, '\n"You are a specialist code reviewer.', 'Past learnings: {learnings or \'none\'}"', 'specialist brief'));
return {
specialist: `You are a specialist code reviewer.${specialist}Past learnings: {learnings or 'none'}`
.replace(/ \(`<diff-base>`: write the sha in before dispatch[\s\S]*?on `DIFF_EXCLUDE: none`\)/, ''),
framing: unquote(between(army, 'Begin the prompt with:\n"', '"\nand replace its full-diff command', 'defensive framing')),
fixtures: unquote(between(army, 'and replace its full-diff command with: "', '"\nEvery other specialist', 'fixture handling')),
redTeam: unquote(between(army, 'fixture handling from item 4 of the specialist dispatch: "', 'don\'t cover."', 'red-team brief')) + 'don\'t cover.',
native: unquote(between(adv, 'Subagent prompt:\n"', '"\n\nPresent findings', 'adversarial-native brief')),
outside: unquote(between(adv, 'Outside prompt (supply repository context from the parent):\n\n"', '"\n\nWrite the **complete prompt and context**', 'outside-voice brief')),
};
}
const SHA = /^[0-9a-f]{7,40}$/;
const FENCE = '`'.repeat(3);
function need(slots: Slots, key: string): string {
const v = slots[key];
if (v === undefined || v === '') throw new BriefError(`missing slot ${key}`);
return v;
}
function fill(text: string, pairs: Array<[string, string]>, what: string): string {
let out = text;
for (const [from, to] of pairs) {
if (!out.includes(from)) throw new BriefError(`${what}: placeholder ${from} is not in the section text (the section changed; update lib/review-brief.ts)`);
out = out.split(from).join(to);
}
return out;
}
const SPECIALIST_DIFF = 'run\n`DIFF_BASE=<diff-base> && git diff "$DIFF_BASE" <exclude>` to get the diff';
const PLACEHOLDERS = ['<diff-base>', '<exclude>', '<base>', '{checklist path}', '{STACK}', '{TEST_FW}', '{merged findings summary}', '{red-team checklist path}', '{learnings'];
/** Render one reader's brief. Throws BriefError on a missing slot, a section that no longer carries an anchor or placeholder, or a placeholder left unfilled. */
export function renderBrief(role: BriefRole, slots: Slots, t: SectionTexts): string {
const text = renderRole(role, slots, t);
const left = role === 'fix-check' || role === 'outside-voice' ? [] : PLACEHOLDERS.filter(p => text.includes(p));
if (left.length) throw new BriefError(`${role}: unfilled placeholder ${left.join(', ')}`);
return text;
}
function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
const diffBase = role === 'fix-check' ? '' : need(slots, 'diff_base');
if (diffBase && !SHA.test(diffBase)) throw new BriefError(`slot diff_base must be a literal commit sha, got ${JSON.stringify(diffBase)}`);
const common = (body: string, checklist: string) => fill(body, [
...(checklist ? [['{checklist path}', checklist] as [string, string]] : []), ['<diff-base>', diffBase],
['{TEST_FW}', slots.test_fw || 'unknown'], ['{STACK}', slots.stack || 'unknown'], ['{learnings or \'none\'}', slots.learnings?.trim() || 'none'],
], role).split(' <exclude>`').join(slots.exclude ? ' ' + slots.exclude + '`' : '`');
switch (role) {
case 'specialist': {
const name = need(slots, 'name');
const checklist = need(slots, 'checklist');
if (name !== 'security') return common(t.specialist, checklist);
const body = fill(t.specialist, [[SPECIALIST_DIFF, 'set `DIFF_BASE=<diff-base>` first. ' + t.fixtures + ' Then read the diff that way']], role);
return `${t.framing}\n\n${common(body, checklist)}`;
}
case 'specialist-batched': {
const lists = need(slots, 'checklists').split(',').map(s => s.trim()).filter(Boolean);
const body = fill(t.specialist, [['Read the checklist at {checklist path}', `Read each checklist at ${lists.join(', ')} (tag each finding's "specialist" with the name of the checklist it came from)`]], role);
return common(body, '');
}
case 'red-team': {
const body = fill(t.redTeam, [['{merged findings summary}', (slots.findings ?? '').trim() || '(none)'], ['by N specialists', `by ${need(slots, 'specialists')} specialists`],
['{red-team checklist path}', need(slots, 'checklist')], ['<diff-base>', diffBase]], role);
return `${t.framing}\n\n${t.fixtures}\n\n${body}`;
}
case 'adversarial-native':
return fill(t.native, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role);
case 'outside-voice': {
const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role);
return [body, '', 'Give each finding a severity: Critical, High, Medium or Low.', '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
}
case 'fix-check':
return renderFixCheckPrompt({ fixList: need(slots, 'fix_list'), touchedHunks: need(slots, 'hunks'), testResults: need(slots, 'tests'), callers: slots.callers ?? '' });
}
}
/** The file a rendered brief is written to under the run's briefs directory. */
export function briefFile(role: BriefRole, name?: string): string {
return `${role}${name ? `-${name.replace(/[^a-zA-Z0-9._-]/g, '_')}` : ''}.prompt.md`;
}
+218
View File
@@ -0,0 +1,218 @@
/**
* review-ledger — the proof that /ship's reviewers got their briefs unchanged and that the
* parent changed nothing they returned (GSTA-167 no-loss gate). One JSONL ledger per run:
*
* rendered a brief gstack-review-dispatch render wrote (role, name, file, sha256)
* sent the prompt a dispatch program actually passed, by hash; a mismatch fails the pass
* output a reader's raw output (sha256) and the merged.jsonl ids collect wrote from it
* judgment each lead-judgment change: an added or promoted finding, with its reason
* judge the verdict on the parent's judged set (ok, or the dropped and downgraded ids)
*
* Findings reach merged.jsonl only through collect, with each voice's label exactly as written.
* The parent's judged set may add or promote a finding and may fold a duplicate into one at least
* as severe; it never drops or downgrades one. verify replays every check from the ledger alone.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { promptHash } from './review-brief';
export interface LedgerEvent { event: 'rendered' | 'sent' | 'output' | 'judgment' | 'judge'; [k: string]: unknown }
export const readerKey = (role: string, name?: string) => (name ? `${role}/${name}` : role);
export function appendEvent(ledger: string, e: LedgerEvent): void {
fs.appendFileSync(ledger, JSON.stringify({ ...e, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
}
export function readLedger(ledger: string): LedgerEvent[] {
let text: string;
try { text = fs.readFileSync(ledger, 'utf8'); } catch { return []; }
return text.split('\n').filter(l => l.trim()).flatMap(l => { try { return [JSON.parse(l) as LedgerEvent]; } catch { return []; } });
}
const lastRendered = (events: LedgerEvent[], reader: string) => [...events].reverse().find(e => e.event === 'rendered' && e.reader === reader);
/** Compare a dispatched prompt with the brief rendered for that reader; records the sent event. */
export function recordSent(ledger: string, reader: string, sentText: string): { ok: boolean; rendered: string | null; sent: string } {
const r = lastRendered(readLedger(ledger), reader);
const sent = promptHash(sentText);
const rendered = typeof r?.sha256 === 'string' ? r.sha256 : null;
const ok = rendered === sent;
appendEvent(ledger, { event: 'sent', reader, sha256: sent, rendered, match: ok });
return { ok, rendered, sent };
}
/** Severity ranks across the voices' vocabularies; a label with no severity (FIXABLE, INVESTIGATE) ranks 0. */
const RANKS: Record<string, number> = { critical: 4, p0: 4, high: 3, p1: 3, medium: 2, p2: 2, informational: 2, low: 1, p3: 1, fixable: 0, investigate: 0 };
export function rank(label: string): number | null {
const r = RANKS[label.trim().replace(/^\[|\]$/g, '').toLowerCase()];
return r === undefined ? null : r;
}
const LABELS = 'CRITICAL|INFORMATIONAL|Critical|Informational|High|HIGH|Medium|MEDIUM|Low|LOW|P[0-3]|FIXABLE|INVESTIGATE';
/** A label anywhere on a line makes it a finding, headings and table rows included: losing a finding costs more than keeping a phantom one, which the judge may fold. */
const ANY_LABEL = new RegExp(`(?<![A-Za-z0-9])(${LABELS})(?![A-Za-z0-9])`);
/** A heading that is only a label ("## High", "**Critical findings**") gives its label to the top-level list items under it, until the next heading or bold-only line. */
const LABEL_HEADING = new RegExp(`^(?:#{1,6}\\s*|\\*\\*)(${LABELS})(?:\\s+(?:findings?|severity|issues?))?\\s*:?\\**\\s*$`);
const HEADING = /^(?:#{1,6}\s|\*\*[^*]+\*\*:?$)/;
const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+\S/;
const stronger = (a: string, b: string) => ((rank(a) ?? 0) >= (rank(b) ?? 0) ? a : b);
export interface MergedLine { id: string; reader: string; label: string; raw: string; output_sha256: string }
/** One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or FIXABLE/INVESTIGATE label, and each top-level list item under a label-only heading (the stronger of the two labels). */
export function parseFindings(reader: string, output: string): Array<{ label: string; raw: string }> {
const out: Array<{ label: string; raw: string }> = [];
let section: string | null = null;
for (const line of output.replace(/\r\n/g, '\n').split('\n')) {
const t = line.trim();
if (!t || /^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) continue;
const heading = LABEL_HEADING.exec(t);
if (heading) { section = heading[1]!; continue; }
if (line === line.trimStart() && HEADING.test(t)) section = null;
if (t.startsWith('{')) {
try {
const o = JSON.parse(t) as Record<string, unknown>;
if (typeof o.severity === 'string') { out.push({ label: o.severity, raw: t }); continue; }
} catch { /* prose that starts with a brace */ }
}
const inline = ANY_LABEL.exec(t)?.[1];
const inherited = section && line === line.trimStart() && LIST_ITEM.test(t) ? section : undefined;
const label = inline && inherited ? stronger(inherited, inline) : inline ?? inherited;
if (label) out.push({ label, raw: t });
}
return out;
}
/** collect: hash the raw output, append its findings to merged.jsonl and record both. */
export function collect(ledger: string, reader: string, output: string, mergedFile: string, outputFile?: string): { sha: string; lines: MergedLine[] } {
const sha = promptHash(output);
const prior = readLedger(ledger).filter(e => e.event === 'output' && e.reader === reader).length;
const lines = parseFindings(reader, output).map((f, i) => ({ id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, raw: f.raw, output_sha256: sha }));
fs.appendFileSync(mergedFile, lines.map(l => JSON.stringify(l) + '\n').join(''), { mode: 0o600 });
appendEvent(ledger, { event: 'output', reader, sha256: sha, file: outputFile ? path.resolve(outputFile) : null, merged: path.resolve(mergedFile), ids: lines.map(l => l.id) });
return { sha, lines };
}
export function readJsonl<T>(file: string): T[] {
return fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l) as T);
}
export interface JudgedLine { id: string; severity: string; reason?: string; duplicate_of?: string }
export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[] }
/** The lead-judgment rule: every merged finding survives at its severity or above; additions and promotions carry a reason. */
export function judge(merged: MergedLine[], judged: JudgedLine[]): Judgment {
const byId = new Map(judged.map(j => [j.id, j]));
const mergedIds = new Set(merged.map(m => m.id));
const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [] };
for (const j of judged) if (rank(j.severity) === null) res.invalid.push(j.id);
for (const m of merged) {
const j = byId.get(m.id);
if (!j) { res.dropped.push(m.id); continue; }
const target = j.duplicate_of ? byId.get(j.duplicate_of) : j;
// A duplicate folds into a different line that stands on its own; a self-fold, a cycle or a chain keeps no survivor.
if (!target || (j.duplicate_of && (j.duplicate_of === j.id || target.duplicate_of))) { res.dropped.push(m.id); continue; }
const from = rank(m.label) ?? 0;
const to = rank(target.severity);
if (to === null) continue;
if (to < from) res.downgraded.push(m.id);
else if (j.duplicate_of) res.folded.push(m.id);
else if (to > from) { res.promoted.push(m.id); if (!j.reason?.trim()) res.unexplained.push(m.id); }
}
for (const j of judged) if (!mergedIds.has(j.id) && !j.duplicate_of) { res.added.push(j.id); if (!j.reason?.trim()) res.unexplained.push(j.id); }
res.ok = !res.dropped.length && !res.downgraded.length && !res.unexplained.length && !res.invalid.length;
return res;
}
/** judge, logged: one judgment event per addition and promotion, then the verdict. */
export function recordJudgment(ledger: string, merged: MergedLine[], judged: JudgedLine[], judgedFile: string, mergedFile: string): Judgment {
const res = judge(merged, judged);
const given = new Set(merged.map(m => m.id));
const absent = collectedIds(readLedger(ledger)).filter(id => !given.has(id));
if (absent.length) { res.dropped.push(...absent); res.ok = false; }
const byId = new Map(judged.map(j => [j.id, j]));
const labels = new Map(merged.map(m => [m.id, m.label]));
if (res.ok) {
for (const id of res.added) appendEvent(ledger, { event: 'judgment', kind: 'added', id, to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
for (const id of res.promoted) appendEvent(ledger, { event: 'judgment', kind: 'promoted', id, from: labels.get(id), to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
}
appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length });
return res;
}
/** Every id collect recorded, in order. */
const collectedIds = (events: LedgerEvent[]) => events.filter(e => e.event === 'output').flatMap(e => (Array.isArray(e.ids) ? (e.ids as string[]) : []));
export interface VerifyRow { kind: 'BRIEF' | 'OUTPUT' | 'JUDGE'; reader: string; status: string; detail: string }
/** Replay every check from a finished run's ledger: briefs dispatched as rendered, outputs merged as returned, judgment without loss. */
export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; briefs: number; voided: number } {
const events = readLedger(ledger);
const rows: VerifyRow[] = [];
const short = (s: unknown) => (typeof s === 'string' ? s.slice(0, 12) : '-');
// Each render opens one dispatch of its reader (a pass); its sends and outputs run until that reader's next render.
const renders = events.flatMap((e, i) => (e.event === 'rendered' ? [i] : []));
const episodes = renders.map((at, n) => {
const reader = String(events[at]!.reader);
const next = renders.slice(n + 1).find(j => events[j]!.reader === reader) ?? events.length;
const nth = renders.slice(0, n + 1).filter(j => events[j]!.reader === reader).length;
return { at, next, reader, label: nth > 1 ? `${reader}@${nth}` : reader };
});
let voided = 0;
for (const p of episodes) {
const r = events[p.at]!;
const inside = events.map((e, i) => ({ e, i })).filter(({ e, i }) => i > p.at && i < p.next && e.reader === p.reader);
const sent = inside.filter(({ e }) => e.event === 'sent');
// A later render into the same path overwrote this one's file by design; only the newest render of a path can check it.
const overwritten = renders.some(j => j > p.at && events[j]!.file === r.file);
let fileNow = '';
try { fileNow = !overwritten && typeof r.file === 'string' && fs.existsSync(r.file) ? promptHash(fs.readFileSync(r.file, 'utf8')) : ''; } catch { /* unreadable */ }
// A mismatched send is void when the reader was re-sent byte-exact before anything was collected from it.
let mismatched = 0, epVoided = 0;
for (const s of sent.filter(({ e }) => e.sha256 !== r.sha256)) {
const after = inside.find(({ e, i }) => i > s.i && (e.event === 'output' || (e.event === 'sent' && e.sha256 === r.sha256)));
if (after?.e.event === 'sent') epVoided++; else mismatched++;
}
voided += epVoided;
const lastMatch = [...sent].reverse().find(({ e }) => e.sha256 === r.sha256);
const collected = !!lastMatch && inside.some(({ e, i }) => e.event === 'output' && i > lastMatch.i);
const status = !sent.length ? 'NOT_SENT' : mismatched ? 'MISMATCH' : collected ? 'MATCH' : 'NOT_COLLECTED';
const tampered = fileNow && fileNow !== r.sha256 ? ' file_changed_since_render' : '';
rows.push({ kind: 'BRIEF', reader: p.label, status: tampered ? 'MISMATCH' : status, detail: `rendered=${short(r.sha256)} sent=${sent.map(({ e }) => short(e.sha256)).join(',') || '-'}${epVoided ? ` voided=${epVoided}` : ''}${tampered}` });
}
for (const [i, o] of events.entries()) {
if (o.event !== 'output') continue;
const reader = String(o.reader);
const p = episodes.find(q => q.reader === reader && q.at < i && i < q.next);
let status = 'MATCH';
const notes: string[] = [];
const lastSent = p ? events.slice(p.at + 1, i).filter(e => e.event === 'sent' && e.reader === reader).at(-1) : undefined;
if (!p) { status = 'MISMATCH'; notes.push('brief_not_rendered'); }
else if (!lastSent || lastSent.sha256 !== events[p.at]!.sha256) { status = 'MISMATCH'; notes.push('brief_not_sent_verbatim'); }
if (typeof o.file === 'string' && fs.existsSync(o.file) && promptHash(fs.readFileSync(o.file, 'utf8')) !== o.sha256) { status = 'MISMATCH'; notes.push('output_file_changed'); }
const ids = Array.isArray(o.ids) ? (o.ids as string[]) : [];
let lines: MergedLine[] = [];
try { lines = typeof o.merged === 'string' ? readJsonl<MergedLine>(o.merged).filter(l => ids.includes(l.id)) : []; } catch { notes.push('merged_unreadable'); status = 'MISMATCH'; }
if (lines.length !== ids.length) { status = 'MISMATCH'; notes.push(`merged_lines=${lines.length}/${ids.length}`); }
if (lines.some(l => l.output_sha256 !== o.sha256)) { status = 'MISMATCH'; notes.push('merged_line_from_other_output'); }
rows.push({ kind: 'OUTPUT', reader: p?.label ?? reader, status, detail: `sha=${short(o.sha256)} findings=${ids.length}${notes.length ? ' ' + notes.join(' ') : ''}` });
}
const lastJudge = events.map(e => e.event).lastIndexOf('judge');
if (lastJudge >= 0) {
const v = events[lastJudge]!;
const notes: string[] = [];
const late = events.slice(lastJudge + 1).filter(e => e.event === 'output').map(e => String(e.reader));
if (late.length) notes.push(`output_after_judge=${late.join(',')}`);
const judgedIds = new Set(Array.isArray(v.merged_ids) ? (v.merged_ids as string[]) : []);
const missing = collectedIds(events.slice(0, lastJudge)).filter(id => !judgedIds.has(id));
if (missing.length) notes.push(`merged_ids_missing=${missing.join(',')}`);
let rerun: Judgment | null = null;
try {
const judgedText = fs.readFileSync(String(v.judged), 'utf8');
if (promptHash(judgedText) !== v.judged_sha256) notes.push('judged_changed_since_judge');
rerun = judge(readJsonl<MergedLine>(String(v.merged)), readJsonl<JudgedLine>(String(v.judged)));
} catch { notes.push('judged_or_merged_unreadable'); }
if (rerun && !rerun.ok) notes.push(`rerun_rejects dropped=${JSON.stringify(rerun.dropped)} downgraded=${JSON.stringify(rerun.downgraded)}`);
const status = !v.ok ? 'REJECTED' : notes.length ? 'MISMATCH' : 'OK';
rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${notes.length ? ' ' + notes.join(' ') : ''}` });
} else if (events.some(e => e.event === 'output')) rows.push({ kind: 'JUDGE', reader: '-', status: 'MISSING', detail: 'outputs were collected but no judged set was checked' });
const clean = episodes.length > 0 && rows.every(r => r.status === 'MATCH' || r.status === 'OK');
return { rows, clean, briefs: episodes.length, voided };
}
+8 -3
View File
@@ -36,6 +36,8 @@ export interface ScopedFinding extends ScopeFinding { locus: Locus; kind: Kind;
/** Who can raise a finding: the core checklist, QA probes, a specialist by name and the review-depth roles; anything else is `unknown`. */
const RAISERS: readonly string[] = ['core', 'qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', ...SPECIALISTS];
const RAISER_ALIASES: Record<string, string> = { adversarial: 'adversarial-native' };
/** Reviewers that read after pass 1 finished (Step 11's voices, a fix-check): only a blocking finding of theirs is ever `fix`. */
const AFTER_PASS_RAISERS: readonly string[] = ['adversarial-native', 'outside-voice', 'fix-check'];
/** The reviewer that raised a finding, for attributing passes later (GSTA-152 D6): its `raised_by` when known, else its `specialist` tag, else `unknown`. */
export function raisedBy(finding: ScopeFinding): string {
@@ -154,21 +156,24 @@ export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks
* `frozen` pass 1: fix the diff, defer blocking findings in unchanged code,
* note the rest. `frozen` pass 2+: fix blocking findings in the range since
* the previous pass or in the diff; defer blocking findings outside; note
* everything else.
* everything else. Step 11's voices and a fix-check read after pass 1 has
* finished, so even at `--pass 1` their below-floor findings are notes and
* never send the loop back for another pass (CREW rules 23 and 30a).
*/
export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInput): ScopedFinding[] {
return findings.map(finding => {
const kind = kindOf(finding);
const blocking = isBlocking(finding, kind);
const raised = raisedBy(finding);
const locus = locusOf(finding, input.diffHunks, input.pass > 1 ? input.rangeHunks : null, input.context);
// A blocking finding nobody could place cannot be filed against a path; the diff's author resolves it now.
const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined;
let scope: ScopeAction;
if (input.policy === 'all') scope = 'fix';
else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer';
else if (input.pass <= 1) scope = 'fix';
else if (input.pass <= 1 && !AFTER_PASS_RAISERS.includes(raised)) scope = 'fix';
else scope = blocking ? 'fix' : 'note';
return { ...finding, locus, kind, blocking, scope, raised_by: raisedBy(finding) };
return { ...finding, locus, kind, blocking, scope, raised_by: raised };
});
}
+104
View File
@@ -0,0 +1,104 @@
/**
* ship-audit — gstack-ship-preflight --audit (GSTA-167 variant G): the parent's single
* reads and the deterministic part of /ship's non-blocking audits in one call. It writes
* the branch diff once to <out>/diff.patch, the commits to <out>/commits.txt and the
* TODOS.md items that name a touched path to <out>/todos.md, maps each changed runtime
* file to the tests that name it (Step 7), lists the plan candidates Step 8 offers, and
* prints which audit still needs a reader. The parent reads these small files and never
* TODOS.md or the diff whole; the audits' briefs stay their sections' own.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { resolveStateRoot } from './state-root';
import { bin, gh, git, hostedOrigin, Report, short } from './ship-bin-common';
const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i;
const TEST_PATH = /(^|\/)(test|tests|__tests__|spec)\/|\.(test|spec)\.[a-z0-9]+$/i;
const TEST_SPECS = ['test', 'tests', '__tests__', 'spec', '*.test.*', '*.spec.*'];
const TODOS_HITS_MAX = 120;
const stem = (p: string) => path.basename(p).replace(/\.[^.]+$/, '');
const readOr = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
/** TODOS.md lines naming a touched path or its file stem (a stem under 4 characters only by full path), under their nearest heading. */
export function todosHits(text: string, touched: readonly string[]): string[] {
const keys = [...new Set(touched.flatMap(p => [p, ...(stem(p).length >= 4 ? [stem(p)] : [])]))];
const out: string[] = [];
let heading = '', printed = '';
for (const line of text.split('\n')) {
if (/^#{1,6} /.test(line)) { heading = line; continue; }
if (!keys.some(k => line.includes(k))) continue;
if (heading && heading !== printed) { out.push(heading); printed = heading; }
out.push(line);
if (out.length >= TODOS_HITS_MAX) break;
}
return out;
}
/** Step 8's discovery: the PR body's Plan: binding, docs/designs/ files this branch changed or that name it, then personal plan files naming it. */
function planCandidates(cwd: string, fork: string, branch: string): string[] {
const found: string[] = [];
if (hostedOrigin(cwd)) {
const bound = /^\s*Plan:\s*(\S+)/m.exec(gh(cwd, ['pr', 'view', '--json', 'body', '-q', '.body']).stdout.replace(/[\r`]/g, ''))?.[1];
if (bound) found.push(`PLAN_BINDING: ${bound}`);
}
const designs = new Set([
...git(cwd, ['diff', '--name-only', '--diff-filter=AM', fork, '--', 'docs/designs/*.md']).stdout.split('\n'),
...(branch ? git(cwd, ['grep', '-l', '-F', '-e', branch, '--', 'docs/designs/*.md']).stdout.split('\n') : []),
].filter(Boolean));
for (const d of designs) found.push(`PLAN_CANDIDATE: ${d}`);
if (!branch) return found;
const slug = /^SLUG=(\S+)/m.exec(bin(cwd, 'gstack-slug', [], 30_000).stdout)?.[1] ?? path.basename(cwd).replace(/[^a-zA-Z0-9._-]/g, '');
const home = process.env.HOME ?? '';
for (const dir of [path.join(resolveStateRoot(process.env), 'projects', slug), path.join(home, '.claude/plans'), path.join(home, '.codex/plans'), path.join(cwd, '.gstack/plans')]) {
let names: string[];
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.md')); } catch { continue; }
for (const n of names) if (readOr(path.join(dir, n)).includes(branch)) found.push(`PLAN_CANDIDATE: ${path.join(dir, n)}`);
}
return found;
}
export function audit(cwd: string, base: string, out: string): Report {
const r = new Report();
const fork = git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim() || `origin/${base}`;
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
const diff = git(cwd, ['diff', fork]).stdout;
const files = git(cwd, ['diff', '--name-only', fork]).stdout.split('\n').filter(Boolean);
const commits = git(cwd, ['log', '--oneline', `origin/${base}..HEAD`]).stdout;
fs.writeFileSync(path.join(out, 'diff.patch'), diff, { mode: 0o600 });
fs.writeFileSync(path.join(out, 'commits.txt'), commits, { mode: 0o600 });
r.add(`CONTEXT: diff=${path.join(out, 'diff.patch')} bytes=${Buffer.byteLength(diff)} files=${files.length} commits=${commits.split('\n').filter(Boolean).length} log=${path.join(out, 'commits.txt')}`);
if (!fs.existsSync(path.join(cwd, 'TODOS.md'))) r.add('TODOS: none (no TODOS.md)');
else {
const text = readOr(path.join(cwd, 'TODOS.md'));
const hits = todosHits(text, files);
fs.writeFileSync(path.join(out, 'todos.md'), hits.join('\n') + (hits.length ? '\n' : ''), { mode: 0o600 });
r.add(`TODOS: bytes=${Buffer.byteLength(text)} hits=${hits.filter(l => !/^#{1,6} /.test(l)).length} file=${path.join(out, 'todos.md')} (Steps 8.2 and 14 read this file, never TODOS.md whole)`);
}
const tests = files.filter(p => TEST_PATH.test(p)).map(p => ({ p, text: readOr(path.join(cwd, p)) }));
const runtime = files.filter(p => !TEST_PATH.test(p) && !DATA_PATH.test(p) && fs.existsSync(path.join(cwd, p)) && classifyPath(p, cwd) === 'code');
// One read of the tracked test tree for every changed file, instead of one full-tree git grep per file.
const testTree = runtime.length ? git(cwd, ['ls-files', '-z', '--', ...TEST_SPECS]).stdout.split('\0').filter(Boolean).map(f => readOr(path.join(cwd, f))) : [];
const map = runtime.map(p => ({
p,
changed: tests.filter(t => t.p.includes(stem(p)) || t.text.includes(stem(p))).length,
referencing: testTree.filter(text => text.includes(stem(p))).length,
}));
const mapFile = path.join(out, 'coverage.txt');
fs.writeFileSync(mapFile, map.map(m => `${m.p} changed_tests=${m.changed} referencing_tests=${m.referencing}\n`).join(''), { mode: 0o600 });
for (const m of map.slice(0, 8)) r.add(`COVERAGE_MAP: ${m.p} changed_tests=${m.changed} referencing_tests=${m.referencing}`);
const untested = map.filter(m => m.changed === 0).map(m => m.p);
r.add(!runtime.length ? 'Coverage: skipped (no runtime file changed; no audit reader)'
: untested.length ? `Coverage: ${untested.length} of ${runtime.length} changed runtime files have no changed test naming them (${short(untested, 4)}); map=${mapFile}`
: `Coverage: deterministic (each of ${runtime.length} changed runtime files has a changed test naming it; no audit reader); map=${mapFile}`);
const branch = git(cwd, ['branch', '--show-current']).stdout.trim().replace(/\//g, '-').replace(/[^a-zA-Z0-9._-]/g, '');
const plans = planCandidates(cwd, fork, branch);
for (const line of plans.slice(0, 5)) r.add(line);
if (!plans.length) r.add('Plan completion audit: not run (no plan is bound to this branch and no docs/designs/ file matches). Fix: add "Plan: <path>" to the PR body, or run /autoplan.');
r.add(`READER: coverage=${untested.length ? 'needed' : 'none'} plan=${plans.length ? 'needed' : 'none'}`);
return r;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* ship-bin-common — what gstack-ship-preflight and gstack-ship-release share: running git
* and sibling bins with timeouts, flag reading, the effective ship policy, and the compact
* result every step prints (KEY: lines, STOP: where the agent must judge, NEXT: what it
* does next). The bins chain existing commands and never make a decision the /ship prose
* assigns to the agent.
*/
import { spawn, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
export interface Run { status: number | null; stdout: string; stderr: string }
const done = (r: { status: number | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null; error?: Error }): Run =>
({ status: r.status, stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') + (r.error ? r.error.message : '') });
export function git(cwd: string, args: string[], timeout = 120_000): Run {
return done(spawnSync('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }));
}
export function bin(cwd: string, name: string, args: string[], timeout = 600_000): Run {
return done(runScriptSync(path.join(BIN_DIR, name), args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 }));
}
/** Whether origin's configured URL names a host (https, ssh or scp form), before any insteadOf rewrite; a local path or file:// remote has none, so gh cannot map it to a repository. */
export function hostedOrigin(cwd: string): boolean {
const url = git(cwd, ['config', '--get', 'remote.origin.url']).stdout.trim();
return /^(?:[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s]+@)?[^/:\s]+[:/]|[^@/\s]+@[^/:\s]+:)\S+$/i.test(url) && !/^file:/i.test(url);
}
/** gh with argv, never a shell, so a branch name cannot change what runs. */
export function gh(cwd: string, args: string[], timeout = 60_000): Run {
return done(spawnSync('gh', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }));
}
export function sh(cwd: string, command: string, timeout = 1_800_000): Run {
return done(runBashSync(command, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 }));
}
/** A sibling bin run without blocking, so independent test lanes run in parallel. */
export function binAsync(cwd: string, name: string, args: string[], timeout = 3_600_000): Promise<Run> {
const inv = scriptInvocation(path.join(BIN_DIR, name), args);
if (!inv.ok) return Promise.resolve({ status: null, stdout: '', stderr: inv.error });
return new Promise(resolve => {
const child = spawn(inv.cmd, inv.argv, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout = (stdout + d).slice(-200_000); });
child.stderr.on('data', d => { stderr = (stderr + d).slice(-200_000); });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
export function flagReader(argv: readonly string[]) {
const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : []));
return { all, flag: (n: string) => all(n).at(-1), has: (n: string) => argv.includes(n) };
}
/** --lane <label>=<command> pairs (Step 5's labels and exact commands). */
export function lanes(argv: readonly string[]): { label: string; command: string }[] | string {
const out: { label: string; command: string }[] = [];
for (const raw of flagReader(argv).all('--lane')) {
const at = raw.indexOf('=');
if (at < 1 || at === raw.length - 1) return `--lane ${raw} is not <label>=<command>`;
out.push({ label: raw.slice(0, at), command: raw.slice(at + 1) });
}
return out;
}
/** The base branch's effective ship policy (gstack-ship-policy show-effective --json), or null when unreadable. */
export function shipPolicy(cwd: string, base: string): Record<string, any> | null {
const r = bin(cwd, 'gstack-ship-policy', ['show-effective', '--base', base, '--json'], 60_000);
try { return JSON.parse(r.stdout).policy ?? null; } catch { return null; }
}
/** Manifests a version stamp writes: release metadata only while the edit changes nothing but the version. */
export const VERSIONED_MANIFESTS = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json'];
/** True when `p` in the working tree differs from `p` at `ref` (a commit or tree) in its version fields alone; a new, deleted or unparseable file never qualifies. */
export function versionOnlyEdit(cwd: string, ref: string, p: string): boolean {
const before = git(cwd, ['show', `${ref}:${p}`]);
if (before.status !== 0) return false;
let now: string;
try { now = fs.readFileSync(path.join(cwd, p), 'utf8'); } catch { return false; }
const strip = (s: string) => {
const o = JSON.parse(s);
delete o.version;
if (o.packages?.['']) delete o.packages[''].version;
return JSON.stringify(o);
};
try { return strip(before.stdout) === strip(now); } catch { return false; }
}
/**
* Release metadata a version stamp and Steps 13-14 write, compared against `ref`: the release commit's paths,
* never a behavior change. A manifest counts only while its edit is version-only (a dependency, script or
* lockfile change is a build input); bun.lock never counts.
*/
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string): string[] {
const list = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []);
const fragment = fragmentPath(policy, cwd);
return [...new Set(['VERSION', 'CHANGELOG.md', 'TODOS.md', 'agents-digest/gstack-AGENTS.md', ...VERSIONED_MANIFESTS.filter(p => versionOnlyEdit(cwd, ref, p)),
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths)])];
}
/** Under `changelog_fragments`, the branch's own Step 13 entry `<dir>/<branch-slug>.md` (docs/ship-policy.md#changelog-fragments); null without the key. */
export function fragmentPath(policy: Record<string, any> | null, cwd: string): string | null {
const dir = policy?.changelog_fragments?.dir;
if (typeof dir !== 'string') return null;
return `${dir.replace(/\/+$/, '')}/${fragmentSlug(git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim())}.md`;
}
/** The paths a lane receipt may ignore: release metadata except TODOS.md, whose Step 14 edit reruns the lanes (ship/SKILL.md Step 16). */
export const evidenceExempt = (release: readonly string[]) => release.filter(p => p !== 'TODOS.md');
export const short = (list: readonly string[], n = 6) => list.slice(0, n).join(',') + (list.length > n ? `,+${list.length - n} more` : '');
export const firstLine = (s: string) => s.trim().split('\n').filter(Boolean).at(-1)?.slice(0, 240) ?? '';
/** The compact result: at most MAX_LINES lines; STOP and NEXT always survive the cap. */
export const MAX_LINES = 30;
export class Report {
readonly lines: string[] = [];
exit = 0;
add(line: string): this { this.lines.push(line); return this; }
next(what: string): this { return this.add(`NEXT: ${what}`); }
stop(what: string): Report { this.exit = 3; return this.add(`STOP: ${what}`); }
render(): string {
const keep = this.lines.length <= MAX_LINES ? this.lines
: [...this.lines.filter(l => !/^(STOP|NEXT):/.test(l)).slice(0, MAX_LINES - 1 - this.lines.filter(l => /^(STOP|NEXT):/.test(l)).length), `… ${this.lines.length - MAX_LINES + 1} line(s) cut`, ...this.lines.filter(l => /^(STOP|NEXT):/.test(l))];
return keep.join('\n') + '\n';
}
}
/** Run each lane through gstack-evidence run in parallel and read its recorded line. */
export async function runLanes(cwd: string, list: { label: string; command: string }[]): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> {
return Promise.all(list.map(async l => {
const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]);
const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr);
return { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' };
}));
}
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone. */
export function laneLine(l: { label: string; exit: number | null; testsRan: string; log: string }): { line: string; ok: boolean } {
const zero = l.exit === 0 && l.testsRan === '0';
const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass';
return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}`, ok: l.exit === 0 && !zero };
}
+59 -7
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only. Each unit's call_floor (GSTA-167) is the host's per-call floor: what one call costs in a fresh session with the ship skill loaded, before any /ship work. The delegate thresholds compare the per-call charge minus this floor, so a host's own system prompt and tools never trigger delegation. A floor is the largest round_to multiple at or below its smallest observed fresh call, so it never hides real context growth; start --call-floor <n> overrides it for one run.",
"note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only. Each unit's call_floor (GSTA-167) is the host's per-call floor: what one call costs in a fresh session with the ship skill loaded, before any /ship work. The delegate thresholds compare the per-call charge minus this floor, so a host's own system prompt and tools never trigger delegation. A floor is the largest round_to multiple at or below its smallest observed fresh call, so it never hides real context growth; start --call-floor <n> overrides it for one run. Step priors (GSTA-167 round 3): a step holds only its own calls; each audit dispatch (Steps 7, 8, 14.5) is reserved by its own check, so it moved from step_dispatches to segment_dispatches, where a child's segment estimate still counts it. On the Capy meter a step is priced at step_calls x the run's own median measured per-call charge once three calls are measured (lib/ship-budget.ts measuredPerCall), and at per_call before that. A keyed step_calls entry is the ceiling of the most calls step_calls_observed holds for it, and an unkeyed step's observations stay at or below default. Each observation is a step window's measured actual (step samples in the round-3 replay ledgers mirrored under the project-user Drive folder ship-budget/) divided by 330k, the per-call charge replay3-34.md reports for gstack-evals #34; replay3-36.md reports no per-call figure, so #36 uses the same 330k. Steps 7 and 9 have no step measurement yet and keep 12. The #3633 lane's first ledger (ship-20261030T020246-7c4b65) read the root counter, which holds sibling threads, so its step samples are left out.",
"headroom": 1.5,
"round_to": 10000,
"observations": {
@@ -19,6 +19,16 @@
"run": "gstack-evals#31",
"value": 500000,
"source": "GSTA-152 plan v3 evidence: the /ship child's context was about 0.5M per call at the handoff"
},
{
"run": "gstack-evals#34 round 3",
"value": 330000,
"source": "handoffs/GSTA-167/replay3-34.md: about 0.33M per late parent call, read from the replay thread's own counter"
},
{
"run": "GSTA-167 lane #3633",
"value": 350000,
"source": "GSTA-167 lane report, own-jam ledger ship-20261030T022114-ef9f9a: the lane's own calls run about 0.35M each (stated by the lane, not solved from the ledger)"
}
],
"per_dispatch": [
@@ -48,18 +58,60 @@
},
"step_calls": {
"7": 12,
"8": 12,
"8": 13,
"9": 12,
"14": 5,
"default": 3
},
"step_dispatches": {
"step_calls_observed": {
"8": [
{
"run": "gstack-evals#34 round 3",
"value": 12.16,
"source": "ledger ship-20261030T020705-eda850, step sample step 7 next 8 (the child's first step window, section reads included): actual 4012570 / 330k per call"
},
{
"run": "gstack-evals#36 round 3",
"value": 1.02,
"source": "ledger ship-20261030T020847-c940ef, step sample step 7 next 8: actual 335631 / 330k per call"
}
],
"13": [
{
"run": "gstack-evals#34 round 3",
"value": 1.31,
"source": "ledger ship-20261030T021912-ab78e5, step sample step 12 next 13: actual 431321 / 330k per call"
}
],
"14": [
{
"run": "gstack-evals#34 round 3",
"value": 4.24,
"source": "ledger ship-20261030T021912-ab78e5, step sample step 13 next 14: actual 1399070 / 330k per call"
}
],
"15": [
{
"run": "gstack-evals#34 round 3",
"value": 2.4,
"source": "ledger ship-20261030T021912-ab78e5, step sample step 14.5 next 15: actual 790578 / 330k per call"
}
],
"14.5": [
{
"run": "gstack-evals#34 round 3",
"value": 1.55,
"source": "ledger ship-20261030T021912-ab78e5, step sample step 14 next 14.5: actual 510368 / 330k per call"
}
]
},
"step_dispatches": {},
"segment_dispatches": {
"7": 1,
"8": 1,
"14.5": 1
},
"segment_dispatches": {
"9": 4,
"11": 2
"11": 2,
"14.5": 1
},
"units": {
"capy-tokens": {
+120 -31
View File
@@ -13,10 +13,10 @@ import * as path from 'node:path';
import { resultLine } from './result-codes';
import {
BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS,
aboveFloor, admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths,
runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
type Ledger, type MeterKind, type Next,
type Ledger, type MeterKind, type Next, type Note,
} from './ship-budget';
import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage';
@@ -44,8 +44,14 @@ const meterFile = (l: Ledger) => path.join(l.dir, 'claude-meter.json');
/** Whether this command took a fresh reading (Claude Code reads its journals on every command). */
const freshReading = (l: Ledger, o: Opts) => l.meter.contract.meter === 'claude' || str(o, 'reading') !== undefined;
/** Take this command's reading under the run's meter contract; returns the per-call charge when known. */
export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | null {
/**
* Take this command's reading under the run's meter contract; returns the
* per-call charge when known. On Capy that charge is --reading minus a
* --prior-reading taken in the previous call; a prior older than the run's last
* reading spans several calls, so it is ignored (call unknown) with a note.
*/
export function readMeter(l: Ledger, o: Opts, requireReading: boolean, notes: string[] = []): number | null {
if (str(o, 'credits') !== undefined) applyCredits(l, parseCredits(str(o, 'credits'), '--credits'));
const reading = tokensOpt(o, 'reading');
const prior = tokensOpt(o, 'prior-reading');
const meter = l.meter.contract.meter;
@@ -59,12 +65,40 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number |
return usage.lastCall;
}
if (reading === undefined) {
if (requireReading && l.cap !== null) throw budgetError('BUDGET_READING_MISSING', `run ${l.run} meter=capy unit=capy-tokens epoch=${l.meter.epoch}`);
if (requireReading && l.cap !== null && !l.parent_metered) throw budgetError('BUDGET_READING_MISSING', `run ${l.run} meter=capy unit=capy-tokens epoch=${l.meter.epoch}`);
return null;
}
if (prior !== undefined && prior > reading) throw budgetError('BUDGET_READING_REJECTED', `--prior-reading ${prior} is above --reading ${reading}`);
const last = l.meter.last;
applyReading(l, reading, 'flag');
return prior === undefined ? null : reading - prior;
if (prior === undefined) return null;
if (last !== null && prior < last) {
notes.push(`CALL_UNKNOWN: --prior-reading ${prior} is older than the run's last reading ${last}; it spans several calls, so the per-call charge is unknown (pass the reading your previous call returned)`);
return null;
}
(l.calls ??= []).push({ epoch: l.meter.epoch, value: reading - prior });
return reading - prior;
}
/**
* The dispatch program's reading from right before its readers started
* (--dispatched-at, with --readers), paired with this command's --reading taken
* after every reader settled. It is late when another command read the meter
* after it: the interval then spans a later program, so it measures nothing.
*/
function dispatchedOpt(o: Opts, lastBefore: number | null): { at: number; readers?: number; late: boolean } | undefined {
const at = tokensOpt(o, 'dispatched-at');
const raw = str(o, 'readers');
const readers = raw === undefined ? undefined : Number(raw);
if (readers !== undefined && !(Number.isInteger(readers) && readers > 0)) throw usageError(`--readers '${raw}' is not a positive integer`);
if (at === undefined) {
if (readers !== undefined) throw usageError('--readers needs --dispatched-at <the reading taken right before the readers started>');
return undefined;
}
const reading = tokensOpt(o, 'reading');
if (reading === undefined) throw usageError('--dispatched-at needs --reading <the reading taken after every reader settled, in the same program>');
if (at > reading) throw budgetError('BUDGET_READING_REJECTED', `--dispatched-at ${at} is above --reading ${reading}`);
return { at, ...(readers !== undefined ? { readers } : {}), late: lastBefore !== null && lastBefore > at };
}
/** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */
@@ -96,12 +130,14 @@ export function writeReturnFile(file: string, lines: string[]): void {
/**
* Admission and the delegate rule for one boundary; reserves the admitted work
* under a cap (a dispatch batch goes to admitBatch). The rule compares the call's context above the host floor (an
* unknown charge delegates at a segment boundary, never at start). A child
* unknown charge delegates at a segment boundary, never at start). A light diff
* whose segment costs less than a child keeps it (keepsSmallRun). A child
* never stops on it: after a completed step it hands its remaining steps to a
* grandchild, and before one, at the maximum depth or past its segment it
* continues with a CONTEXT_HIGH note. Admission still stops anyone.
*/
export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome {
export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false, runtimeLines?: number): Outcome {
if (l.mode === 'observe') return observed(l, step, next.kind === 'dispatch' ? `dispatch:${next.roles.join(',')}` : next.step, call);
if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call);
const p = projector(l);
const nextId = next.step;
@@ -111,8 +147,10 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null,
}
const threshold = startThreshold ? DELEGATE_START_CALL : DELEGATE_SEGMENT_CALL;
const above = aboveFloor(call, p.floor);
const boundary = (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12');
const wants = boundary && (above === null ? !startThreshold : above >= threshold);
const boundary = (startThreshold ? next.step === '1' : next.step in SEGMENTS && next.step !== '1');
const to = l.parent ? l.parent.to : SEGMENTS[next.step]!;
const small = boundary && !startThreshold ? keepsSmallRun(loadCalibration(l.calibration), { runtimeLines, from: next.step, to, call }) : null;
const wants = boundary && !small && (above === null ? !startThreshold && !l.parent_metered : above >= threshold);
const depth = l.depth ?? (l.parent ? 1 : 0);
const hold = !wants || !l.parent ? undefined
: !l.steps_done?.length ? 'no step completed since verify-handoff'
@@ -123,22 +161,29 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null,
return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold', threshold }), exit: 3 };
}
if (wants && !hold) {
const to = l.parent ? l.parent.to : SEGMENTS[next.step];
return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }),
`DELEGATE: Steps ${next.step}-${to} go to a fresh ${l.parent ? `grandchild (chain depth ${depth + 1})` : 'child'}: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${to} --base origin/<base> (ship/sections/budget-handoff.md)`], exit: 0 };
}
const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })];
const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' }), ...(small ? [small] : [])];
if (hold) lines.push(`CONTEXT_HIGH: call=${call ?? 'unknown'} call_floor=${p.floor} above_floor=${above ?? 'unknown'} threshold=${threshold} next=${nextId} depth=${depth}; continuing: ${hold}`);
if (l.cap !== null) {
const r = reserveIn(l, { kind: 'step', step, next: nextId, tokens: projected, prior });
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`);
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`, `RESERVATION_ID: ${r.id}`);
}
return { stdout: lines, exit: 0 };
}
/** An observe run's verdict: continue, or the hard stop once used + unknown pass the cap. Nothing is reserved. */
function observed(l: Ledger, step: string, next: string, call: number | null, line?: string): Outcome {
const t = tally(l);
const head = line ? [line] : [];
if (l.cap !== null && t.used + t.unknown > l.cap) return { stdout: [...head, ...stopLines(l, { call, next, step, projected: 0, reason: 'budget_exhausted' })], exit: 3 };
return { stdout: [...head, budgetLine(l, { call, next, projected: 0, verdict: 'continue' })], exit: 0 };
}
/**
* A dispatch batch: each reader is priced at max(its projection, the run's
* measured mean for its role) and the batch is reserved as one sum. When it
* A dispatch batch: each reader is priced by projector().reader and the batch is
* reserved as one sum. When it
* does not fit, the largest prefix that does is admitted and ADMIT names the
* rest; the reservation keeps them as `remaining` until a later check of the
* same step requests them, and close flags any still left. Only a batch whose
@@ -161,7 +206,7 @@ function admitBatch(l: Ledger, step: string, roles: string[], call: number | nul
older?.remaining!.splice(older.remaining!.indexOf(role), 1);
}
const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted }).prior, roles: admitted, ...(remaining.length ? { remaining } : {}) });
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`);
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`, `RESERVATION_ID: ${r.id}`);
return { stdout: lines, exit: 0 };
}
@@ -190,7 +235,9 @@ function pickMeter(o: Opts): MeterKind | undefined {
}
export function cmdStart(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor']);
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode']);
const mode = str(o, 'mode') ?? 'admit';
if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`);
const record = str(o, 'record');
if (!record) throw usageError('start needs --record <invocation record path>');
const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP;
@@ -221,7 +268,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
delegate, role: 'parent', meter: { contract, epoch: 1, baseline: 0, last: null, ...(sessions ? { sessions, config_dir: claudeConfigDir(ctx.env) } : {}) },
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}),
...(callFloor !== undefined ? { call_floor: callFloor } : {}),
...(callFloor !== undefined ? { call_floor: callFloor } : {}), ...(mode === 'observe' ? { mode } : {}),
};
if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy');
if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!;
@@ -239,7 +286,9 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
* reviewer consumed), measure them, then admit the next step or dispatch.
*/
export function cmdCheck(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading', 'returned']);
const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'credits', 'prior-reading', 'returned', 'dispatched-at', 'readers', 'runtime-lines']);
const runtimeRaw = str(o, 'runtime-lines');
if (runtimeRaw !== undefined && !/^\d+$/.test(runtimeRaw)) throw usageError(`--runtime-lines '${runtimeRaw}' is not a line count; pass runtime_lines from gstack-review-plan --json`);
const step = parseStep(str(o, 'step'));
const next = parseNext(str(o, 'next'));
const returned = (str(o, 'returned') ?? '').split(',').filter(Boolean);
@@ -249,46 +298,55 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome {
const back = returned.map(id => l.reservations.find(r => r.id === id && r.kind === 'dispatch'));
const bad = returned.filter((_, i) => !back[i]);
if (bad.length) throw usageError(`--returned ${bad.join(',')} is not a dispatch reservation of run ${l.run}; fix: pass the RESERVATION ids check printed for the reviewers that came back`);
const call = readMeter(l, o, true);
if (str(o, 'dispatched-at') !== undefined && !back.length) throw usageError('--dispatched-at measures the reservations named by --returned; pass them, or use settle');
const notes: string[] = [];
const lastBefore = l.meter.last;
const dispatched = dispatchedOpt(o, lastBefore);
const call = readMeter(l, o, true, notes);
const fresh = freshReading(l, o);
for (const r of l.reservations) {
const done = r.kind === 'step' ? r.state === 'open' : r.kind === 'dispatch' && (back.includes(r) || (next.kind === 'step' && r.state === 'consumed'));
if (done) settleIn(l, r);
if (done) settleIn(l, r, back.includes(r) && fresh ? { reading: l.meter.last ?? undefined, dispatched } : {});
}
const drift = freshReading(l, o) ? measureSettled(l) : [];
const drift = fresh ? measureSettled(l, next.kind === 'step') : [];
if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])];
const out = decide(l, step, next, call);
return { stdout: [...drift, ...out.stdout], exit: out.exit };
const out = decide(l, step, next, call, false, runtimeRaw === undefined ? undefined : Number(runtimeRaw));
return { stdout: [...notes, ...drift, ...out.stdout], exit: out.exit };
});
}
export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome {
const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading'])], release ? [] : ['unknown']);
const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading', 'credits', 'dispatched-at', 'readers'])], release ? [] : ['unknown']);
const id = str(o, 'reservation');
if (!id) throw usageError(`${release ? 'release' : 'settle'} needs --reservation <id>`);
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
const r = l.reservations.find(x => x.id === id);
if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`);
if (release) { if (r.state === 'open') r.state = 'released'; return { stdout: [`RELEASED: id=${id} state=${r.state}`], exit: 0 }; }
const dispatched = dispatchedOpt(o, l.meter.last);
readMeter(l, o, false);
settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) });
return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(freshReading(l, o) ? measureSettled(l) : [])], exit: 0 };
const fresh = freshReading(l, o);
settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration), reading: fresh ? l.meter.last ?? undefined : undefined, dispatched });
return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(fresh ? measureSettled(l, false) : [])], exit: 0 };
});
}
export function cmdClose(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'reading', 'approved-by', 'words']);
const o = parseArgs(argv, ['run', 'record', 'reading', 'credits', 'approved-by', 'words']);
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
readMeter(l, o, false);
for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r);
if (freshReading(l, o)) measureSettled(l);
const t = tally(l);
const open = l.reservations.filter(r => r.kind === 'dispatch' && r.state === 'consumed').length;
const lines = [`BUDGET_FINAL: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} open_dispatches=${open}`];
const credits = creditsUsed(l);
const lines = [`BUDGET_FINAL: used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} open_dispatches=${open}`];
const sum = (kind: string, k: 'projected' | 'actual') => (l.samples ?? []).filter(s => s.kind === kind).reduce((n, s) => n + s[k], 0);
const count = (kind: string) => (l.samples ?? []).filter(s => s.kind === kind).length;
if (l.notes?.length) lines.push(`BUDGET_TIMELINE: ${l.notes.map(noteText).join('; ')}`);
lines.push(`BUDGET_DRIFT: steps=${count('step')} step_projected=${sum('step', 'projected')} step_actual=${sum('step', 'actual')} dispatches=${count('dispatch')} dispatch_projected=${sum('dispatch', 'projected')} dispatch_actual=${sum('dispatch', 'actual')}`);
const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []);
const skipped = l.cap === null ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started));
const skipped = l.cap === null || l.mode === 'observe' ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started));
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
if ((skipped.length || dropped.length) && str(o, 'approved-by') && str(o, 'words')) {
(l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started), ...dropped.map(r => r.id)];
@@ -308,9 +366,39 @@ export function cmdClose(argv: string[], ctx: Ctx): Outcome {
});
}
const noteText = (n: Note) => `${n.step}${n.label ? `:${n.label}` : ''} used=${n.used}${n.credits === undefined ? '' : ` credits=${n.credits}`}`
+ (n.readers ? ` readers=${n.readers.n} readers_tokens=${n.readers.tokens}${n.readers.credits === undefined ? '' : ` readers_credits=${n.readers.credits}`}` : '');
/**
* note: record one program's readings (tokens and credits) and, when --dispatched-at
* brackets them, the readers it dispatched; stop only once used + unknown pass the cap.
* It reserves, admits and delegates nothing: ship/DRIVER.md's observe runs end every
* program with it instead of a check at each boundary.
*/
export function cmdNote(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'step', 'label', 'reading', 'credits', 'dispatched-at', 'dispatched-credits', 'readers']);
const step = parseStep(str(o, 'step'));
const label = str(o, 'label');
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
if (l.closed_at) throw usageError(`run ${l.run} is closed`);
if (l.stopped) return { stdout: [budgetLine(l, { call: null, next: l.stopped.next, projected: 0, verdict: 'stop' }), `STOP: reason=${l.stopped.reason} at Step ${l.stopped.step}; resume first: gstack-ship-budget resume --record ${l.record}`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`], exit: 3 };
if (l.meter.contract.meter === 'capy' && str(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', `note --step ${step}: run ${l.run} meter=capy`);
const dispatched = dispatchedOpt(o, null);
const before = str(o, 'dispatched-credits') === undefined ? undefined : parseCredits(str(o, 'dispatched-credits'), '--dispatched-credits');
if (before !== undefined && !dispatched) throw usageError('--dispatched-credits needs --dispatched-at <the token reading taken with it>');
readMeter(l, o, true);
const credits = creditsUsed(l);
const readers = dispatched && l.meter.last !== null ? { n: dispatched.readers ?? 1, tokens: l.meter.last - dispatched.at,
...(before !== undefined && l.credits ? { credits: Math.round((l.credits.last - before) * 100) / 100 } : {}) } : undefined;
const n: Note = { step, ...(label ? { label } : {}), at: new Date().toISOString(), used: tally(l).used, ...(credits === null ? {} : { credits }), ...(readers ? { readers } : {}) };
(l.notes ??= []).push(n);
return observed(l, step, label ?? step, null, `NOTE: ${noteText(n)}`);
});
}
/** resume: re-attach after a stop or a machine change; rebuild or restore a lost ledger; journal a cap raise. */
export function cmdResume(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['record', 'run', 'reading', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words'], ['rebuild-from-record']);
const o = parseArgs(argv, ['record', 'run', 'reading', 'credits', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words'], ['rebuild-from-record']);
const cap = str(o, 'token-cap');
if (cap !== undefined && (!str(o, 'approved-by') || !str(o, 'words'))) throw usageError('resume --token-cap needs --approved-by <who> --words <the owner\'s words>; raising the cap is the owner\'s call');
const lines: string[] = [];
@@ -365,6 +453,7 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op
meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null },
epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [],
...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(),
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}),
};
if (meter === 'estimate') l.estimate_used = 0;
fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 });
+220 -60
View File
@@ -25,10 +25,11 @@ import { resolveStateRoot } from './state-root';
import { resultLine, type ResultCodeName } from './result-codes';
import { capyDriveFolder } from './autoplan-checkpoint';
import { BIN_DIR, runScriptSync } from './script-invocation';
import type { Role } from './review-depth';
import { SMALL_DIFF_LINES, type Role } from './review-depth';
export const SHIP_STEPS = ['0.9', '1', '2', '3', '4', '5', '6', '6.5', '7', '8', '9', '10', '11', '11.5', '12', '13', '14', '14.5', '15', '16', '17', '18', '19', '20', '21', '22'] as const;
export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check'] as const satisfies readonly Role[];
/** Reviewer roles plus `docs`, the Step 14.5 documentation audit: each is one reader priced and reconciled the same way. */
export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'docs'] as const satisfies readonly (Role | 'docs')[];
type MissingRole = Exclude<Role, typeof DISPATCH_ROLES[number]>;
const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true;
void ROLES_COMPLETE;
@@ -40,15 +41,15 @@ export const DELEGATE_START_CALL = 100_000;
export const DELEGATE_SEGMENT_CALL = 150_000;
/** Handoffs between the root run and the deepest child; a run at this depth continues instead of handing off. */
export const MAX_CHAIN_DEPTH = 3;
/** Segment boundaries (before Step 7, before Step 12) and the last step each segment runs. */
export const SEGMENTS: Record<string, string> = { '1': '22', '7': '11.5', '12': '22' };
/** Segment boundaries (before Step 9, before Step 12) and the last step each segment runs: Steps 7-8 stay with the parent, so the review child reads only what dispatch needs. */
export const SEGMENTS: Record<string, string> = { '1': '22', '9': '11.5', '12': '22' };
export const DEFAULT_TAIL = 2_000_000;
const DIFF_REVIEW_STEP: Record<string, string> = { review: '9', 'review-fix-check': '9', 'design-review-lite': '9', 'adversarial-review': '11', 'codex-review': '11' };
export type MeterKind = 'capy' | 'claude' | 'estimate';
export interface MeterContract { version: 1; meter: MeterKind; unit: string; host_field: string; scope: 'root' | 'session' | 'run'; descendants: boolean }
export interface MeterContract { version: 1; meter: MeterKind; unit: string; host_field: string; scope: 'thread' | 'session' | 'run'; descendants: boolean }
export const METER_CONTRACTS: Record<MeterKind, MeterContract> = {
capy: { version: 1, meter: 'capy', unit: 'capy-tokens', host_field: 'tools.thread.get({ thread_id: "this" }).cost.tokens', scope: 'root', descendants: true },
capy: { version: 1, meter: 'capy', unit: 'capy-tokens', host_field: 'tools.thread.get({ thread_id: "<this agent\'s own jam id>" }).cost.tokens', scope: 'thread', descendants: true },
claude: { version: 1, meter: 'claude', unit: 'claude-usage-tokens', host_field: 'journal message.usage input+cache_creation+cache_read+output, max per (message.id, requestId)', scope: 'session', descendants: true },
estimate: { version: 1, meter: 'estimate', unit: 'estimate-tokens', host_field: 'lib/ship-budget-calibration.json projections', scope: 'run', descendants: true },
};
@@ -57,11 +58,18 @@ export interface Reservation {
id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string;
state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string;
prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[];
settled_reading?: number; unmeasured?: boolean; dispatched_at?: number; readers?: number; late?: boolean;
/** Every review-log start this reservation admitted: one per role (token and started_at keep the first). */
starts?: Array<{ token: string; at: string }>;
}
/** One observation: the run's totals at a program's end, and the readers it dispatched when it measured them. */
export interface Note { step: string; label?: string; at: string; used: number; credits?: number; readers?: { n: number; tokens: number; credits?: number } }
export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number }
export interface Handoff {
n: number; sha: string; file: string; from: string; to: string; sub_cap: number; reservation: string; reading_at: number | null;
epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; depth?: number;
/** Ledgers of the child runs verify-handoff opened on this brief: close audits their starts too. */
child_ledgers?: string[];
}
export interface Ledger {
schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string;
@@ -74,6 +82,15 @@ export interface Ledger {
cap_raises: Array<{ from: number | null; to: number | null; by: string; words: string; at: string }>;
marker?: string; review_log?: string; calibration?: string; samples?: Sample[]; tail_explicit?: boolean;
call_floor?: number; depth?: number; steps_done?: string[];
/** Capy per-call charges (--reading minus a fresh --prior-reading), per epoch: step priors use their median. */
calls?: Array<{ epoch: number; value: number }>;
/** A Capy handoff child verified with no reading: a subagent cannot read its own jam id, so the parent's reconcile reading measures it. */
parent_metered?: boolean;
/** observe (ship/DRIVER.md): readings are recorded by note and the cap is one hard stop; no reservation, admission or delegation. */
mode?: 'admit' | 'observe';
/** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */
credits?: { baseline: number; last: number };
notes?: Note[];
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
}
@@ -112,6 +129,7 @@ export interface Calibration {
observations: Record<string, Array<{ run: string; value: number; source: string }>>;
constants: Record<'per_call' | 'per_dispatch' | 'stop_overhead', number>;
step_calls: Record<string, number>; step_dispatches: Record<string, number>; segment_dispatches: Record<string, number>;
step_calls_observed?: Record<string, Array<{ run: string; value: number; source: string }>>;
units: Record<string, { fit?: string; seeded_from?: string; why?: string; call_floor?: number; call_floor_observed?: number | null; call_floor_why?: string }>;
}
export const CALIBRATION_FILE = path.join(import.meta.dir, 'ship-budget-calibration.json');
@@ -133,9 +151,24 @@ export function unitConstants(cal: Calibration, unit: string): Calibration['cons
return entry.seeded_from ? unitConstants(cal, entry.seeded_from) : cal.constants;
}
export function stepProjection(cal: Calibration, unit: string, step: string): number {
const k = unitConstants(cal, unit);
return (cal.step_calls[step] ?? cal.step_calls.default ?? 0) * k.per_call + (cal.step_dispatches[step] ?? 0) * k.per_dispatch;
export const stepCalls = (cal: Calibration, step: string) => cal.step_calls[step] ?? cal.step_calls.default ?? 0;
/** A step's prior: its calibrated calls at `perCall` (the unit's per_call by default) plus any dispatches the step itself carries. */
export function stepProjection(cal: Calibration, unit: string, step: string, perCall = unitConstants(cal, unit).per_call): number {
return stepCalls(cal, step) * perCall + (cal.step_dispatches[step] ?? 0) * unitConstants(cal, unit).per_dispatch;
}
/** Calls the per-call median looks at, and how many it needs before it replaces the calibrated per_call. */
export const CALL_WINDOW = 5;
export const MIN_CALLS = 3;
/** The Capy run's median per-call charge over its last CALL_WINDOW measured calls of this epoch; null below MIN_CALLS or on another meter. */
export function measuredPerCall(l: Ledger): number | null {
if (l.meter.contract.meter !== 'capy') return null;
const xs = (l.calls ?? []).filter(c => c.epoch === l.meter.epoch).slice(-CALL_WINDOW).map(c => c.value).sort((a, b) => a - b);
if (xs.length < MIN_CALLS) return null;
const mid = Math.floor(xs.length / 2);
return xs.length % 2 ? xs[mid]! : Math.round((xs[mid - 1]! + xs[mid]!) / 2);
}
export function projection(cal: Calibration, unit: string, next: Next): number {
@@ -161,54 +194,98 @@ export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind']
* with the step factor unless --reserve set it; the stop overhead keeps its
* calibrated fit so a run always has room to stop. `floor` is the host's
* per-call floor: start --call-floor, else the unit's calibrated value.
* `reader` prices one dispatch reader at max(its projection, the run's mean
* measured cost for that role over settled dispatch reservations).
* Once a Capy run has MIN_CALLS measured calls, a step is priced at its
* calibrated calls x their median (measuredPerCall) instead, unscaled: that
* median is already the run's own measurement.
* `reader` prices one dispatch reader: the calibrated per-dispatch prior times the
* dispatch factor, but never above the larger of that prior and the run's clean
* per-reader mean, and never below the role's own measured mean.
*/
export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) {
const unit = l.meter.contract.unit;
const floor = l.call_floor ?? cal.units[unit]?.call_floor ?? 0;
const step = measuredFactor(l, cal, 'step'), dispatch = measuredFactor(l, cal, 'dispatch');
const scale = (n: number, f: number | null) => (f === null ? n : roundUp(n * f, cal.round_to));
const measured = new Map<string, number[]>();
for (const r of l.reservations) {
if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue;
for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / (r.readers ?? r.roles!.length)]);
}
const mean = (xs: number[]) => (xs.length ? Math.ceil(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const clean = mean([...measured.values()].flat());
const reader = (role: string) => {
const prior = unitConstants(cal, unit).per_dispatch;
const scaled = scale(prior, dispatch);
return Math.max(clean ? Math.min(scaled, Math.max(prior, clean)) : scaled, mean(measured.get(role) ?? []));
};
const perCall = measuredPerCall(l);
const project = (next: Next) => {
if (next.kind === 'dispatch') return { prior: projection(cal, unit, next), tokens: next.roles.reduce((n, role) => n + reader(role), 0) };
if (perCall !== null) {
const prior = stepProjection(cal, unit, next.step, perCall);
return { prior, tokens: prior };
}
const prior = projection(cal, unit, next);
return { prior, tokens: scale(prior, next.kind === 'dispatch' ? dispatch : step) };
return { prior, tokens: scale(prior, step) };
};
const segment = (from: string, to: string) => SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1)
.reduce((sum, s) => sum + project({ kind: 'step', step: s }).tokens + (cal.segment_dispatches[s] ?? 0) * project({ kind: 'dispatch', roles: ['specialist'] }).tokens, 0);
const label = (f: number | null) => (f === null ? 'prior' : f.toFixed(3));
const measured = new Map<string, number[]>();
for (const r of l.reservations) {
if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue;
for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / r.roles!.length]);
}
const reader = (role: string) => {
const seen = measured.get(role) ?? [];
return Math.max(project({ kind: 'dispatch', roles: [role] }).tokens, seen.length ? Math.ceil(seen.reduce((a, b) => a + b, 0) / seen.length) : 0);
};
return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}`,
floor: l.call_floor ?? cal.units[unit]?.call_floor ?? 0 };
return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}${perCall === null ? '' : `,call:${perCall}`}`, floor };
}
/**
* Measure settled reservations against the current (fresh) reading: a step
* from its reading to now; settled dispatches as one group from the earliest
* of their readings, split by role. A charge the meter cannot see (settled
* --unknown, or a Claude-metered outside voice) is never measured. Returns one
* DRIFT line per measurement.
* Measure settled reservations against the current (fresh) reading (GSTA-167).
* A dispatch is measured only from the reading taken right before its readers
* started (--dispatched-at) to the one it was settled with after they all
* returned, both read inside the dispatch program, where the parent makes no
* model call; dispatches with the same pair split it per reader (--readers,
* else one per role). One settled without a reading (review-log --finish, a
* step change), without --dispatched-at (a later program), or after another
* command read the meter in between is UNMEASURED: its interval also holds the
* parent's own work, so it teaches nothing.
* A step is measured on a step change, from its reading to now minus the
* dispatches reserved after it, so the parent's interleaved calls stay in the
* step; a window holding an unmeasured dispatch leaves the step unmeasured. A
* charge the meter cannot see (settled --unknown, or a Claude-metered outside
* voice) is never measured. Returns one DRIFT or UNMEASURED line each.
*/
export function measureSettled(l: Ledger): string[] {
export function measureSettled(l: Ledger, stepChange = true): string[] {
const last = l.meter.last;
if (last === null) return [];
const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen
const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen && !r.unmeasured
&& typeof r.reading_at === 'number' && r.epoch === l.meter.epoch && last >= r.reading_at);
const dispatches = pending.filter(r => r.kind === 'dispatch');
const from = Math.min(...dispatches.map(r => r.reading_at!));
const roles = dispatches.reduce((n, r) => n + (r.roles?.length ?? 1), 0);
return pending.map(r => {
const actual = r.kind === 'dispatch' ? Math.round(((last - from) * (r.roles?.length ?? 1)) / roles) : last - r.reading_at!;
const lines: string[] = [];
const measure = (r: Reservation, actual: number) => {
r.actual = actual;
(l.samples ??= []).push({ id: r.id, kind: r.kind as Sample['kind'], epoch: l.meter.epoch, prior: r.prior ?? r.tokens, projected: r.tokens, actual });
return `DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`;
});
lines.push(`DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`);
};
const unmeasured = (r: Reservation, reason: string) => {
r.unmeasured = true;
lines.push(`UNMEASURED: id=${r.id} kind=${r.kind} next=${r.next} reason=${reason}`);
};
const dispatches = pending.filter(r => r.kind === 'dispatch');
for (const r of dispatches) {
if (r.settled_reading === undefined) unmeasured(r, 'settled_without_reading');
else if (r.late) unmeasured(r, 'reading_between');
else if (r.dispatched_at === undefined) unmeasured(r, 'no_dispatch_reading');
}
const count = (r: Reservation) => r.readers ?? r.roles?.length ?? 1;
const measurable = dispatches.filter(r => !r.unmeasured);
for (const key of new Set(measurable.map(r => `${r.dispatched_at}:${r.settled_reading}`))) {
const group = measurable.filter(r => `${r.dispatched_at}:${r.settled_reading}` === key);
const span = group[0]!.settled_reading! - group[0]!.dispatched_at!;
const readers = group.reduce((n, r) => n + count(r), 0);
for (const r of group) measure(r, Math.round((span * count(r)) / readers));
}
if (!stepChange) return lines;
for (const r of pending.filter(x => x.kind === 'step')) {
const inside = l.reservations.slice(l.reservations.indexOf(r) + 1).filter(d => d.kind === 'dispatch' && d.state !== 'released' && !d.unseen && d.epoch === l.meter.epoch);
if (inside.some(d => d.actual === undefined)) { unmeasured(r, 'dispatch_unmeasured'); continue; }
measure(r, Math.max(0, last - r.reading_at! - inside.reduce((n, d) => n + d.actual!, 0)));
}
return lines;
}
/** Steps from..to inclusive plus their usual reviewer batches at the prior: a child's segment estimate. */
@@ -218,6 +295,33 @@ export function segmentEstimate(cal: Calibration, unit: string, from: string, to
return SHIP_STEPS.slice(i, j + 1).reduce((sum, s) => sum + stepProjection(cal, unit, s) + (cal.segment_dispatches[s] ?? 0) * k.per_dispatch, 0);
}
/**
* Small-run rule (GSTA-167 PR 4). A delegation pays a fixed child cost (boot, install check, verify-handoff,
* section reads, reconcile: gstack-evals #34's two children cost 4.53M, 2-4.5M each) to save the parent's
* context above the host floor on each call of the segment. #34's parent calls ran 0.2-0.3M over a 0.15M
* floor, so a fresh child saves at most about 3/8 of a call, and a segment under CHILD_FIXED_COST over that
* share (12M) cannot pay for its child. A light-depth diff (runtime lines at most SMALL_DIFF_LINES) keeps
* its segment when the segment's calibrated calls at this run's own per-call charge stay under that.
*/
export const SMALL_RUN_LINES = SMALL_DIFF_LINES;
export const CHILD_FIXED_COST = 4_500_000;
export const SMALL_RUN_SEGMENT = CHILD_FIXED_COST / 0.375;
/** #34's per-call charge midpoint, used when this call's charge is unknown. */
export const SMALL_RUN_CALL = 250_000;
export function smallRunSegment(cal: Calibration, from: string, to: string, call: number | null): number {
const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1);
return steps.reduce((n, s) => n + stepCalls(cal, s), 0) * (call ?? SMALL_RUN_CALL);
}
/** The SMALL_RUN line when a segment boundary should continue instead of delegating; null keeps the delegate rule. */
export function keepsSmallRun(cal: Calibration, o: { runtimeLines: number | undefined; from: string; to: string; call: number | null }): string | null {
if (o.runtimeLines === undefined || o.runtimeLines > SMALL_RUN_LINES) return null;
const segment = smallRunSegment(cal, o.from, o.to, o.call);
if (segment > SMALL_RUN_SEGMENT) return null;
return `SMALL_RUN: runtime_lines=${o.runtimeLines}<=${SMALL_RUN_LINES} segment=${o.from}-${o.to} projected=${segment}<=${SMALL_RUN_SEGMENT}; continuing here: a child's fixed ${CHILD_FIXED_COST} would cost more than it saves`;
}
export const runDirFor = (env: NodeJS.ProcessEnv, run: string) => path.join(resolveStateRoot(env), 'ship-runs', run);
export function mintRunId(now = new Date()): string {
return `ship-${now.toISOString().replace(/[-:]/g, '').slice(0, 15)}-${crypto.randomBytes(3).toString('hex')}`;
@@ -281,10 +385,26 @@ export function admits(t: { used: number; reserved: number; unknown: number }, p
return cap === null || t.used + t.reserved + t.unknown + projected + reserve <= cap;
}
/** A cost.credits value: a non-negative decimal, kept to two places. */
export function parseCredits(raw: string | undefined, flag: string): number {
if (!/^\d+(?:\.\d+)?$/.test((raw ?? '').trim())) throw usageError(`${flag} '${raw ?? ''}' is not a credits reading; fix: pass cost.credits from the same thread.get as --reading, e.g. 845009.2`);
return Math.round(Number(raw) * 100) / 100;
}
/** Store a credits reading; the first is the baseline, and one below the last is refused like a token reading. */
export function applyCredits(l: Ledger, value: number | undefined): void {
if (value === undefined) return;
if (l.credits && value < l.credits.last) throw budgetError('BUDGET_READING_REJECTED', `--credits ${value} is below the last credits reading ${l.credits.last}`);
l.credits = { baseline: l.credits?.baseline ?? value, last: value };
}
export const creditsUsed = (l: Ledger): number | null => (l.credits ? Math.round((l.credits.last - l.credits.baseline) * 100) / 100 : null);
export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string {
const t = tally(l);
const p = projector(l);
return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`;
const credits = creditsUsed(l);
return `BUDGET: used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`;
}
/** The per-call context above the host floor that the delegate thresholds compare; null when the charge is unknown. */
@@ -296,11 +416,22 @@ export function reserveIn(l: Ledger, r: Omit<Reservation, 'id' | 'at' | 'state'>
return row;
}
/** Settle at completion: the meter already holds the spend; estimate and unseen charges are booked here. */
export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration } = {}): void {
/**
* Settle at completion: the meter already holds the spend; estimate and unseen
* charges are booked here. `dispatched` is the dispatch program's reading from
* right before the readers started, with the reader count; `late` marks one
* another command's reading came after.
*/
export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration; reading?: number; dispatched?: { at: number; readers?: number; late: boolean } } = {}): void {
if (r.state === 'settled' || r.state === 'released') return;
r.state = 'settled';
r.settled_at = new Date().toISOString();
if (opts.reading !== undefined) r.settled_reading = opts.reading;
if (opts.reading !== undefined && opts.dispatched) {
if (opts.dispatched.late) r.late = true;
else r.dispatched_at = opts.dispatched.at;
if (opts.dispatched.readers !== undefined) r.readers = opts.dispatched.readers;
}
if (opts.unknown) { l.unknown += r.tokens; r.unseen = true; return; }
if (l.meter.contract.meter === 'estimate') { l.estimate_used += r.tokens; return; }
const unseen = l.meter.contract.meter === 'claude' ? (r.roles ?? []).filter(x => x === 'outside-voice').length : 0;
@@ -331,7 +462,8 @@ const BLOCK = /```gstack-budget\n[\s\S]*?\n```\n?/;
export function recordBlock(l: Ledger): string {
const t = tally(l);
const open = l.handoffs.filter(h => !h.reconciled_at).map(h => h.sha).join(',') || 'none';
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}`;
const credits = creditsUsed(l);
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`;
}
/** The record's fenced gstack-budget block: written by the bin, never by the agent. */
@@ -352,12 +484,23 @@ export function parseRecordBlock(record: string): Record<string, string> | undef
return Object.fromEntries(body.split(/\s+/).filter(Boolean).map(kv => [kv.slice(0, kv.indexOf('=')), kv.slice(kv.indexOf('=') + 1)]));
}
/** Resolve --run or --record to a ledger file; never by branch or working directory. */
/**
* Resolve --run or --record to a ledger file; never by branch or working directory.
* A handoff child writes the shared record's block while it runs; once it has
* written its return file the record is its parent's again, so --record
* follows the parent link (the parent reconciles by --record after a child).
*/
export function ledgerFileFor(o: { run?: string; record?: string }, env: NodeJS.ProcessEnv): string {
if (o.record) {
const block = parseRecordBlock(o.record);
if (!block?.ledger) throw budgetError('BUDGET_LEDGER_INVALID', `${o.record}: no gstack-budget block (run gstack-ship-budget start --record ${o.record} first)`);
return block.ledger;
let file = block.ledger;
while (fs.existsSync(file)) {
const parent = readLedger(file).parent;
if (!parent || !fs.existsSync(parent.return_file)) break;
file = parent.ledger;
}
return file;
}
if (o.run) {
if (!/^[A-Za-z0-9._-]+$/.test(o.run)) throw usageError(`--run '${o.run}' is not a run id`);
@@ -378,7 +521,7 @@ export function reviewPaths(cwd: string, env: NodeJS.ProcessEnv): { marker: stri
export function pointMarker(marker: string | undefined, l: Ledger): void {
if (!marker) return;
if (l.cap === null) { try { fs.unlinkSync(marker); } catch {} return; }
if (l.cap === null || l.mode === 'observe') { try { fs.unlinkSync(marker); } catch {} return; }
writePrivate(marker, JSON.stringify({ run: l.run, ledger: l.file }) + '\n');
}
@@ -386,13 +529,14 @@ function activeLedger(marker: string): Ledger | undefined {
let pointer: { ledger?: string };
try { pointer = JSON.parse(fs.readFileSync(marker, 'utf8')); } catch (e: any) { if (e.code === 'ENOENT') return undefined; throw e; }
const l = pointer.ledger && fs.existsSync(pointer.ledger) ? readLedger(pointer.ledger) : undefined;
return l && l.cap !== null && !l.closed_at ? l : undefined;
return l && l.cap !== null && l.mode !== 'observe' && !l.closed_at ? l : undefined;
}
/**
* gstack-review-log --start under a cap: the dispatch needs an unspent, one-shot
* dispatch reservation of the active run, consumed atomically with the start
* capture. No marker (no cap), a marker whose ledger is gone, or a stopped run
* gstack-review-log --start under a cap: the dispatch needs a dispatch
* reservation of the active run with a role left to start (one start per role,
* so Step 11's native and outside voices share one reservation), consumed
* atomically with the start capture. No marker (no cap), a marker whose ledger is gone, or a stopped run
* started without --reservation (a later uncapped review on the branch) runs
* capture() exactly as before.
*/
@@ -405,38 +549,55 @@ export function admitReviewStart(marker: string, reservation: string | undefined
if (!reservation) throw new BudgetError('BUDGET_CHECK_SKIPPED', resultLine('gstack-review-log', 'BUDGET_CHECK_SKIPPED', `no --reservation under the ${l.cap} cap of run ${l.run}; ${fix}`), 1);
const r = l.reservations.find(x => x.id === reservation);
if (!r || r.kind !== 'dispatch') throw new BudgetError('BUDGET_CHECK_SKIPPED', resultLine('gstack-review-log', 'BUDGET_CHECK_SKIPPED', `reservation ${reservation} is ${r ? `a ${r.kind} reservation` : 'not in'} run ${l.run}${r ? '' : ' (mismatched)'}; ${fix}`), 1);
if (r.state !== 'open') throw new BudgetError('BUDGET_CHECK_SKIPPED', resultLine('gstack-review-log', 'BUDGET_CHECK_SKIPPED', `reservation ${reservation} is already ${r.state}${r.token ? ` by start ${r.token}` : ''} (one-shot); ${fix}`), 1);
const starts = r.starts ?? (r.token ? [{ token: r.token, at: r.started_at ?? '' }] : []);
const slots = r.roles?.length ?? 1;
if (r.state !== 'open' && !(r.state === 'consumed' && starts.length < slots)) throw new BudgetError('BUDGET_CHECK_SKIPPED', resultLine('gstack-review-log', 'BUDGET_CHECK_SKIPPED', `reservation ${reservation} is already ${r.state}${r.token ? ` by start ${r.token}` : ''} (one start per role: ${starts.length} of ${slots} used); ${fix}`), 1);
const out = capture();
const start = { token: out.split('\n')[0]!.trim(), at: new Date().toISOString() };
r.state = 'consumed';
r.token = out.split('\n')[0]!.trim();
r.started_at = new Date().toISOString();
r.starts = [...starts, start];
r.token ??= start.token;
r.started_at ??= start.at;
return out;
});
}
/**
* gstack-review-log --finish TOKEN: the pass is over, so settle the reservation
* that start consumed and every other dispatch reservation started in the same
* step (a pass logs one row even when its reviewers were admitted one by one).
* that start consumed, every other dispatch reservation started in the same
* step (a pass logs one row even when its reviewers were admitted one by one),
* and every later batch of the pass reserved after it that took no start (Step
* 9's Red Team or an ADMIT remainder).
*/
export function settleReviewFinish(marker: string, token: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const active = activeLedger(marker);
if (!active || !active.reservations.some(r => r.token === token && r.state === 'consumed')) return undefined;
const started = (r: Reservation) => (r.state === 'consumed' || r.state === 'settled') && (r.token === token || !!r.starts?.some(s => s.token === token));
if (!active || !active.reservations.some(started)) return undefined;
return mutateLedger(active.file, env, l => {
const r = l.reservations.find(x => x.token === token && x.state === 'consumed');
const r = l.reservations.find(started);
if (!r) return undefined;
const pass = l.reservations.filter(x => x.kind === 'dispatch' && x.state === 'consumed' && x.step === r.step);
const at = l.reservations.indexOf(r);
const pass = l.reservations.filter((x, i) => x.kind === 'dispatch' && x.step === r.step && (x.state === 'consumed' || (x.state === 'open' && i > at)));
for (const x of pass) settleIn(l, x);
return pass.map(x => x.id).join(',');
});
}
/** close's audit: review-log rows of this run with no reservation consumed before they started. */
/** Every review-log start a reservation of this run or of a handoff child below it admitted. */
function treeStarts(l: Ledger, seen = new Set<string>()): Array<{ token: string; at: string }> {
if (seen.has(l.file)) return [];
seen.add(l.file);
const own = l.reservations.flatMap(r => r.starts ?? (r.started_at && r.token ? [{ token: r.token, at: r.started_at }] : []));
const children = l.handoffs.flatMap(h => h.child_ledgers ?? []).flatMap(file => { try { return treeStarts(readLedger(file), seen); } catch { return []; } });
return [...own, ...children];
}
/** close's audit: review-log rows of this run with no reservation, its own or a handoff child's, consumed before they started. */
export function unreservedDispatches(l: Ledger): Array<{ skill: string; step: string; started: string }> {
if (!l.review_log) return [];
let lines: string[];
try { lines = fs.readFileSync(l.review_log, 'utf8').split('\n').filter(Boolean); } catch { return []; }
const reservedStarts = new Set(l.reservations.filter(r => r.started_at).map(r => r.token));
const starts = treeStarts(l);
const out: Array<{ skill: string; step: string; started: string }> = [];
for (const line of lines) {
let row: any;
@@ -444,8 +605,7 @@ export function unreservedDispatches(l: Ledger): Array<{ skill: string; step: st
const step = DIFF_REVIEW_STEP[row?.skill];
const started = row?.review_binding?.started_at ?? row?.timestamp;
if (!step || typeof started !== 'string' || Date.parse(started) < Date.parse(l.created_at)) continue;
const startFile = l.reservations.find(r => r.started_at && r.token && Math.abs(Date.parse(r.started_at) - Date.parse(started)) < 5_000);
if (!startFile || !reservedStarts.has(startFile.token)) out.push({ skill: row.skill, step, started });
if (!starts.some(s => Math.abs(Date.parse(s.at) - Date.parse(started)) < 5_000)) out.push({ skill: row.skill, step, started });
}
return out;
}
+79 -16
View File
@@ -6,7 +6,11 @@
* trail, the budget and the return contract; its sha256 goes in the parent's
* ledger with a reservation for the child's whole sub-cap. The child verifies
* the brief before anything else and admits against its sub-cap with
* child_used = reading - reading_at_handoff. The context rule never stops a
* child_used = its own counter's reading - its reading at verify-handoff (a Capy
* child verified with no reading is parent-metered: reconcile charges the parent's
* counter delta since the handoff instead; on
* Capy each run reads its own jam, subagents included, so a child's counter
* starts below its parent's); reconcile charges the BUDGET used= its return prints. The context rule never stops a
* child (GSTA-167): once it has completed a step it hands its remaining steps
* to a grandchild the same way, up to MAX_CHAIN_DEPTH handoffs from the root. It
* returns at most 40 lines through a return file; the parent reconciles once
@@ -22,7 +26,7 @@ import { wtreeFingerprint } from './script-invocation';
import { resultLine } from './result-codes';
import {
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
projector, readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger,
projector, readLedger, reserveIn, runDirFor, segmentEstimate, settleIn, tally, usageError, writeLedger, type Handoff, type Ledger,
} from './ship-budget';
import { loadMeterState, saveMeterState } from './claude-usage';
import { parseArgs, readMeter, stopLines, str, tokensOpt, writeReturnFile, type Ctx, type Outcome } from './ship-budget-cli';
@@ -51,6 +55,45 @@ export function recordSection(text: string, name: string): string[] | undefined
return lines.slice(at, end < 0 ? lines.length : end);
}
/** The ship sections each step reads (ship/sections/manifest.json); conditional ones (apple-release, measure, greptile, shared-code-reuse, handoff) load only when their trigger fires. */
const STEP_SECTIONS: Record<string, string[]> = {
'4': ['tests'], '5': ['tests'], '6': ['tests'], '6.5': ['greptile-early'], '7': ['test-coverage'], '8': ['plan-completion'], '9': ['review-army'],
'11': ['adversarial'], '13': ['changelog'], '14.5': ['documentation'], '18': ['pr-body', 'capy-pr'], '19': ['pr-body', 'capy-pr'],
};
/** The sections a child running Steps from..to reads (GSTA-167): its own steps' only. */
export function segmentSections(from: string, to: string): string[] {
return [...new Set(SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1).flatMap(s => STEP_SECTIONS[s] ?? []))];
}
const SKILL_ROOT = path.resolve(import.meta.dir, '..');
/** The exact line ranges a child of Steps from..to reads (GSTA-167): ship/SKILL.md's contract (never its preamble or section index), its own steps, the self-check and Important Rules; each segment section whole, so every reviewer brief is the section's own text. */
export function readRanges(from: string, to: string, root = SKILL_ROOT): string[] {
const read = (rel: string) => { try { const l = fs.readFileSync(path.join(root, rel), 'utf8').split('\n'); return l.at(-1) === '' ? l.slice(0, -1) : l; } catch { return null; } };
const steps = new Set<string>(SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1));
const line = (rs: [number, number][]) => rs.sort((a, b) => a[0] - b[0]).reduce<[number, number][]>((m, r) => { const last = m.at(-1); if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1]); else m.push([...r]); return m; }, []).map(([a, b]) => `${a}-${b}`).join(',');
const out: string[] = [];
const skill = read('ship/SKILL.md');
if (skill) {
const bounds = skill.flatMap((l, i) => /^(# |## (Step |Section |Important Rules))/.test(l) ? [i] : []);
const end = (i: number) => bounds.find(k => k > i) ?? skill.length;
const rs: [number, number][] = [];
skill.forEach((l, i) => {
const step = /^## Step ([\d.]+):/.exec(l)?.[1];
if (l.startsWith('# Ship:') || (step && steps.has(step)) || l.startsWith('## Section self-check') || l.startsWith('## Important Rules')) rs.push([i + 1, end(i)]);
});
out.push(`READ: ship/SKILL.md:${line(rs)}`);
}
for (const s of [...segmentSections(from, to), 'budget-handoff']) {
const rel = `ship/sections/${s}.md`;
const lines = read(rel);
if (!lines) continue;
out.push(`READ: ${rel}:${line([[1, lines.length]])}`);
}
return out;
}
function brief(l: Ledger, h: Omit<Handoff, 'sha'>, pins: Record<string, unknown>, recordText: string, o: Record<string, string | undefined>): string {
const reviews = recordSection(recordText, 'Reviews') ?? [];
const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])];
@@ -61,20 +104,22 @@ function brief(l: Ledger, h: Omit<Handoff, 'sha'>, pins: Record<string, unknown>
`# /ship handoff ${h.n}: Steps ${h.from}-${h.to}`, '',
'```gstack-handoff', JSON.stringify(block, null, 2), '```', '',
'## Start', '',
`1. Run \`gstack-ship-budget verify-handoff --in <this file>\` (on Capy add \`--reading <n>\`) before anything else. On a refusal, return its lines and stop.`,
`1. Run \`gstack-ship-budget verify-handoff --in <this file>\` before anything else, with no \`--reading\` on Capy: a subagent cannot read its own jam id, so the parent's handoff and reconcile readings measure you (\`METER: parent\`); never pass the parent's or the root's counter. On a refusal, return its lines and stop.`,
block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} <your run id>\` before any trail write.` : '2. No decision trail was named.',
`3. Run /ship --continue <this file> for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. The context rule never stops you: \`verdict=delegate\` hands your remaining steps to a fresh child the same way (ship/sections/budget-handoff.md), and a \`CONTEXT_HIGH:\` note means continue. This run is at chain depth ${h.depth} of ${MAX_CHAIN_DEPTH}.`,
block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '',
'## Read', '',
`Only ship/SKILL.md's step list and, under ship/sections/, ${[...segmentSections(h.from, h.to), 'budget-handoff'].map(s => s + '.md').join(', ')}; a conditional section only when its manifest.json trigger fires. Never read ship/sections/preamble.md in full or docs/ship-budget.md: budget-handoff.md is the whole budget procedure. Read only the \`READ:\` ranges below (\`path:first-last\`, from the gstack install root), in one parallel call.`, '', ...readRanges(h.from, h.to), '',
'## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '',
'## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '',
'## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}.`, '',
'## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}. On Capy pass no --reading or --credits: the parent charges child_used from its own counter around your work. Elsewhere the parent charges child_used from the BUDGET used= your return prints.`, '',
'## Return contract', '',
`Finish with \`gstack-ship-budget return --run <your child run> --to <last step done> --changed <record sections you changed>\`. It writes ${h.return_file} (at most ${RETURN_MAX_LINES} lines: the steps done, new HEAD and tree, the record sections changed, the BUDGET line and GSTACK_RESULT) and prints the same lines. The parent reads the record, never your transcript.`, '',
`Your last step is Step ${h.to}. After it, finish with \`gstack-ship-budget return --run <your child run> --to ${h.to} --changed <record sections you changed>\` (a child that stops earlier returns \`--status incomplete --to <last step done>\`). It writes ${h.return_file} (at most ${RETURN_MAX_LINES} lines: the steps done, new HEAD and tree, the record sections changed, the BUDGET line and GSTACK_RESULT) and prints the same lines. The parent reads the record, never your transcript.`, '',
].join('\n');
}
export function cmdHandoff(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'from', 'to', 'base', 'reviewed-tree', 'trail', 'trail-run', 'sub-cap', 'reading', 'prior-reading', 'child-session']);
const o = parseArgs(argv, ['run', 'record', 'from', 'to', 'base', 'reviewed-tree', 'trail', 'trail-run', 'sub-cap', 'reading', 'credits', 'prior-reading', 'child-session']);
const from = parseStep(str(o, 'from'), '--from'), to = parseStep(str(o, 'to'), '--to');
if (SHIP_STEPS.indexOf(from as any) > SHIP_STEPS.indexOf(to as any)) throw usageError(`--from ${from} is after --to ${to}`);
const base = str(o, 'base');
@@ -117,7 +162,7 @@ function handoffBlock(text: string): any {
}
export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['in', 'reading', 'session']);
const o = parseArgs(argv, ['in', 'reading', 'credits', 'session']);
const file = str(o, 'in');
if (!file) throw usageError('verify-handoff needs --in <brief>');
const text = fs.readFileSync(file, 'utf8');
@@ -138,6 +183,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
writeReturnFile(block.return_file, lines);
return { stdout: lines, exit: 3 };
}
const parentMetered = parent.meter.contract.meter === 'capy' && tokensOpt(o, 'reading') === undefined;
const run = mintRunId();
const dir = runDirFor(ctx.env, run);
const child: Ledger = {
@@ -145,26 +191,38 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: parent.delegate, role: 'child',
depth: h!.depth ?? (parent.depth ?? 0) + 1, ...(parent.call_floor !== undefined ? { call_floor: parent.call_floor } : {}),
parent: { run: parent.run, ledger: parent.file, handoff_sha: sha, from: block.from, to: block.to, return_file: block.return_file },
meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? block.reading_at ?? 0 : 0, last: null,
meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? tokensOpt(o, 'reading') ?? 0 : 0, last: null,
...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) },
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration,
...(parentMetered ? { parent_metered: true } : {}),
};
if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session <this child session id>');
readMeter(child, o, true);
readMeter(child, o, !parentMetered);
mutateLedger(parent.file, ctx.env, p => {
const ph = p.handoffs.find(x => x.sha === sha)!;
ph.child_ledgers = [...(ph.child_ledgers ?? []), child.file];
});
writeLedger(child, ctx.env);
pointMarker(child.marker, child);
return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`, budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 };
return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`,
...(parentMetered ? [`${PARENT_METERED} (no counter of its own: the parent's handoff and reconcile readings measure Steps ${block.from}-${block.to})`] : []),
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 };
}
export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'to', 'changed', 'status', 'reading']);
const o = parseArgs(argv, ['run', 'to', 'changed', 'status', 'reading', 'credits']);
const to = parseStep(str(o, 'to'), '--to');
const status = str(o, 'status') ?? 'complete';
if (!['complete', 'refused', 'incomplete'].includes(status)) throw usageError(`--status '${status}' is not complete, refused or incomplete`);
return mutateLedger(ledgerFileFor({ run: str(o, 'run') }, ctx.env), ctx.env, l => {
if (l.role !== 'child' || !l.parent) throw usageError(`run ${l.run} is not a child run`);
readMeter(l, o, false);
for (const r of l.reservations) {
if (r.kind !== 'step' || r.state !== 'open') continue;
if (l.meter.contract.meter === 'estimate') r.state = 'released';
else settleIn(l, r);
}
const head = git(ctx.cwd, ['rev-parse', 'HEAD']);
const tree = pinsNow(ctx.cwd, 'HEAD', ctx.env).wtree;
const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []);
@@ -174,7 +232,7 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`),
...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`),
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`];
writeReturnFile(l.parent.return_file, lines);
l.closed_at = new Date().toISOString();
@@ -182,6 +240,9 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
});
}
/** A parent-metered child's line: its BUDGET used= is not a measurement, so reconcile charges the parent's own counter delta. */
export const PARENT_METERED = 'METER: parent';
/** Parse a child's return: at most 40 lines with RETURN and GSTACK_RESULT; undefined when it is not a return. */
export function parseReturn(text: string): { handoff: string; status: string; to: string; used: number | null } | undefined {
const lines = text.split('\n').filter(Boolean);
@@ -189,11 +250,11 @@ export function parseReturn(text: string): { handoff: string; status: string; to
const ret = /^RETURN: handoff=([0-9a-f]{64}) status=(\w+) steps=[\d.]+-([\d.]+)/.exec(lines[0] ?? '');
if (!ret || !lines.some(line => line.startsWith('GSTACK_RESULT: skill=ship status='))) return undefined;
const used = /^BUDGET: used=(\d+)/m.exec(text);
return { handoff: ret[1]!, status: ret[2]!, to: ret[3]!, used: used ? Number(used[1]) : null };
return { handoff: ret[1]!, status: ret[2]!, to: ret[3]!, used: used && !text.split('\n').some(l => l.startsWith(PARENT_METERED)) ? Number(used[1]) : null };
}
export function cmdReconcile(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'return', 'handoff', 'reading', 'prior-reading']);
const o = parseArgs(argv, ['run', 'record', 'return', 'handoff', 'reading', 'credits', 'prior-reading']);
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
const retFile = str(o, 'return');
const text = retFile && fs.existsSync(retFile) ? fs.readFileSync(retFile, 'utf8') : undefined;
@@ -209,17 +270,19 @@ export function cmdReconcile(argv: string[], ctx: Ctx): Outcome {
const ret = parsed ?? (own?.handoff === h.sha ? own : undefined);
const call = readMeter(l, o, true);
const metered = h.reading_at !== null && h.epoch === l.meter.epoch && l.meter.last !== null;
const childUsed = metered ? Math.max(0, l.meter.last! - h.reading_at!) : ret?.used ?? h.sub_cap;
const childUsed = ret?.used ?? (metered ? Math.max(0, l.meter.last! - h.reading_at!) : h.sub_cap);
const r = l.reservations.find(x => x.id === h.reservation);
if (r && (r.state === 'open' || r.state === 'consumed')) { r.state = 'settled'; r.settled_at = new Date().toISOString(); }
if (l.meter.contract.meter === 'estimate') l.estimate_used += childUsed;
h.status = ret && ret.to !== h.to && ret.status === 'complete' ? 'incomplete' : ret?.status ?? 'interrupted';
h.child_used = childUsed;
h.reconciled_at = new Date().toISOString();
if (h.status !== 'complete') l.stopped = { at: h.reconciled_at, reason: `child_${h.status}`, step: h.from, next: h.from };
else if (l.stopped?.reason.startsWith('child_')) delete l.stopped;
pointMarker(l.marker, l);
const after = SHIP_STEPS[SHIP_STEPS.indexOf(h.to as any) + 1] ?? '-';
const lines = [`RECONCILED: handoff=${h.sha} child=${h.status} child_used=${childUsed}`, budgetLine(l, { call, next: h.status === 'complete' ? after : h.from, projected: 0, verdict: h.status === 'complete' ? 'continue' : 'stop' })];
if (h.status !== 'complete') lines.push(`NEXT: the child returned ${h.status}; regenerate the handoff (gstack-ship-budget handoff --run ${l.run} --from ${h.from} --to ${h.to} --base origin/<base>) or stop`);
if (h.status !== 'complete') lines.push(`NEXT: the child returned ${h.status}; regenerate the handoff (gstack-ship-budget handoff --run ${l.run} --from ${h.from} --to ${h.to} --base origin/<base>) or stop. Every check refuses until a regenerated child reconciles complete or the owner resumes (gstack-ship-budget resume --record ${l.record})`);
return { stdout: lines, exit: exitFor(h.status) };
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* ship-preflight — /ship Steps 0.9-3 and the mechanical part of Step 5 as one command
* (gstack-ship-preflight). It chains git, gh, gstack-ship-receipt carry, gstack-diff-scope,
* gstack-ship-policy, gstack-review-plan, gstack-pregate and gstack-evidence, prints a
* compact result, and stops with STOP: wherever the prose assigns the decision to the
* agent (a merge conflict, a failing or zero-run lane, the carry route, the Apple target).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { audit } from './ship-audit';
import { matchesAny } from './ship-policy';
import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
/** Data files: code-class paths with no runtime behavior, left out of RUNTIME_LINES. */
const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i;
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
Prints at most 30 lines: BRANCH, STATUS, DIFF, PR/CARRY, APPLE, SCOPE, MERGE, DISTRIBUTION, POLICY,
RUNTIME_LINES, PREGATE, one LANE line per lane, then NEXT: lines. STOP: names what the agent must
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
function integrate(cwd: string, base: string, r: Report): boolean {
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
const remote = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(git(cwd, ['ls-remote', '--symref', 'origin', 'HEAD'], 60_000).stdout)?.[1];
const head = remote ?? git(cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout.trim().replace(/^origin\//, '');
r.add(`BRANCH: ${branch || '(detached)'} base=${base}`);
if (!branch || branch === base || branch === head) { r.stop(`on the base branch (${branch || 'detached HEAD'}): ship from a feature branch`); return false; }
const status = git(cwd, ['status', '--porcelain']).stdout.split('\n').filter(Boolean);
r.add(`STATUS: modified=${status.filter(l => !l.startsWith('??')).length} untracked=${status.filter(l => l.startsWith('??')).length} (uncommitted changes ship with this run)`);
const fetch = git(cwd, ['fetch', 'origin', base], 300_000);
if (fetch.status !== 0) { r.stop(`git fetch origin ${base} failed: ${firstLine(fetch.stderr)}; restore access first`); return false; }
const stat = git(cwd, ['diff', forkPoint(cwd, base), '--shortstat']).stdout.trim();
const lines = [...stat.matchAll(/(\d+) (insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0);
const commits = git(cwd, ['rev-list', '--count', `origin/${base}..HEAD`]).stdout.trim();
r.add(`DIFF: ${stat || 'no changes'}; commits=${commits || 0}${lines > 200 ? '; over 200 lines: recommend /plan-eng-review or /autoplan' : ''}`);
const pr = hostedOrigin(cwd) ? gh(cwd, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number', '--jq', '.[0].number // empty']) : null;
const n = pr?.stdout.trim() ?? '';
if (!pr) r.add('PR: not checked (origin is a local path, not a hosted remote gh can map; Step 0 uses git-native commands)');
else if (pr.status !== 0) r.add(`CARRY: not checked (gh pr list failed: ${firstLine(pr.stderr)})`);
else if (/^\d+$/.test(n)) {
const carry = bin(cwd, 'gstack-ship-receipt', ['carry', '--pr', n, '--base', base, '--dry-run'], 120_000);
const line = (carry.stdout.match(/^CARRY: .*$/m) ?? [`CARRY: check failed (${firstLine(carry.stderr)})`])[0];
r.add(`PR: #${n}`).add(line);
if (line.startsWith('CARRY: would carry')) {
r.next(`carry route (Step 1.4): fix what the base merge broke (CHANGELOG, size caps), gstack-pregate --allow-repo-commands, then gstack-ship-receipt carry --pr ${n} --base ${base} --pregate .gstack/tmp/pregate.json`);
return false;
}
} else r.add('PR: none open');
const apple = git(cwd, ['ls-files', '--', '*.xcodeproj/*', '*.xcworkspace/*', 'Package.swift']).stdout.split('\n').filter(Boolean).map(p => p.replace(/(\.xcodeproj|\.xcworkspace)\/.*$/, '$1'));
if (apple.length) r.add(`APPLE: ${short([...new Set(apple)], 3)}`).next('Step 0.9: for an App Store/TestFlight ask, confirm the app target and follow apple-release.md; otherwise ignore');
const scope = bin(cwd, 'gstack-diff-scope', [`origin/${base}`], 60_000).stdout;
const scopeError = /^SCOPE_ERROR=(?!unmatched)(\S+)/m.exec(scope)?.[1];
if (scopeError) r.add(`SCOPE: unknown (gstack-diff-scope origin/${base}: SCOPE_ERROR=${scopeError})`);
else r.add(`SCOPE: frontend=${/SCOPE_FRONTEND=true/.test(scope)}${/SCOPE_FRONTEND=true/.test(scope) ? ' (Step 9 runs the design lite check; mention /design-review when no design review exists)' : ''}`);
return merge(cwd, base, r);
}
const forkPoint = (cwd: string, base: string) => git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim() || `origin/${base}`;
function merge(cwd: string, base: string, r: Report): boolean {
const policy = shipPolicy(cwd, base);
if (policy?.queue_mode === 'stamp-at-merge') r.add('MERGE: skipped (queue_mode stamp-at-merge: the slot stamp merges the base; docs/ship-policy.md#ship-before-ready)');
else {
const before = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const m = git(cwd, ['merge', `origin/${base}`, '--no-edit'], 300_000);
if (m.status !== 0) {
const conflicts = git(cwd, ['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean);
r.stop(conflicts.length
? `merge conflicts in ${short(conflicts, 8)}: resolve simple ones (VERSION, schema.rb, CHANGELOG ordering) yourself; for complex or ambiguous ones ask (Step 3), then commit the merge and rerun`
: `git merge origin/${base} failed: ${firstLine(m.stderr || m.stdout)}`);
return false;
}
const after = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
r.add(after === before ? 'MERGE: up to date' : `MERGE: merged origin/${base} at ${after.slice(0, 12)}`);
}
const added = git(cwd, ['diff', forkPoint(cwd, base), '--diff-filter=A', '--name-only']).stdout.split('\n');
const untracked = git(cwd, ['ls-files', '--others', '--exclude-standard']).stdout.split('\n');
const candidates = [...new Set([...added, ...untracked].filter(p => p && DIST.test(p)))];
if (!candidates.length) r.add('DISTRIBUTION: no new artifact paths');
else {
const wf = fs.existsSync(path.join(cwd, '.github/workflows')) ? fs.readdirSync(path.join(cwd, '.github/workflows')).filter(f => /release|publish|dist/i.test(f)) : [];
const gitlab = fs.existsSync(path.join(cwd, '.gitlab-ci.yml')) && /release|publish|deploy/.test(fs.readFileSync(path.join(cwd, '.gitlab-ci.yml'), 'utf8'));
r.add(`DISTRIBUTION: candidates=${short(candidates, 5)} release_workflow=${[...wf, ...(gitlab ? ['GITLAB_CI_RELEASE'] : [])].join(',') || 'none'}`)
.next('Step 2: read each candidate (and manifests for new binaries or exports); a new distributable without a pipeline needs Step 2\'s question');
}
r.add(`POLICY: ${['bump', 'queue_mode', 'review_scope', 'history'].map(k => `${k}=${policy?.[k] ?? '?'}`).join(' ')} (binds Steps 12-16 and history)`);
const plan = bin(cwd, 'gstack-review-plan', ['--base', base, '--json'], 180_000);
let runtime = 'unknown';
try {
const j = JSON.parse(plan.stdout);
const outputs: string[] = j.registry?.outputs ?? [];
const counted = (j.per_path as { path: string; lines: number; binary: boolean }[]).filter(e => !e.binary && !(j.stamp_only ?? []).includes(e.path)
&& !(outputs.length && matchesAny(outputs, e.path)) && classifyPath(e.path, cwd) === 'code' && !DATA_PATH.test(e.path));
runtime = String(counted.reduce((n, e) => n + e.lines, 0));
} catch { /* unknown */ }
r.add(`RUNTIME_LINES: ${runtime} (gstack-ship-budget check --runtime-lines at the Step 9 and 12 boundaries)`);
return true;
}
async function tests(cwd: string, base: string, list: { label: string; command: string }[], r: Report): Promise<void> {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
r.add(`PREGATE: ${verdict}`);
if (/^(fail|incomplete|unreadable)/.test(verdict)) { r.stop(`pregate ${verdict}: fix the failing checks it lists (gstack-pregate --allow-repo-commands), then rerun --tests-only`); return; }
if (/uncleared/.test(verdict)) r.next('pregate requires-remote: gstack-pregate clear, or --remote --spend-cap <usd> under a user cap, before Step 18');
const results = (await runLanes(cwd, list)).map(laneLine);
for (const l of results) r.add(l.line);
const bad = results.filter(l => !l.ok);
if (bad.length) { r.stop(`${bad.length} lane(s) failed or ran zero tests: Step 5 triage (read each log; a free-suite shard failure reruns its file list per measure.md); in-branch failures stop the ship`); return; }
r.next('Step 6: select eval suites for prompt-related changes with the project\'s selector, then Step 7');
}
export async function preflight(argv: readonly string[], cwd: string): Promise<{ text: string; exit: number }> {
const { flag, has } = flagReader(argv);
const base = flag('--base');
const list = lanes(argv);
if (has('--help') || has('-h')) return { text: PREFLIGHT_HELP + '\n', exit: 0 };
if (has('--audit')) {
const out = flag('--out');
if (!base || !out) return { text: 'gstack-ship-preflight: --audit needs --base <base> --out <dir>\n', exit: 2 };
return { text: audit(cwd, base, out).render(), exit: 0 };
}
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
const r = new Report();
if (has('--tests-only') || integrate(cwd, base, r)) {
if (list.length) await tests(cwd, base, list, r);
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
}
return { text: r.render(), exit: r.exit };
}
+241
View File
@@ -0,0 +1,241 @@
/**
* ship-release — /ship Step 12 (version), Steps 15-16 (release commit, verification)
* and Step 17 (push) as gstack-ship-release's three commands. They chain
* gstack-ship-policy, gstack-version-bump, gstack-next-version, gstack-decision-log,
* gstack-wtree, gstack-docs-candidate compare, gstack-evidence, gstack-pregate (the
* stamped-tree rerun and the sealed guard) and git, print a compact result, and stop
* with STOP: wherever the prose assigns the decision to the agent: the bump level, a
* rebump, an incomplete queue, a sibling's claim, feature-commit grouping, a changed
* reviewed tree, stale docs, failing or zero-run tests, the pre-push offer and pushes
* that fail.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { resolveStateRoot } from './state-root';
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short } from './ship-bin-common';
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
version --base <base> [--bump micro|patch|minor|major] [--version <v>] [--keep-current] [--accept-incomplete] [--why <text>]
Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision
finish --base <base> --reviewed-tree <tree> --docs-post <post.json> [--lane <label>=<command>]... [--build <command>]...
[--trailer <text>] [--docs-rechecked] [--no-lanes-approved]
Step 15's release commit, Step 16 stages 1-4 (your builds, the reviewed-tree route, docs freshness,
each lane's receipt run once if stale) and the pregate on the stamped tree
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved]
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u
At most 30 lines each; STOP: names what the agent must decide or repair, and nothing after it ran.
Exit codes: 0 ok · 2 usage · 3 STOP`;
const LEVELS: Bump[] = ['micro', 'patch', 'minor', 'major'];
function classify(cwd: string, base: string): { state?: string; json?: any; error?: string } {
const c = bin(cwd, 'gstack-version-bump', ['classify', '--base', base], 60_000);
if (c.status !== 0) return { error: firstLine(c.stderr) || `exit ${c.status}` };
try { const json = JSON.parse(c.stdout); return { state: json.state, json }; } catch { return { error: 'classify printed no JSON' }; }
}
/** The first component that changed from base to current (an absent fourth is zero). */
function derivedLevel(base: string, current: string): Bump | null {
const a = parseVersion(base), b = parseVersion(current);
if (!a || !b) return null;
const order: Bump[] = ['major', 'minor', 'patch', 'micro'];
const i = a.findIndex((n, k) => n !== b[k]);
return i < 0 ? null : order[i]!;
}
function queue(cwd: string, base: string, level: Bump, baseVersion: string): { version?: string; json?: any; note: string } {
const r = bin(cwd, 'gstack-next-version', ['--base', base, '--bump', level, '--current-version', baseVersion], 180_000);
let json: any;
try { json = JSON.parse(r.stdout); } catch { return { note: `unverified (gstack-next-version exit ${r.status}, no JSON)` }; }
const usable = r.status === 0 && typeof json.version === 'string' && parseVersion(json.version) && (json.offline === false || json.fallback === 'git');
return usable ? { version: json.version, json, note: json.offline ? 'git-fallback' : 'live' } : { json, note: 'unverified (offline without the git fallback, or no valid version)' };
}
export function version(argv: readonly string[], cwd: string): Report {
const { flag, has } = flagReader(argv);
const base = flag('--base')!;
const r = new Report();
const policy = shipPolicy(cwd, base);
const fragment = fragmentPath(policy, cwd);
if (policy?.queue_mode === 'stamp-at-merge' || fragment) return r.add(`VERSION: skipped (policy: ${fragment ? 'changelog_fragments' : 'queue_mode stamp-at-merge'} replaces Step 12 items 1-4; the merge stamps it)`).next(fragment ? `Step 13: write the entry as ${fragment} (entry text only, no heading), never an [Unreleased] section` : 'Step 13: write the CHANGELOG entry under ## [Unreleased]');
let c = classify(cwd, base);
if (c.error) return r.stop(`a configured version file is missing, empty, unreadable or malformed: ${c.error}; fix it or its pin, then rerun (never substitute 0.0.0.0)`);
if (c.state === 'DRIFT_STALE_PKG') {
const rep = bin(cwd, 'gstack-version-bump', ['repair'], 60_000);
r.add(`REPAIR: ${rep.status === 0 ? 'package manifest synced to VERSION' : `failed (${firstLine(rep.stderr)})`}`);
if (rep.status !== 0) return r.stop('gstack-version-bump repair failed; fix the manifest, then rerun');
c = classify(cwd, base);
if (c.error || c.state !== 'ALREADY_BUMPED') return r.stop(`after repair the state is ${c.state ?? c.error}; reconcile by hand, then rerun`);
}
const j = c.json;
if (c.state === 'NO_VERSION') return r.add(`VERSION: skipped (no version source: ${j.versionSource?.path ?? '-'}, ${j.versionSource?.reason ?? '-'})`).next('ship without a version change: no Step 13 entry heading, an unprefixed Step 18 title, "version":null in Step 20');
if (c.state === 'DRIFT_UNEXPECTED') return r.stop('package.json disagrees with VERSION while VERSION matches base: reconcile the manual edit, then rerun');
if (c.state !== 'FRESH' && c.state !== 'ALREADY_BUMPED') return r.stop(`unknown classify state ${c.state}`);
const explicit = flag('--version');
if (explicit && !parseVersion(explicit)) return r.stop(`--version ${explicit} is not a version`);
const fresh = c.state === 'FRESH';
const asked = flag('--bump') as Bump | undefined;
if (asked && !LEVELS.includes(asked)) return r.stop(`--bump ${asked} is not micro, patch, minor or major`);
const level = fresh ? asked ?? (policy?.bump === 'patch' ? 'patch' : undefined) : asked ?? derivedLevel(j.baseVersion, j.currentVersion) ?? undefined;
if (!level) {
const stat = git(cwd, ['diff', `origin/${base}`, '--shortstat']).stdout.trim();
return r.stop(`BUMP_LEVEL needed (FRESH, ${stat || 'no diff'}): MICRO under 50 lines, PATCH 50+ with no feature signal, MINOR/MAJOR ask first (Step 12.2); rerun with --bump <level>`);
}
let q = queue(cwd, base, level, j.baseVersion);
if (q.json?.complete === false) q = queue(cwd, base, level, j.baseVersion);
if (q.json?.complete === false && !has('--accept-incomplete')) return r.stop(`the queue read is incomplete (unknown_claims=${(q.json.unknown_claims ?? []).join(',')}): ask to use ${q.version ?? 'the pick'} (recommended; Version Gate rereads the queue) and rerun with --accept-incomplete, or stop`);
for (const w of (q.json?.warnings ?? []).slice(0, 3)) r.add(`QUEUE_WARNING: ${String(w).slice(0, 200)}`);
let next = explicit ?? (fresh ? q.version ?? fmtVersion(bumpVersion(parseVersion(j.baseVersion)!, level, versionWidth(j.baseVersion)), versionWidth(j.baseVersion)) : j.currentVersion);
if (!fresh && !explicit && q.version && q.version !== j.currentVersion && !has('--keep-current')) {
return r.stop(`ALREADY_BUMPED at ${j.currentVersion} but the queue picks ${q.version}: ask to rebump (rerun with --version ${q.version}; refresh the CHANGELOG and PR title) or keep (rerun with --keep-current; CI rejects a collision)`);
}
const sibling = (q.json?.active_siblings ?? []).find((s: any) => s.version && parseVersion(s.version) && cmpVersion(parseVersion(s.version)!, parseVersion(next)!) >= 0);
if (sibling && !explicit) return r.stop(`sibling ${sibling.branch} holds ${sibling.version} >= ${next}: choose to advance past it (rerun with --version <v>) or stop and sync`);
if (!fresh && explicit === undefined) {
r.add(`VERSION: ${j.baseVersion} -> ${next} level=${level} state=ALREADY_BUMPED queue=${q.note} (kept, no write)`);
return r.next(`Step 13: CHANGELOG entry under ${next}`);
}
if (cmpVersion(parseVersion(next)!, parseVersion(j.baseVersion)!) <= 0) return r.stop(`${next} is not above the base version ${j.baseVersion}`);
const changelog = typeof policy?.changelog === 'string' ? policy.changelog : 'CHANGELOG.md';
let w = bin(cwd, 'gstack-version-bump', ['write', '--version', next, '--regen-digest', '--changelog', changelog], 300_000);
if (w.status === 3) {
bin(cwd, 'gstack-version-bump', ['repair'], 60_000);
w = classify(cwd, base).state === 'ALREADY_BUMPED' ? { ...w, status: 0, stdout: w.stdout || '{}' } : w;
}
if (w.status !== 0) return r.stop(`gstack-version-bump write exited ${w.status}: ${firstLine(w.stderr)}${w.status === 2 ? ' (nothing was written)' : ''}`);
let wrote: any = {};
try { wrote = JSON.parse(w.stdout); } catch { /* reported below */ }
r.add(`VERSION: ${j.baseVersion} -> ${next} level=${level} state=${c.state} queue=${q.note} wrote=${wrote.wrote ?? next}`);
if (wrote.agentsDigest === false) r.next('run bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts and stage the digest with the bump');
const why = flag('--why') ?? `${level} bump from the diff`;
bin(cwd, 'gstack-decision-log', [JSON.stringify({ decision: `Ship ${next} (${level})`, rationale: why, scope: 'repo', source: 'skill', confidence: 9 })], 30_000);
return r.next(`Step 13: CHANGELOG entry under ${next}`);
}
function commitRelease(cwd: string, release: string[], trailer: string | undefined, r: Report): boolean {
const dirty = git(cwd, ['status', '--porcelain', '-z']).stdout.split('\0').filter(Boolean).map(l => l.slice(3));
const feature = dirty.filter(p => !release.includes(p));
if (feature.length) { r.stop(`uncommitted changes outside release metadata: ${short(feature, 8)}; commit them in bisectable chunks with their tests and docs (Step 15), then rerun`); return false; }
const meta = dirty.filter(p => release.includes(p));
if (!meta.length) { r.add('COMMIT: nothing left to commit'); return true; }
git(cwd, ['add', '--', ...meta]);
const v = meta.includes('VERSION') && fs.existsSync(path.join(cwd, 'VERSION')) ? fs.readFileSync(path.join(cwd, 'VERSION'), 'utf8').trim() : null;
const msg = `${v ? `chore: bump version and changelog (v${v})` : 'chore: changelog and TODOS'}${trailer ? `\n\n${trailer}` : ''}`;
const c = git(cwd, ['commit', '-q', '-m', msg]);
if (c.status !== 0) { r.stop(`release commit failed: ${firstLine(c.stderr || c.stdout)}`); return false; }
r.add(`COMMIT: ${git(cwd, ['rev-parse', '--short=12', 'HEAD']).stdout.trim()} ${msg.split('\n')[0]} (${short(meta, 5)})`);
return true;
}
function route(cwd: string, reviewed: string, release: string[], docsRechecked: boolean, r: Report): boolean {
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return false; }
const d = git(cwd, ['diff', '--name-only', reviewed, now]);
if (d.status !== 0) { r.stop(`the reviewed tree ${reviewed} is unreadable (${firstLine(d.stderr)}): a missing snapshot blocks stage 2`); return false; }
const changed = d.stdout.split('\n').filter(Boolean);
const behavior = changed.filter(p => !release.includes(p) && classifyPath(p, cwd) !== 'docs');
const docs = changed.filter(p => !release.includes(p) && classifyPath(p, cwd) === 'docs');
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${behavior.length} docs=${docs.length} metadata=${changed.length - behavior.length - docs.length}`);
if (behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(behavior)}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return false; }
if (docs.length && !docsRechecked) { r.stop(`authored docs changed since review (${short(docs)}): recheck the plan items they touch (stage 2.2), then rerun with --docs-rechecked`); return false; }
return true;
}
export async function finish(argv: readonly string[], cwd: string): Promise<Report> {
const { flag, all, has } = flagReader(argv);
const base = flag('--base')!;
const r = new Report();
const list = lanes(argv) as { label: string; command: string }[];
const policy = shipPolicy(cwd, base);
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD'), flag('--trailer'), r)) return r;
for (const cmd of all('--build')) {
const b = sh(cwd, cmd);
r.add(`BUILD: ${b.status === 0 ? 'ok' : `FAIL exit=${b.status}`} ${cmd.slice(0, 120)}`);
if (b.status !== 0) return r.stop(`Build failed or prerequisite missing: ${firstLine(b.stderr || b.stdout)}; repair it, then repeat stage 1`);
}
if (!all('--build').length) r.add('BUILD: none passed (record the inspected sources, or pass --build per declared command)');
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!);
if (!route(cwd, flag('--reviewed-tree')!, release, has('--docs-rechecked'), r)) return r;
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', flag('--docs-post')!], 120_000);
let stale: string[] | null = null;
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
if (stale === null) return r.stop(`gstack-docs-candidate compare failed: ${firstLine(cmp.stderr)}; documentation freshness is unknown (stage 3)`);
r.add(`DOCS: ${stale.length ? `stale (${short(stale)})` : 'fresh against the Step 14.5 post-audit snapshot'}`);
if (stale.length) return r.stop('documentation inputs changed since the accepted audit: use stage 3 recovery (the remaining re-audit, or a named risk); never refresh hashes');
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
const allow = evidenceExempt(release).join(',');
const check = (l: { label: string; command: string }) => bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24', '--allow-paths', allow], 60_000).stdout;
for (const l of list) {
let e = check(l);
if (!/EVIDENCE: FRESH/.test(e)) {
const ran = laneLine((await runLanes(cwd, [l]))[0]!);
r.add(ran.line);
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: STOP publication; run Steps 5-15 from Step 5's triage, then Step 16 stage 1`);
e = check(l);
}
const line = /^EVIDENCE: .*$/m.exec(e)?.[0] ?? `EVIDENCE: unreadable label=${l.label}`;
r.add(line.replace(/ log=\S+/, ''));
if (!/EVIDENCE: FRESH/.test(line)) return r.stop(`lane ${l.label} is not FRESH after one rerun: Step 16 stage 4 recovery`);
}
const v = bin(cwd, 'gstack-pregate', ['verify'], 60_000);
if (v.status === 0) r.add('PREGATE: verify ok on the stamped tree');
else {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
r.add(`PREGATE: ${verdict} (rerun on the stamped tree)`);
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the stamped tree: fix it${/uncleared/.test(verdict) ? ', or gstack-pregate clear with the remote run URL' : ''}, then rerun finish`);
}
return r.next('Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17\'s guard block and gstack-ship-release push');
}
export function push(argv: readonly string[], cwd: string): Report {
const { flag, has } = flagReader(argv);
const r = new Report();
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
if (!branch) return r.stop('detached HEAD: push needs the feature branch');
if (branch !== flag('--branch')) return r.stop(`HEAD is on ${branch}, not the branch Step 1 saved (${flag('--branch')}): check out the saved branch, then rerun push`);
const guard = bin(cwd, 'gstack-config', ['get', 'redact_prepush_hook'], 30_000).stdout.trim() || 'false';
const prompted = fs.existsSync(path.join(resolveStateRoot(process.env), '.redact-prepush-prompted'));
r.add(`REDACT_PREPUSH: ${guard} prompted=${prompted ? 'yes' : 'no'}`);
if (guard !== 'true' && !prompted) return r.stop('Step 17\'s one-time pre-push guard offer is unanswered: run the guard block and ask it, then rerun push');
const gateOpen = Number(flag('--gate-open') ?? '0');
if (gateOpen > 0) {
if (!hostedOrigin(cwd)) return r.stop('open-PR lookup unavailable: origin is a local path, not a hosted remote gh can map; it must succeed before pushing open gate findings');
const pr = gh(cwd, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,isDraft', '--jq', '[.[] | select(.isDraft | not) | .number] | first // empty']);
if (pr.status !== 0) return r.stop(`open-PR lookup failed (${firstLine(pr.stderr)}): it must succeed before pushing open gate findings`);
if (pr.stdout.trim() && !has('--gate-open-approved')) return r.stop(`${gateOpen} open gate finding(s) and ready PR #${pr.stdout.trim()}: spawned/headless sessions stop here; otherwise ask (resolve now, or publish with the banner and rerun with --gate-open-approved)`);
}
const sealed = bin(cwd, 'gstack-pregate', ['sealed'], 120_000);
r.add(`SEALED: ${sealed.status === 0 ? 'clear' : sealed.status === 1 ? 'refused' : `not checked (exit ${sealed.status})`}`);
if (sealed.status === 1) return r.stop(`sealed output would be published: ${firstLine(sealed.stderr)}; remove it from the commits, then repeat Step 16`);
if (sealed.status !== 0) return r.stop(`the sealed-output guard did not run (exit ${sealed.status}: ${firstLine(sealed.stderr || sealed.stdout)}); repair it, then rerun push`);
const local = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`);
if (remote.stdout.split(/\s/)[0] === local) return r.add(`PUSH: ALREADY_PUSHED ${local.slice(0, 12)}`).next('Step 18');
const p = git(cwd, ['push', '-u', 'origin', branch], 600_000);
if (p.status !== 0) {
const nff = /non-fast-forward|fetch first|rejected/.test(p.stderr);
return r.stop(nff ? 'non-fast-forward: fetch and merge the remote under Step 3\'s conflict rules, run Steps 5-16, then push again; never force-push'
: `push failed (${firstLine(p.stderr)}): repair the auth, hook or network cause, repeat Step 16, then push; never bypass a guard`);
}
return r.add(`PUSH: pushed ${local.slice(0, 12)} to origin/${branch}`).next('Step 18');
}
export async function release(argv: readonly string[], cwd: string): Promise<{ text: string; exit: number }> {
const [cmd, ...rest] = argv;
const { flag, has } = flagReader(rest);
if (!cmd || cmd === '--help' || cmd === '-h' || has('--help')) return { text: RELEASE_HELP + '\n', exit: cmd ? 0 : 2 };
const usage = (m: string) => ({ text: `gstack-ship-release: ${m}\n`, exit: 2 });
if (cmd !== 'push' && !flag('--base')) return usage(`${cmd} needs --base <base>`);
if (cmd === 'push' && !flag('--branch')) return usage('push needs --branch <the branch Step 1 saved>');
if (typeof lanes(rest) === 'string') return usage(lanes(rest) as string);
if (cmd === 'finish' && (!flag('--reviewed-tree') || !flag('--docs-post'))) return usage('finish needs --reviewed-tree <the tree saved before Step 12> and --docs-post <Step 14.5\'s post-audit snapshot>');
const r = cmd === 'version' ? version(rest, cwd) : cmd === 'finish' ? await finish(rest, cwd) : cmd === 'push' ? push(rest, cwd) : null;
if (!r) return usage(`unknown command ${cmd}; run gstack-ship-release --help`);
return { text: r.render(), exit: r.exit };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.344",
"version": "1.91.345",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+12
View File
@@ -901,6 +901,15 @@ export function renderAutoplanDriver(options: RenderOptions): string {
return GENERATED_HEADER.replace('{{SOURCE}}', `${AUTOPLAN_DRIVER_RELPATH}.tmpl`) + resolvePlaceholders(tmpl, ctx, getHostConfig('claude'), `${AUTOPLAN_DRIVER_RELPATH}.tmpl`, options);
}
/** ship/DRIVER.md (GSTA-167 PR 5): the compact /ship entry for a parent agent, rendered the same way. */
export const SHIP_DRIVER_RELPATH = 'ship/DRIVER.md';
export function renderShipDriver(options: RenderOptions): string {
const tmplPath = path.join(ROOT, `${SHIP_DRIVER_RELPATH}.tmpl`);
const tmpl = fs.readFileSync(tmplPath, 'utf-8');
const ctx = buildContext(tmpl, tmplPath, 'claude', options, 'ship');
return GENERATED_HEADER.replace('{{SOURCE}}', `${SHIP_DRIVER_RELPATH}.tmpl`) + resolvePlaceholders(tmpl, ctx, getHostConfig('claude'), `${SHIP_DRIVER_RELPATH}.tmpl`, options);
}
/**
* Generate one on-demand section file (`<skill>/sections/<name>.md.tmpl` →
* `<name>.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim,
@@ -1092,6 +1101,9 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
if (host === 'claude' && includesSkill(hostConfig, 'autoplan') && templates.some(t => t.tmpl === 'autoplan/SKILL.md.tmpl')) {
emit(path.join(options.outputRoot, AUTOPLAN_DRIVER_RELPATH), renderAutoplanDriver(options), 'asset', host);
}
if (host === 'claude' && includesSkill(hostConfig, 'ship') && templates.some(t => t.tmpl === 'ship/SKILL.md.tmpl')) {
emit(path.join(options.outputRoot, SHIP_DRIVER_RELPATH), renderShipDriver(options), 'asset', host);
}
if (host === 'openclaw') {
for (const variant of ['lite', 'full', 'plan'] as const) {
+4
View File
@@ -319,6 +319,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/restamp-gate-ahead-changelog.test.ts',
reason: 'spawns bin/gstack-restamp (shebang) and a POSIX release tool through test/helpers/restamp-fixture.ts',
},
{
file: 'test/ship-release.test.ts',
reason: 'spawns bin/gstack-ship-release (shebang) directly and writes #!/bin/sh gh stubs through test/helpers/ship-bin-fixture.ts; windows-latest exits before the bin runs (windows-free-shard 3, run 41073592483)',
},
];
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
+2
View File
@@ -54,6 +54,7 @@ import { generatePlanRealityRows } from './plan-reality';
import { generateSidewaysSweep, generateFlakeFixNumber } from './sideways';
import { generateCoordinatorContract } from './coordinator-contract';
import { generateAutoplanDriverCodes, generateAutoplanDriverCommands, generateAutoplanDriverFiles, generateAutoplanDriverGate, generateAutoplanDriverPhases, generateAutoplanDriverResultFormat } from './autoplan-driver';
import { generateShipDriverStops } from './ship-driver';
import { generateQAScope, generateQAExploratory, generateQAFunctional, generateQAResource, generateQAReview, generateQAReviewPreflight, generateQAMethodReads } from './qa';
import { generateVoiceTriggerRouting } from './voice-triggers';
import { generateTranscriptDiscovery, generateTranscriptSources } from './transcripts';
@@ -191,6 +192,7 @@ export const RESOLVERS: Record<string, ResolverFn> = {
AUTOPLAN_DRIVER_GATE: generateAutoplanDriverGate,
AUTOPLAN_DRIVER_FILES: generateAutoplanDriverFiles,
AUTOPLAN_DRIVER_CODES: generateAutoplanDriverCodes,
SHIP_DRIVER_STOPS: generateShipDriverStops,
SECTION,
SECTION_INDEX,
TRANSCRIPT_SOURCES: generateTranscriptSources,
+40
View File
@@ -0,0 +1,40 @@
/**
* ship/DRIVER.md resolvers (GSTA-167 PR 5): the compact entry a parent agent
* reads to run an unattended /ship, instead of ship/SKILL.md plus its sections.
* The phases live in ship/DRIVER.md.tmpl; this file owns the one list a reader
* must trust most: which heading each STOP sends the parent to. test/ship-driver.test.ts
* resolves every anchor against the generated files.
*/
import type { ResolverFn } from './types';
/** Each STOP (or read-when line) a driver run can meet, and the one heading it reads, relative to <root>/ship/. */
export const SHIP_DRIVER_STOPS: Array<{ when: string; read: string }> = [
{ when: '`merge conflicts`', read: 'SKILL.md#step-3-merge-the-base-branch-before-tests' },
{ when: '`NEXT: carry route`', read: 'SKILL.md#step-1-pre-flight' },
{ when: '`DISTRIBUTION: candidates=`', read: 'SKILL.md#step-2-distribution-pipeline-check' },
{ when: '`APPLE:` with a store ask', read: 'sections/apple-release.md#apple-app-store-release' },
{ when: '`pregate fail` or `incomplete`', read: 'sections/tests.md#step-5-run-tests-on-merged-code' },
{ when: 'a failed or zero-run lane', read: 'sections/tests.md#test-failure-ownership-triage' },
{ when: 'a red eval or a failed free-suite shard', read: 'sections/measure.md#measure-then-fix-loop-a-red-paid-eval-or-a-failed-free-suite-shard' },
{ when: 'Greptile found', read: 'sections/greptile-early.md#step-65-early-greptile-pr-after-the-free-tests-pass' },
{ when: 'coverage below target, a failed audit', read: 'sections/test-coverage.md#step-7-test-coverage-audit' },
{ when: 'plan items NOT DONE or UNVERIFIABLE', read: 'sections/plan-completion.md#gate-logic' },
{ when: '`NEXT: stop`', read: 'sections/review-army.md#decide-whether-to-repeat-step-9' },
{ when: 'a `REVIEW_SCOPE_*` refusal, the review-log row', read: 'sections/review-army.md#step-94-fix-first-and-persistence' },
{ when: 'a required probe not passed', read: 'sections/review-army.md#step-921-exploratory-qa-before-fix-first' },
{ when: 'native adversarial incomplete, queued fixes', read: 'sections/adversarial.md#finish-the-adversarial-phase' },
{ when: 'Step 11.5 mismatch', read: 'SKILL.md#step-115-bind-the-reviews' },
{ when: 'a `version` STOP', read: 'SKILL.md#step-12-version-bump-auto-decide' },
{ when: '`Documentation: blocked` or a `DOCS_CAP:` over', read: 'sections/documentation.md#blocked-recovery' },
{ when: 'finish: changed since review', read: 'SKILL.md#2-choose-the-change-route' },
{ when: 'finish: documentation inputs changed', read: 'SKILL.md#3-resolve-documentation-freshness' },
{ when: 'finish: build, lane or pregate STOP', read: 'SKILL.md#4-verify-the-frozen-candidate' },
{ when: 'a push STOP', read: 'SKILL.md#step-17-push' },
{ when: 'unattended, no open PR at HEAD', read: 'sections/handoff.md#step-18-h-handoff-without-a-pr-automation-lanes' },
{ when: 'a receipt write or `gstack-post` refusal', read: 'sections/pr-body.md#compose-the-body-then-publish-through-gstack-post' },
{ when: '`verdict=stop` or `DELEGATE:`', read: 'sections/budget-handoff.md#budget-and-context-handoff-read-when-a-cap-is-set-or-verdictdelegate-prints' },
{ when: '`QUEUE_RECEIPT_STALE`', read: '../docs/ship-policy.md#ship-before-ready' },
];
export const generateShipDriverStops: ResolverFn = () =>
SHIP_DRIVER_STOPS.map(s => `- ${s.when}: \`${s.read}\``).join('\n');
+153
View File
@@ -0,0 +1,153 @@
<!-- AUTO-GENERATED from ship/DRIVER.md.tmpl — do not edit directly -->
<!-- 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
(`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
`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 shell
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
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**
(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,
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 restates none of them and
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.
**Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's
section read or a fix-check. A pass that fixes something repeats Phases 3-4.
**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`, R and C read in that program
(Capy: your own jam's `cost.tokens` and `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
`--dispatched-at <R> --dispatched-credits <C> --readers <n>` with the reading after the last settles.
**Waits.** No call waits past 240 s: a program returns whatever still runs at 240 s; the next call resumes (Capy: `wait` 240 s on it). A small
waiting child saves nothing. Past 100 minutes, stop.
**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-plan --base <base>` with the pass's
flags (its `DISPATCH:` lines; 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) one subagent per reader on its rendered file, then
`sent` (`BRIEF_MISMATCH` voids it);
(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,
given the specialists' collected lines) repeats (3)-(6); (7) the collect: return only each reader's
final output and status, via `collect`. The next call applies the section's merge, judgment, fixes and persistence;
it never walks the dispatch steps call by call.
## 1. Load: Step 0
The install check and 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): `STATUS:`, `DIFF:`, `PR:`, `CARRY:`, `SCOPE:`, `MERGE:`, `DISTRIBUTION:`,
`POLICY:`, `RUNTIME_LINES:`, `PREGATE:`, `LANE:`. `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`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the project's 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
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>`. The same program reads
`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`.
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 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
reader ran.
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
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` reruns Step 5 (`--tests-only`) and Phases 3-4; `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)`).
## 5. Voices, bind and version: Steps 11, 11.5, 12
Judge the voices; missing outside coverage is `unavailable`, never clean; queued fixes rerun Phases
3-4. Then `gstack-review-dispatch verify` (`VERIFY: clean`) and `gstack-review-read`: the final Step 9 row and Step 11 native row (or `CURRENT` freshness
after a planned skip) agree on `wtree`, `start_wtree`, `end_wtree`; save that reviewed tree. Then
`gstack-ship-release version --base <base> [--bump micro|patch] --why '<line>'`; MINOR or MAJOR is a
question; `VERSION: skipped (policy: ...)`: write no version.
## 6. Notes and documentation: Steps 13, 14, 14.5
Step 13: one user-facing entry from the commits file, where `NEXT:` says;
`gstack-prose-lint <it> --base origin/<base> --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and
completions, edited from the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
one program (above), then `gstack-ship-receipt docs-caps --base <base>`; print `Documentation:`.
One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit features in 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:`
(pushed or ALREADY_PUSHED). Its pre-push offer STOP runs Step 17's guard block (`HOOK_STATE:`,
`HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`); unattended takes the recommended install. Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- 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; with `CAPY_PR: on` (Step 19-C) What changed, Why, Blast radius, Approvals (`gstack-approvals render`); Summary, Test Coverage, Pre-Landing Review (`REVIEW_LOOP:`, `Gate edits:`, deferred, notes), Exploratory QA, Plan Completion, Verification Results, Issues, TODOS, Documentation, Test plan. `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.
## Where each STOP sends you (paths under `<root>/ship/`)
- `merge conflicts`: `SKILL.md#step-3-merge-the-base-branch-before-tests`
- `NEXT: carry route`: `SKILL.md#step-1-pre-flight`
- `DISTRIBUTION: candidates=`: `SKILL.md#step-2-distribution-pipeline-check`
- `APPLE:` with a store ask: `sections/apple-release.md#apple-app-store-release`
- `pregate fail` or `incomplete`: `sections/tests.md#step-5-run-tests-on-merged-code`
- a failed or zero-run lane: `sections/tests.md#test-failure-ownership-triage`
- a red eval or a failed free-suite shard: `sections/measure.md#measure-then-fix-loop-a-red-paid-eval-or-a-failed-free-suite-shard`
- Greptile found: `sections/greptile-early.md#step-65-early-greptile-pr-after-the-free-tests-pass`
- coverage below target, a failed audit: `sections/test-coverage.md#step-7-test-coverage-audit`
- plan items NOT DONE or UNVERIFIABLE: `sections/plan-completion.md#gate-logic`
- `NEXT: stop`: `sections/review-army.md#decide-whether-to-repeat-step-9`
- a `REVIEW_SCOPE_*` refusal, the review-log row: `sections/review-army.md#step-94-fix-first-and-persistence`
- a required probe not passed: `sections/review-army.md#step-921-exploratory-qa-before-fix-first`
- native adversarial incomplete, queued fixes: `sections/adversarial.md#finish-the-adversarial-phase`
- Step 11.5 mismatch: `SKILL.md#step-115-bind-the-reviews`
- a `version` STOP: `SKILL.md#step-12-version-bump-auto-decide`
- `Documentation: blocked` or a `DOCS_CAP:` over: `sections/documentation.md#blocked-recovery`
- finish: changed since review: `SKILL.md#2-choose-the-change-route`
- finish: documentation inputs changed: `SKILL.md#3-resolve-documentation-freshness`
- finish: build, lane or pregate STOP: `SKILL.md#4-verify-the-frozen-candidate`
- a push STOP: `SKILL.md#step-17-push`
- unattended, no open PR at HEAD: `sections/handoff.md#step-18-h-handoff-without-a-pr-automation-lanes`
- a receipt write or `gstack-post` refusal: `sections/pr-body.md#compose-the-body-then-publish-through-gstack-post`
- `verdict=stop` or `DELEGATE:`: `sections/budget-handoff.md#budget-and-context-handoff-read-when-a-cap-is-set-or-verdictdelegate-prints`
- `QUEUE_RECEIPT_STALE`: `../docs/ship-policy.md#ship-before-ready`
+127
View File
@@ -0,0 +1,127 @@
# /ship driver: run /ship from a parent agent
Read this instead of `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
`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 shell
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
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**
(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,
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 restates none of them and
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.
**Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's
section read or a fix-check. A pass that fixes something repeats Phases 3-4.
**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`, R and C read in that program
(Capy: your own jam's `cost.tokens` and `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
`--dispatched-at <R> --dispatched-credits <C> --readers <n>` with the reading after the last settles.
**Waits.** No call waits past 240 s: a program returns whatever still runs at 240 s; the next call resumes (Capy: `wait` 240 s on it). A small
waiting child saves nothing. Past 100 minutes, stop.
**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-plan --base <base>` with the pass's
flags (its `DISPATCH:` lines; 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) one subagent per reader on its rendered file, then
`sent` (`BRIEF_MISMATCH` voids it);
(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,
given the specialists' collected lines) repeats (3)-(6); (7) the collect: return only each reader's
final output and status, via `collect`. The next call applies the section's merge, judgment, fixes and persistence;
it never walks the dispatch steps call by call.
## 1. Load: Step 0
The install check and 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): `STATUS:`, `DIFF:`, `PR:`, `CARRY:`, `SCOPE:`, `MERGE:`, `DISTRIBUTION:`,
`POLICY:`, `RUNTIME_LINES:`, `PREGATE:`, `LANE:`. `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`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the project's 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
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>`. The same program reads
`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`.
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 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
reader ran.
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
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` reruns Step 5 (`--tests-only`) and Phases 3-4; `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)`).
## 5. Voices, bind and version: Steps 11, 11.5, 12
Judge the voices; missing outside coverage is `unavailable`, never clean; queued fixes rerun Phases
3-4. Then `gstack-review-dispatch verify` (`VERIFY: clean`) and `gstack-review-read`: the final Step 9 row and Step 11 native row (or `CURRENT` freshness
after a planned skip) agree on `wtree`, `start_wtree`, `end_wtree`; save that reviewed tree. Then
`gstack-ship-release version --base <base> [--bump micro|patch] --why '<line>'`; MINOR or MAJOR is a
question; `VERSION: skipped (policy: ...)`: write no version.
## 6. Notes and documentation: Steps 13, 14, 14.5
Step 13: one user-facing entry from the commits file, where `NEXT:` says;
`gstack-prose-lint <it> --base origin/<base> --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and
completions, edited from the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
one program (above), then `gstack-ship-receipt docs-caps --base <base>`; print `Documentation:`.
One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit features in 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:`
(pushed or ALREADY_PUSHED). Its pre-push offer STOP runs Step 17's guard block (`HOOK_STATE:`,
`HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`); unattended takes the recommended install. Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- 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; with `CAPY_PR: on` (Step 19-C) What changed, Why, Blast radius, Approvals (`gstack-approvals render`); Summary, Test Coverage, Pre-Landing Review (`REVIEW_LOOP:`, `Gate edits:`, deferred, notes), Exploratory QA, Plan Completion, Verification Results, Issues, TODOS, Documentation, Test plan. `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.
## Where each STOP sends you (paths under `<root>/ship/`)
{{SHIP_DRIVER_STOPS}}
+35 -57
View File
@@ -324,17 +324,15 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
3. It runs `git fetch origin <base>` first; if the fetch fails, stop: report the error and
restore access. `DIFF:` is the stat and commit count.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `~/.claude/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
4. **Carry check.** An open PR prints `PR: #<n>` and its `gstack-ship-receipt carry --pr <n> --base <base> --dry-run` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
@@ -423,10 +421,8 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <(~/.claude/skills/gstack/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Over 200 lines, `DIFF:` recommends `/plan-eng-review` or `/autoplan`. `SCOPE: frontend=true` (from
`gstack-diff-scope <base>`) with no design review: mention "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Continue to Step 2 without asking; Step 9 applies the review gates.
@@ -437,49 +433,35 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Include matching untracked files from Step 1's status. Read each match:
1. `DISTRIBUTION:` lists new candidate paths (`git diff origin/<base> --diff-filter=A --name-only`,
untracked included) and any release workflow. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
ls .github/workflows/ 2>/dev/null | grep -iE 'release|publish|dist'
grep -qE 'release|publish|deploy' .gitlab-ci.yml 2>/dev/null && echo "GITLAB_CI_RELEASE"
```
3. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
2. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
artifact after merge without a release pipeline."
- A) Add the platform's release workflow now
- B) Defer with a P1 distribution TODO in Step 14
- C) Not needed: internal/web-only, covered by existing deployment
4. **If A:** Add packaging/publish configuration using repository CI conventions.
3. **If A:** Add packaging/publish configuration using repository CI conventions.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise continue without a pipeline.
4. Otherwise continue without a pipeline.
---
## Step 3: Merge the base branch (BEFORE tests)
Merge the base ref fetched in Step 1 so tests and reviews cover the integrated code:
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge).
```bash
git merge origin/<base> --no-edit
```
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
**Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
to `gstack-ship-budget check --runtime-lines`.
If integration changes the artifact or distribution configuration inspected in Step 2,
repeat Step 2 (decisions included) on the merged content, then Step 4.
@@ -533,6 +515,10 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**One call:** `~/.claude/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
`ship: queue_mode stamp-at-merge` replaces items 1–4.
1. **Classify state** — pure reader, never writes:
@@ -602,11 +588,9 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
push, verify the committed digest matches generation for the selected VERSION.
5. **Record the release decision after a version was written**, including
an approved ALREADY_BUMPED rebump. Skip unchanged versions and manifest-only repairs.
```bash
~/.claude/skills/gstack/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
an approved ALREADY_BUMPED rebump; skip unchanged versions and manifest-only repairs.
The bin logs `Ship NEW_VERSION (BUMP_LEVEL)` through `gstack-decision-log` with `--why`,
a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `~/.claude/skills/gstack/ship/sections/changelog.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -682,6 +666,10 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
makes Step 15's item-3 commit and runs stages 1–4 and the pregate; each `STOP:` is a recovery
below (`--docs-rechecked`, `--no-lanes-approved` record stage 2.2 and Step 5's approval).
### 1. Finish writers and prepare outputs
Inspect writer handles, including the docs child. Confirm terminal completion or
@@ -884,25 +872,15 @@ failure STOPs here. If one exists and is not a draft:
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
**Idempotency check:** after the guard block run
```bash
LOCAL=$(git rev-parse HEAD) || exit 1
REMOTE_REF=$(git ls-remote --heads origin refs/heads/<branch-name>) || {
echo "STATUS: BLOCKED — cannot verify remote branch; restore access before pushing"
exit 1
}
REMOTE=$(printf '%s\n' "$REMOTE_REF" | awk '{print $(1)}')
REMOTE=${REMOTE:-none}
echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
~/.claude/skills/gstack/bin/gstack-ship-release push --branch <branch-name>
```
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
+35 -57
View File
@@ -130,17 +130,15 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
3. It runs `git fetch origin <base>` first; if the fetch fails, stop: report the error and
restore access. `DIFF:` is the stat and commit count.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `~/.claude/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
4. **Carry check.** An open PR prints `PR: #<n>` and its `gstack-ship-receipt carry --pr <n> --base <base> --dry-run` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
@@ -153,10 +151,8 @@ repository-landing asks, including on Apple repos.
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <(~/.claude/skills/gstack/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Over 200 lines, `DIFF:` recommends `/plan-eng-review` or `/autoplan`. `SCOPE: frontend=true` (from
`gstack-diff-scope <base>`) with no design review: mention "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Continue to Step 2 without asking; Step 9 applies the review gates.
@@ -167,49 +163,35 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Include matching untracked files from Step 1's status. Read each match:
1. `DISTRIBUTION:` lists new candidate paths (`git diff origin/<base> --diff-filter=A --name-only`,
untracked included) and any release workflow. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
ls .github/workflows/ 2>/dev/null | grep -iE 'release|publish|dist'
grep -qE 'release|publish|deploy' .gitlab-ci.yml 2>/dev/null && echo "GITLAB_CI_RELEASE"
```
3. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
2. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
artifact after merge without a release pipeline."
- A) Add the platform's release workflow now
- B) Defer with a P1 distribution TODO in Step 14
- C) Not needed: internal/web-only, covered by existing deployment
4. **If A:** Add packaging/publish configuration using repository CI conventions.
3. **If A:** Add packaging/publish configuration using repository CI conventions.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise continue without a pipeline.
4. Otherwise continue without a pipeline.
---
## Step 3: Merge the base branch (BEFORE tests)
Merge the base ref fetched in Step 1 so tests and reviews cover the integrated code:
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge).
```bash
git merge origin/<base> --no-edit
```
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
**Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
to `gstack-ship-budget check --runtime-lines`.
If integration changes the artifact or distribution configuration inspected in Step 2,
repeat Step 2 (decisions included) on the merged content, then Step 4.
@@ -256,6 +238,10 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**One call:** `~/.claude/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
`ship: queue_mode stamp-at-merge` replaces items 1–4.
1. **Classify state** — pure reader, never writes:
@@ -325,11 +311,9 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
push, verify the committed digest matches generation for the selected VERSION.
5. **Record the release decision after a version was written**, including
an approved ALREADY_BUMPED rebump. Skip unchanged versions and manifest-only repairs.
```bash
~/.claude/skills/gstack/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
an approved ALREADY_BUMPED rebump; skip unchanged versions and manifest-only repairs.
The bin logs `Ship NEW_VERSION (BUMP_LEVEL)` through `gstack-decision-log` with `--why`,
a one-line `WHY` (scope or breaking-change signal); best-effort.
{{SECTION:changelog}}
@@ -403,6 +387,10 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
makes Step 15's item-3 commit and runs stages 1–4 and the pregate; each `STOP:` is a recovery
below (`--docs-rechecked`, `--no-lanes-approved` record stage 2.2 and Step 5's approval).
### 1. Finish writers and prepare outputs
Inspect writer handles, including the docs child. Confirm terminal completion or
@@ -605,25 +593,15 @@ failure STOPs here. If one exists and is not a draft:
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
**Idempotency check:** after the guard block run
```bash
LOCAL=$(git rev-parse HEAD) || exit 1
REMOTE_REF=$(git ls-remote --heads origin refs/heads/<branch-name>) || {
echo "STATUS: BLOCKED — cannot verify remote branch; restore access before pushing"
exit 1
}
REMOTE=$(printf '%s\n' "$REMOTE_REF" | awk '{print $(1)}')
REMOTE=${REMOTE:-none}
echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
~/.claude/skills/gstack/bin/gstack-ship-release push --branch <branch-name>
```
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
+38 -18
View File
@@ -6,19 +6,23 @@ The cap is an **admission cap**: `gstack-ship-budget check` admits the next step
dispatch only when used + open reservations + unknown + its projection + the tail
and stop reserve fit. Work already admitted can still overrun its projection.
Field-by-field reference: docs/ship-budget.md. Exit 0 continue or delegate, 3 stop,
2 usage; any non-zero exit means the next step does not start.
2 usage; any non-zero exit means the next step does not start. A `ship/DRIVER.md` run starts
`--mode observe` instead: no check, reservation or handoff; each program ends with `note`, and only a
note past the cap stops.
**Meter.** Read one per command:
- Capy: in one execute program return only `(await tools.thread.get({ thread_id: "this" })).cost.tokens`
and pass it as `--reading <n>`. For the per-call charge, read it again in your next
call and pass the earlier value as `--prior-reading <n>`.
- Capy: read `(await tools.thread.get({ thread_id: "<your own jam id>" })).cost` (your own jam, its
subagents included; a root thread's is `"this"`) in the same program as the
`gstack-ship-budget` command, never its own call: `--reading <cost.tokens> --credits <cost.credits>`.
`--prior-reading <n>` (per-call charge; three of them price steps at the run's own median) is a reading
your previous call already took; never spend a call only to read the meter. One older than the run's last reading is ignored.
- Claude Code: `start --session <id>`, or echo the nonce the refusal prints once and
rerun `start --nonce <value>`; the helper reads the journals itself (never `--reading`).
- Elsewhere: `--meter estimate` (printed `meter=estimate`), only when the owner accepts it.
**Every boundary.** `gstack-ship-budget check --run <id> --step <done> --next <step> --reading <n>`.
Before each dispatch batch, reserve the batch as one sum, then pass the printed id once
(`READING` is the current counter reading; `RESERVATION_ID` is the id the check prints):
Before each dispatch batch, reserve the batch as one sum, one role per `DISPATCH:` line, then pass the printed id once
(`READING` is the current counter reading; `RESERVATION_ID` is the check's `RESERVATION_ID:` line, copied whole):
```bash
~/.claude/skills/gstack/bin/gstack-ship-budget check --run <id> --step 9 --next dispatch:specialist,red-team --reading "$READING"
@@ -26,10 +30,22 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed
```
On `ADMIT: k of n (remaining=<roles>)`, dispatch only those k and re-check the remaining roles
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. Admitting reviewers one at
a time, name each one that came back in the next check's `--returned <id>`. A dispatch with no `review-log --start` settles with
`gstack-ship-budget settle --run <id> --reservation <id>` when it returns
(`--unknown` when the meter cannot see it), or `release` if it never ran.
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations.
**One program per review pass.** On Capy one execute program runs a whole pass, in order (elsewhere
the same order): `gstack-review-plan` (its `DISPATCH:` lines), `check --step <n> --next dispatch:<roles>`,
`gstack-review-log --start <skill> --reservation <id>` once per
row the step logs, the reading `BEFORE`, the readers exactly as the step dispatches them (their
prompts unchanged), the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle`
has not run), the reading `AFTER`, `gstack-ship-budget settle`;
a later batch of the same pass (the Red Team or an ADMIT remainder) takes no start and repeats the
rest (`--finish` settles it); last the collect, returning only the readers' final outputs. Step 11's
voices and Step 14.5's audit (`dispatch:docs`, no start) are one program each.
Settle each batch in the program that dispatched it: read the counter right before the readers
start and right after the last one settles, then `gstack-ship-budget settle --run <id> --reservation <id>
--dispatched-at <before> --reading <after> --readers <n>` (`--unknown` when the meter cannot see it);
`release` one that never ran. Only that pair is measured: `check --returned`, a settle from a later
program (no `--dispatched-at`), `--finish` or a step change settles without measuring.
**verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and
end status=incomplete. Raising the cap is the owner's call: `/ship --resume <record>
@@ -38,22 +54,26 @@ end status=incomplete. Raising the cap is the owner's call: `/ship --resume <rec
`resume --run <id>` restores the ledger and record from the Drive checkpoint; a
corrupt ledger takes `--rebuild-from-record`.
**verdict=delegate.** The next segment (Steps 7–11.5, 12–22, or 1–22 at start) runs in a
fresh child; you wait and advance only after reconciling it:
1. `gstack-ship-budget handoff --run <id> --from 7 --to 11.5 --base origin/<base> --reading <n>`
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). It prints `HANDOFF:` and `RETURN_FILE:`.
**verdict=delegate.** The next segment (Steps 9–11.5, 12–22, or 1–22 at start) runs in a
fresh child; you wait and advance only after reconciling it. Steps 7–8 stay with the parent: the
Steps 9–11.5 child reads review-army.md and adversarial.md and takes their results from the record.
1. `gstack-ship-budget handoff --run <id> --from 9 --to 11.5 --base origin/<base> --reading <n>`
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). It prints `HANDOFF:` and `RETURN_FILE:`;
the brief lists the child's `READ:` line ranges and names `--to` as its last step.
2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source
GSTACK_LAUNCH_FILE, run /ship --continue <HANDOFF file>"; on Claude Code
`gstack-ship-budget launch --in <HANDOFF file>` (background, explicit permissions).
3. The child runs `verify-handoff --in <file>` first (`--reading <n>` on Capy); a
3. The child runs `verify-handoff --in <file>` first, with no `--reading` on Capy (a subagent cannot read
its own jam id; `METER: parent`: the parent's handoff and reconcile readings measure it); a
`HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and
never stops on the context rule: after a completed step, `verdict=delegate` hands its
remaining steps to a grandchild through these four steps (depth 3 at most); a
`CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with
`gstack-ship-budget return --run <child> --to <step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>`. Read the
`gstack-ship-budget return --run <child> --to <the brief's last step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>` charges the return's own-counter `used=`. Read the
record, never the child's transcript. Exit 3 (refused, incomplete or interrupted):
regenerate the handoff or stop.
regenerate the handoff or stop; every check refuses until a regenerated child reconciles
complete or the owner resumes.
**--resume <record>** and **--continue <handoff>** (you are the child) attach to the existing
record and ledger instead of a fresh Step 1: keep the attempt counts and start tokens; under
+38 -18
View File
@@ -4,19 +4,23 @@ The cap is an **admission cap**: `gstack-ship-budget check` admits the next step
dispatch only when used + open reservations + unknown + its projection + the tail
and stop reserve fit. Work already admitted can still overrun its projection.
Field-by-field reference: docs/ship-budget.md. Exit 0 continue or delegate, 3 stop,
2 usage; any non-zero exit means the next step does not start.
2 usage; any non-zero exit means the next step does not start. A `ship/DRIVER.md` run starts
`--mode observe` instead: no check, reservation or handoff; each program ends with `note`, and only a
note past the cap stops.
**Meter.** Read one per command:
- Capy: in one execute program return only `(await tools.thread.get({ thread_id: "this" })).cost.tokens`
and pass it as `--reading <n>`. For the per-call charge, read it again in your next
call and pass the earlier value as `--prior-reading <n>`.
- Capy: read `(await tools.thread.get({ thread_id: "<your own jam id>" })).cost` (your own jam, its
subagents included; a root thread's is `"this"`) in the same program as the
`gstack-ship-budget` command, never its own call: `--reading <cost.tokens> --credits <cost.credits>`.
`--prior-reading <n>` (per-call charge; three of them price steps at the run's own median) is a reading
your previous call already took; never spend a call only to read the meter. One older than the run's last reading is ignored.
- Claude Code: `start --session <id>`, or echo the nonce the refusal prints once and
rerun `start --nonce <value>`; the helper reads the journals itself (never `--reading`).
- Elsewhere: `--meter estimate` (printed `meter=estimate`), only when the owner accepts it.
**Every boundary.** `gstack-ship-budget check --run <id> --step <done> --next <step> --reading <n>`.
Before each dispatch batch, reserve the batch as one sum, then pass the printed id once
(`READING` is the current counter reading; `RESERVATION_ID` is the id the check prints):
Before each dispatch batch, reserve the batch as one sum, one role per `DISPATCH:` line, then pass the printed id once
(`READING` is the current counter reading; `RESERVATION_ID` is the check's `RESERVATION_ID:` line, copied whole):
```bash
~/.claude/skills/gstack/bin/gstack-ship-budget check --run <id> --step 9 --next dispatch:specialist,red-team --reading "$READING"
@@ -24,10 +28,22 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed
```
On `ADMIT: k of n (remaining=<roles>)`, dispatch only those k and re-check the remaining roles
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. Admitting reviewers one at
a time, name each one that came back in the next check's `--returned <id>`. A dispatch with no `review-log --start` settles with
`gstack-ship-budget settle --run <id> --reservation <id>` when it returns
(`--unknown` when the meter cannot see it), or `release` if it never ran.
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations.
**One program per review pass.** On Capy one execute program runs a whole pass, in order (elsewhere
the same order): `gstack-review-plan` (its `DISPATCH:` lines), `check --step <n> --next dispatch:<roles>`,
`gstack-review-log --start <skill> --reservation <id>` once per
row the step logs, the reading `BEFORE`, the readers exactly as the step dispatches them (their
prompts unchanged), the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle`
has not run), the reading `AFTER`, `gstack-ship-budget settle`;
a later batch of the same pass (the Red Team or an ADMIT remainder) takes no start and repeats the
rest (`--finish` settles it); last the collect, returning only the readers' final outputs. Step 11's
voices and Step 14.5's audit (`dispatch:docs`, no start) are one program each.
Settle each batch in the program that dispatched it: read the counter right before the readers
start and right after the last one settles, then `gstack-ship-budget settle --run <id> --reservation <id>
--dispatched-at <before> --reading <after> --readers <n>` (`--unknown` when the meter cannot see it);
`release` one that never ran. Only that pair is measured: `check --returned`, a settle from a later
program (no `--dispatched-at`), `--finish` or a step change settles without measuring.
**verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and
end status=incomplete. Raising the cap is the owner's call: `/ship --resume <record>
@@ -36,22 +52,26 @@ end status=incomplete. Raising the cap is the owner's call: `/ship --resume <rec
`resume --run <id>` restores the ledger and record from the Drive checkpoint; a
corrupt ledger takes `--rebuild-from-record`.
**verdict=delegate.** The next segment (Steps 7–11.5, 12–22, or 1–22 at start) runs in a
fresh child; you wait and advance only after reconciling it:
1. `gstack-ship-budget handoff --run <id> --from 7 --to 11.5 --base origin/<base> --reading <n>`
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). It prints `HANDOFF:` and `RETURN_FILE:`.
**verdict=delegate.** The next segment (Steps 9–11.5, 12–22, or 1–22 at start) runs in a
fresh child; you wait and advance only after reconciling it. Steps 7–8 stay with the parent: the
Steps 9–11.5 child reads review-army.md and adversarial.md and takes their results from the record.
1. `gstack-ship-budget handoff --run <id> --from 9 --to 11.5 --base origin/<base> --reading <n>`
(add `--reviewed-tree <t>`, `--trail <file> --trail-run <id>`). It prints `HANDOFF:` and `RETURN_FILE:`;
the brief lists the child's `READ:` line ranges and names `--to` as its last step.
2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source
GSTACK_LAUNCH_FILE, run /ship --continue <HANDOFF file>"; on Claude Code
`gstack-ship-budget launch --in <HANDOFF file>` (background, explicit permissions).
3. The child runs `verify-handoff --in <file>` first (`--reading <n>` on Capy); a
3. The child runs `verify-handoff --in <file>` first, with no `--reading` on Capy (a subagent cannot read
its own jam id; `METER: parent`: the parent's handoff and reconcile readings measure it); a
`HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and
never stops on the context rule: after a completed step, `verdict=delegate` hands its
remaining steps to a grandchild through these four steps (depth 3 at most); a
`CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with
`gstack-ship-budget return --run <child> --to <step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>`. Read the
`gstack-ship-budget return --run <child> --to <the brief's last step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>` charges the return's own-counter `used=`. Read the
record, never the child's transcript. Exit 3 (refused, incomplete or interrupted):
regenerate the handoff or stop.
regenerate the handoff or stop; every check refuses until a regenerated child reconciles
complete or the owner resumes.
**--resume <record>** and **--continue <handoff>** (you are the child) attach to the existing
record and ledger instead of a fresh Step 1: keep the attempt counts and start tokens; under
+14 -29
View File
@@ -210,19 +210,16 @@ Only commit if there are changes. Stage the bootstrap's own files by name (confi
## Step 5: Run tests (on merged code)
**Pre-gate first** — free, before any lane, eval or remote gate:
```bash
~/.claude/skills/gstack/bin/gstack-pregate --allow-repo-commands
```
Stage 1 (regen, secrets, strays, literals) is the two-minute preflight; stage 2
runs the selected lanes under pipefail. `fail` or `incomplete` blocks: fix,
rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs
**Run it:** `~/.claude/skills/gstack/bin/gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>...`
(or the Step 1 call with lanes). It runs the free pre-gate first, before any lane, eval or
remote gate (`gstack-pregate --allow-repo-commands`: stage 1 regen, secrets, strays, literals;
stage 2 the selected lanes under pipefail), then every lane in parallel inside the evidence
ledger, and prints `PREGATE:` and one `LANE: <label> <state> exit= tests_ran= log=` line each.
`fail` or `incomplete` blocks: fix, rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs
`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun
after the final stamp; Step 18 refuses a stale one.
after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a stale one.
Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. The commands below are examples only for repositories that actually provide them. Use the same lane labels and exact commands again in Step 16.
Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. Pass each as `--lane <label>=<command>`; use the same lane labels and exact commands again in Step 16.
**If no applicable test suite exists:** Name the untested scope. AskUserQuestion:
A) Add tests (recommended), B) Ship with this named testing
@@ -237,26 +234,14 @@ C stops this attempt.
`db:test:prepare` internally, which loads the schema into the correct lane database.
Running bare test migrations without INSTANCE hits an orphan DB and corrupts structure.sql.
Run independent test suites in parallel, each wrapped in the evidence ledger. The
wrapper is transparent (streams output live, exit code passes through) and
records `{command, exit, working-tree fingerprint, log path}` to
`$GSTACK_STATE_ROOT/projects/<slug>/<branch>-evidence.jsonl` — Step 16 cites this
record instead of re-running when the content hasn't changed:
```bash
set -o pipefail; pids=()
~/.claude/skills/gstack/bin/gstack-evidence run --label tests -- 'bin/test-lane 2>&1' & pids+=($!)
~/.claude/skills/gstack/bin/gstack-evidence run --label vitest -- 'npm run test 2>&1' & pids+=($!)
fail=0; for p in "${pids[@]}"; do wait "$p" || fail=1; done; echo "lanes failed=$fail"
```
After all suites complete, check the `gstack-evidence: recorded label=... exit=...
log=...` summary lines — each carries the lane's exit code and a per-run log
file (no shared /tmp collisions between concurrent ships). Read the log files
for failure detail.
Each lane runs as `gstack-evidence run --label <label> -- '<command>'`: transparent (exit code passes
through) and recorded as `{command, exit, working-tree fingerprint, log path}` in
`$GSTACK_STATE_ROOT/projects/<slug>/<branch>-evidence.jsonl`, which Step 16 cites instead of
re-running when the content hasn't changed. Each lane has its own log (no shared /tmp
collisions between concurrent ships); read it for failure detail.
**A lane passes on its runner summary line, not on exit 0 alone.** Read each
lane's `tests_ran` from its receipt (or from the runner's own summary in the log)
lane's `tests_ran` from its `LANE:` line (or from the runner's own summary in the log)
and keep the three states apart; never merge them:
1. `tests_ran: N` (N > 0) with exit 0 — the lane passed; record N for the PR body.
+14 -29
View File
@@ -6,19 +6,16 @@
## Step 5: Run tests (on merged code)
**Pre-gate first** — free, before any lane, eval or remote gate:
```bash
~/.claude/skills/gstack/bin/gstack-pregate --allow-repo-commands
```
Stage 1 (regen, secrets, strays, literals) is the two-minute preflight; stage 2
runs the selected lanes under pipefail. `fail` or `incomplete` blocks: fix,
rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs
**Run it:** `~/.claude/skills/gstack/bin/gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>...`
(or the Step 1 call with lanes). It runs the free pre-gate first, before any lane, eval or
remote gate (`gstack-pregate --allow-repo-commands`: stage 1 regen, secrets, strays, literals;
stage 2 the selected lanes under pipefail), then every lane in parallel inside the evidence
ledger, and prints `PREGATE:` and one `LANE: <label> <state> exit= tests_ran= log=` line each.
`fail` or `incomplete` blocks: fix, rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs
`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun
after the final stamp; Step 18 refuses a stale one.
after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a stale one.
Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. The commands below are examples only for repositories that actually provide them. Use the same lane labels and exact commands again in Step 16.
Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. Pass each as `--lane <label>=<command>`; use the same lane labels and exact commands again in Step 16.
**If no applicable test suite exists:** Name the untested scope. AskUserQuestion:
A) Add tests (recommended), B) Ship with this named testing
@@ -33,26 +30,14 @@ C stops this attempt.
`db:test:prepare` internally, which loads the schema into the correct lane database.
Running bare test migrations without INSTANCE hits an orphan DB and corrupts structure.sql.
Run independent test suites in parallel, each wrapped in the evidence ledger. The
wrapper is transparent (streams output live, exit code passes through) and
records `{command, exit, working-tree fingerprint, log path}` to
`$GSTACK_STATE_ROOT/projects/<slug>/<branch>-evidence.jsonl` — Step 16 cites this
record instead of re-running when the content hasn't changed:
```bash
set -o pipefail; pids=()
~/.claude/skills/gstack/bin/gstack-evidence run --label tests -- 'bin/test-lane 2>&1' & pids+=($!)
~/.claude/skills/gstack/bin/gstack-evidence run --label vitest -- 'npm run test 2>&1' & pids+=($!)
fail=0; for p in "${pids[@]}"; do wait "$p" || fail=1; done; echo "lanes failed=$fail"
```
After all suites complete, check the `gstack-evidence: recorded label=... exit=...
log=...` summary lines — each carries the lane's exit code and a per-run log
file (no shared /tmp collisions between concurrent ships). Read the log files
for failure detail.
Each lane runs as `gstack-evidence run --label <label> -- '<command>'`: transparent (exit code passes
through) and recorded as `{command, exit, working-tree fingerprint, log path}` in
`$GSTACK_STATE_ROOT/projects/<slug>/<branch>-evidence.jsonl`, which Step 16 cites instead of
re-running when the content hasn't changed. Each lane has its own log (no shared /tmp
collisions between concurrent ships); read it for failure detail.
**A lane passes on its runner summary line, not on exit 0 alone.** Read each
lane's `tests_ran` from its receipt (or from the runner's own summary in the log)
lane's `tests_ran` from its `LANE:` line (or from the runner's own summary in the log)
and keep the three states apart; never merge them:
1. `tests_ran: N` (N > 0) with exit 0 — the lane passed; record N for the PR body.
+3 -2
View File
@@ -55,8 +55,9 @@ describe('content-binding template drift', () => {
test('ship Step 5 lanes run wrapped with per-lane labels', () => {
const tests = rendered('ship/sections/tests.md');
expect(tests).toContain('gstack-evidence run --label tests');
expect(tests).toContain('gstack-evidence run --label vitest');
expect(tests).toContain('gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>');
expect(tests).toContain('Each lane runs as `gstack-evidence run --label <label> -- \'<command>\'`');
expect(tests).toContain('use the same lane labels and exact commands again in Step 16');
});
test('land-and-deploy grades staleness content-first (wtree rule) and checks evidence', () => {
+1 -1
View File
@@ -9,7 +9,7 @@
"browser-skills/hackernews-frontpage": 371,
"canary": 7738,
"captain": 6551,
"capy/skill/gstack": 4588,
"capy/skill/gstack": 5136,
"careful": 1239,
"codex": 9890,
"context-restore": 6287,
+35 -57
View File
@@ -324,17 +324,15 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
3. It runs `git fetch origin <base>` first; if the fetch fails, stop: report the error and
restore access. `DIFF:` is the stat and commit count.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
4. **Carry check.** An open PR prints `PR: #<n>` and its `gstack-ship-receipt carry --pr <n> --base <base> --dry-run` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
@@ -423,10 +421,8 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <(~/.gemini/antigravity-cli/skills/gstack/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Over 200 lines, `DIFF:` recommends `/plan-eng-review` or `/autoplan`. `SCOPE: frontend=true` (from
`gstack-diff-scope <base>`) with no design review: mention "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Continue to Step 2 without asking; Step 9 applies the review gates.
@@ -437,49 +433,35 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Include matching untracked files from Step 1's status. Read each match:
1. `DISTRIBUTION:` lists new candidate paths (`git diff origin/<base> --diff-filter=A --name-only`,
untracked included) and any release workflow. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
ls .github/workflows/ 2>/dev/null | grep -iE 'release|publish|dist'
grep -qE 'release|publish|deploy' .gitlab-ci.yml 2>/dev/null && echo "GITLAB_CI_RELEASE"
```
3. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
2. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
artifact after merge without a release pipeline."
- A) Add the platform's release workflow now
- B) Defer with a P1 distribution TODO in Step 14
- C) Not needed: internal/web-only, covered by existing deployment
4. **If A:** Add packaging/publish configuration using repository CI conventions.
3. **If A:** Add packaging/publish configuration using repository CI conventions.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise continue without a pipeline.
4. Otherwise continue without a pipeline.
---
## Step 3: Merge the base branch (BEFORE tests)
Merge the base ref fetched in Step 1 so tests and reviews cover the integrated code:
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge).
```bash
git merge origin/<base> --no-edit
```
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
**Ship policy:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
to `gstack-ship-budget check --runtime-lines`.
If integration changes the artifact or distribution configuration inspected in Step 2,
repeat Step 2 (decisions included) on the merged content, then Step 4.
@@ -533,6 +515,10 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**One call:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
`ship: queue_mode stamp-at-merge` replaces items 1–4.
1. **Classify state** — pure reader, never writes:
@@ -602,11 +588,9 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
push, verify the committed digest matches generation for the selected VERSION.
5. **Record the release decision after a version was written**, including
an approved ALREADY_BUMPED rebump. Skip unchanged versions and manifest-only repairs.
```bash
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
an approved ALREADY_BUMPED rebump; skip unchanged versions and manifest-only repairs.
The bin logs `Ship NEW_VERSION (BUMP_LEVEL)` through `gstack-decision-log` with `--why`,
a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `sections/changelog.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -682,6 +666,10 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
makes Step 15's item-3 commit and runs stages 1–4 and the pregate; each `STOP:` is a recovery
below (`--docs-rechecked`, `--no-lanes-approved` record stage 2.2 and Step 5's approval).
### 1. Finish writers and prepare outputs
Inspect writer handles, including the docs child. Confirm terminal completion or
@@ -884,25 +872,15 @@ failure STOPs here. If one exists and is not a draft:
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
**Idempotency check:** after the guard block run
```bash
LOCAL=$(git rev-parse HEAD) || exit 1
REMOTE_REF=$(git ls-remote --heads origin refs/heads/<branch-name>) || {
echo "STATUS: BLOCKED — cannot verify remote branch; restore access before pushing"
exit 1
}
REMOTE=$(printf '%s\n' "$REMOTE_REF" | awk '{print $(1)}')
REMOTE=${REMOTE:-none}
echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release push --branch <branch-name>
```
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
+35 -57
View File
@@ -324,17 +324,15 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
3. It runs `git fetch origin <base>` first; if the fetch fails, stop: report the error and
restore access. `DIFF:` is the stat and commit count.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `~/.claude/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
4. **Carry check.** An open PR prints `PR: #<n>` and its `gstack-ship-receipt carry --pr <n> --base <base> --dry-run` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
@@ -423,10 +421,8 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <(~/.claude/skills/gstack/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Over 200 lines, `DIFF:` recommends `/plan-eng-review` or `/autoplan`. `SCOPE: frontend=true` (from
`gstack-diff-scope <base>`) with no design review: mention "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Continue to Step 2 without asking; Step 9 applies the review gates.
@@ -437,49 +433,35 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Include matching untracked files from Step 1's status. Read each match:
1. `DISTRIBUTION:` lists new candidate paths (`git diff origin/<base> --diff-filter=A --name-only`,
untracked included) and any release workflow. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
ls .github/workflows/ 2>/dev/null | grep -iE 'release|publish|dist'
grep -qE 'release|publish|deploy' .gitlab-ci.yml 2>/dev/null && echo "GITLAB_CI_RELEASE"
```
3. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
2. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
artifact after merge without a release pipeline."
- A) Add the platform's release workflow now
- B) Defer with a P1 distribution TODO in Step 14
- C) Not needed: internal/web-only, covered by existing deployment
4. **If A:** Add packaging/publish configuration using repository CI conventions.
3. **If A:** Add packaging/publish configuration using repository CI conventions.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise continue without a pipeline.
4. Otherwise continue without a pipeline.
---
## Step 3: Merge the base branch (BEFORE tests)
Merge the base ref fetched in Step 1 so tests and reviews cover the integrated code:
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge).
```bash
git merge origin/<base> --no-edit
```
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
**Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
to `gstack-ship-budget check --runtime-lines`.
If integration changes the artifact or distribution configuration inspected in Step 2,
repeat Step 2 (decisions included) on the merged content, then Step 4.
@@ -533,6 +515,10 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**One call:** `~/.claude/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
`ship: queue_mode stamp-at-merge` replaces items 1–4.
1. **Classify state** — pure reader, never writes:
@@ -602,11 +588,9 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
push, verify the committed digest matches generation for the selected VERSION.
5. **Record the release decision after a version was written**, including
an approved ALREADY_BUMPED rebump. Skip unchanged versions and manifest-only repairs.
```bash
~/.claude/skills/gstack/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
an approved ALREADY_BUMPED rebump; skip unchanged versions and manifest-only repairs.
The bin logs `Ship NEW_VERSION (BUMP_LEVEL)` through `gstack-decision-log` with `--why`,
a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `~/.claude/skills/gstack/ship/sections/changelog.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -682,6 +666,10 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
makes Step 15's item-3 commit and runs stages 1–4 and the pregate; each `STOP:` is a recovery
below (`--docs-rechecked`, `--no-lanes-approved` record stage 2.2 and Step 5's approval).
### 1. Finish writers and prepare outputs
Inspect writer handles, including the docs child. Confirm terminal completion or
@@ -884,25 +872,15 @@ failure STOPs here. If one exists and is not a draft:
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
**Idempotency check:** after the guard block run
```bash
LOCAL=$(git rev-parse HEAD) || exit 1
REMOTE_REF=$(git ls-remote --heads origin refs/heads/<branch-name>) || {
echo "STATUS: BLOCKED — cannot verify remote branch; restore access before pushing"
exit 1
}
REMOTE=$(printf '%s\n' "$REMOTE_REF" | awk '{print $(1)}')
REMOTE=${REMOTE:-none}
echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
~/.claude/skills/gstack/bin/gstack-ship-release push --branch <branch-name>
```
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
+36 -58
View File
@@ -322,17 +322,15 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
3. It runs `git fetch origin <base>` first; if the fetch fails, stop: report the error and
restore access. `DIFF:` is the stat and commit count.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
4. **Carry check.** An open PR prints `PR: #<n>` and its `gstack-ship-receipt carry --pr <n> --base <base> --dry-run` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
@@ -422,10 +420,8 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <($GSTACK_ROOT/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Over 200 lines, `DIFF:` recommends `/plan-eng-review` or `/autoplan`. `SCOPE: frontend=true` (from
`gstack-diff-scope <base>`) with no design review: mention "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Continue to Step 2 without asking; Step 9 applies the review gates.
@@ -436,49 +432,35 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Include matching untracked files from Step 1's status. Read each match:
1. `DISTRIBUTION:` lists new candidate paths (`git diff origin/<base> --diff-filter=A --name-only`,
untracked included) and any release workflow. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
ls .github/workflows/ 2>/dev/null | grep -iE 'release|publish|dist'
grep -qE 'release|publish|deploy' .gitlab-ci.yml 2>/dev/null && echo "GITLAB_CI_RELEASE"
```
3. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
2. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
artifact after merge without a release pipeline."
- A) Add the platform's release workflow now
- B) Defer with a P1 distribution TODO in Step 14
- C) Not needed: internal/web-only, covered by existing deployment
4. **If A:** Add packaging/publish configuration using repository CI conventions.
3. **If A:** Add packaging/publish configuration using repository CI conventions.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise continue without a pipeline.
4. Otherwise continue without a pipeline.
---
## Step 3: Merge the base branch (BEFORE tests)
Merge the base ref fetched in Step 1 so tests and reviews cover the integrated code:
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge).
```bash
git merge origin/<base> --no-edit
```
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
**Ship policy:** `$GSTACK_ROOT/bin/gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
to `gstack-ship-budget check --runtime-lines`.
If integration changes the artifact or distribution configuration inspected in Step 2,
repeat Step 2 (decisions included) on the merged content, then Step 4.
@@ -532,6 +514,10 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**One call:** `$GSTACK_ROOT/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
`ship: queue_mode stamp-at-merge` replaces items 1–4.
1. **Classify state** — pure reader, never writes:
@@ -604,12 +590,9 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
push, verify the committed digest matches generation for the selected VERSION.
5. **Record the release decision after a version was written**, including
an approved ALREADY_BUMPED rebump. Skip unchanged versions and manifest-only repairs.
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
$GSTACK_ROOT/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
an approved ALREADY_BUMPED rebump; skip unchanged versions and manifest-only repairs.
The bin logs `Ship NEW_VERSION (BUMP_LEVEL)` through `gstack-decision-log` with `--why`,
a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `sections/changelog.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -685,6 +668,10 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
makes Step 15's item-3 commit and runs stages 1–4 and the pregate; each `STOP:` is a recovery
below (`--docs-rechecked`, `--no-lanes-approved` record stage 2.2 and Step 5's approval).
### 1. Finish writers and prepare outputs
Inspect writer handles, including the docs child. Confirm terminal completion or
@@ -890,25 +877,16 @@ failure STOPs here. If one exists and is not a draft:
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
**Idempotency check:** after the guard block run
```bash
LOCAL=$(git rev-parse HEAD) || exit 1
REMOTE_REF=$(git ls-remote --heads origin refs/heads/<branch-name>) || {
echo "STATUS: BLOCKED — cannot verify remote branch; restore access before pushing"
exit 1
}
REMOTE=$(printf '%s\n' "$REMOTE_REF" | awk '{print $(1)}')
REMOTE=${REMOTE:-none}
echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
$GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
```
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
+36 -58
View File
@@ -324,17 +324,15 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
3. It runs `git fetch origin <base>` first; if the fetch fails, stop: report the error and
restore access. `DIFF:` is the stat and commit count.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
4. **Carry check.** An open PR prints `PR: #<n>` and its `gstack-ship-receipt carry --pr <n> --base <base> --dry-run` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
@@ -424,10 +422,8 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <($GSTACK_ROOT/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Over 200 lines, `DIFF:` recommends `/plan-eng-review` or `/autoplan`. `SCOPE: frontend=true` (from
`gstack-diff-scope <base>`) with no design review: mention "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
Continue to Step 2 without asking; Step 9 applies the review gates.
@@ -438,49 +434,35 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Include matching untracked files from Step 1's status. Read each match:
1. `DISTRIBUTION:` lists new candidate paths (`git diff origin/<base> --diff-filter=A --name-only`,
untracked included) and any release workflow. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
ls .github/workflows/ 2>/dev/null | grep -iE 'release|publish|dist'
grep -qE 'release|publish|deploy' .gitlab-ci.yml 2>/dev/null && echo "GITLAB_CI_RELEASE"
```
3. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
2. **New artifact without a pipeline:** AskUserQuestion: "Users cannot download this
artifact after merge without a release pipeline."
- A) Add the platform's release workflow now
- B) Defer with a P1 distribution TODO in Step 14
- C) Not needed: internal/web-only, covered by existing deployment
4. **If A:** Add packaging/publish configuration using repository CI conventions.
3. **If A:** Add packaging/publish configuration using repository CI conventions.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise continue without a pipeline.
4. Otherwise continue without a pipeline.
---
## Step 3: Merge the base branch (BEFORE tests)
Merge the base ref fetched in Step 1 so tests and reviews cover the integrated code:
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge).
```bash
git merge origin/<base> --no-edit
```
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
**Ship policy:** `$GSTACK_ROOT/bin/gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
to `gstack-ship-budget check --runtime-lines`.
If integration changes the artifact or distribution configuration inspected in Step 2,
repeat Step 2 (decisions included) on the merged content, then Step 4.
@@ -534,6 +516,10 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**One call:** `$GSTACK_ROOT/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
`ship: queue_mode stamp-at-merge` replaces items 1–4.
1. **Classify state** — pure reader, never writes:
@@ -606,12 +592,9 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
push, verify the committed digest matches generation for the selected VERSION.
5. **Record the release decision after a version was written**, including
an approved ALREADY_BUMPED rebump. Skip unchanged versions and manifest-only repairs.
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
$GSTACK_ROOT/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
an approved ALREADY_BUMPED rebump; skip unchanged versions and manifest-only repairs.
The bin logs `Ship NEW_VERSION (BUMP_LEVEL)` through `gstack-decision-log` with `--why`,
a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `sections/changelog.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -687,6 +670,10 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
makes Step 15's item-3 commit and runs stages 1–4 and the pregate; each `STOP:` is a recovery
below (`--docs-rechecked`, `--no-lanes-approved` record stage 2.2 and Step 5's approval).
### 1. Finish writers and prepare outputs
Inspect writer handles, including the docs child. Confirm terminal completion or
@@ -892,25 +879,16 @@ failure STOPs here. If one exists and is not a draft:
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
**Idempotency check:** after the guard block run
```bash
LOCAL=$(git rev-parse HEAD) || exit 1
REMOTE_REF=$(git ls-remote --heads origin refs/heads/<branch-name>) || {
echo "STATUS: BLOCKED — cannot verify remote branch; restore access before pushing"
exit 1
}
REMOTE=$(printf '%s\n' "$REMOTE_REF" | awk '{print $(1)}')
REMOTE=${REMOTE:-none}
echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
$GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
```
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
+1
View File
@@ -157,6 +157,7 @@
{ "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" },
{ "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" },
{ "file": "scripts/resolvers/autoplan-driver.ts", "movedFrom": "(new: renders autoplan/DRIVER.md from the runner's constants, field wave B2e)" },
{ "file": "scripts/resolvers/ship-driver.ts", "movedFrom": "(new: renders ship/DRIVER.md's STOP-to-heading list, GSTA-167 PR 5)" },
{ "file": "lib/script-invocation.ts", "movedFrom": "(new: the one Windows bash resolver, shebang-bin invocation and wtreeFingerprint, from spawn-bin.ts and gbrain-exec.ts, #3197)" },
{ "file": "lib/autoplan-budget.ts", "movedFrom": "(new: round cap, default host estimate, budget_exhausted stop and the budget verb, field list 2 W5)" },
{ "file": "lib/autoplan-confirm.ts", "movedFrom": "(new: confirm-round plan delta, revision-qualified correlation and settled findings, field list 2 W5)" },
+1
View File
@@ -47,6 +47,7 @@ describe('generator artifact and dry-run contract', () => {
{ relativePath: 'review/design-checklist.md', kind: 'asset', host: 'claude' },
{ relativePath: 'lib/dom-dump.js', kind: 'asset', host: 'claude' },
{ relativePath: 'autoplan/DRIVER.md', kind: 'asset', host: 'claude' },
{ relativePath: 'ship/DRIVER.md', kind: 'asset', host: 'claude' },
...ALL_HOST_NAMES.filter(host => includesSkill(getHostConfig(host), 'qa')).map(host => ({
relativePath: host === 'claude' ? 'qa/templates/functional-report-template.md'
: `${getHostConfig(host).hostSubdir}/skills/gstack-qa/templates/functional-report-template.md`,
+10 -8
View File
@@ -536,15 +536,17 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(r.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`);
expect(fs.readFileSync(files[0].replace(/\.json$/, '.env'), 'utf8')).toContain(`export GSTACK_INSTALL_VERDICT='${files[0]}'`);
// /ship Step 7 value card. protects: with the certified Bun first on PATH (no GSTACK_LAUNCH_PATH)
// the launch file carries no PATH line, sources under POSIX sh, and leaves PATH exactly as it was;
// --check rewrites and prints it too. fails_when: the PATH guard is dropped (an empty prepend
// leaves ":"-led PATH), the file uses a bashism, or --check stops printing GSTACK_LAUNCH_FILE.
// why_existing_misses: the PATH-bearing case above is sourced by bash only, and the --check run
// below asserted only the GSTACK_LAUNCH line. seam=none.
const sourced = spawnSync('sh', ['-c', '. "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"', '_', files[0].replace(/\.json$/, '.env')],
// the launch file prepends nothing, sources under POSIX sh, and only appends the checkout's bin
// directory once (GSTA-167: a /ship child that sourced only the launch file got
// `gstack-ship-budget: command not found`, exit 127); --check rewrites and prints it too.
// fails_when: the PATH guard is dropped (an empty prepend leaves ":"-led PATH, a second source
// appends the bin directory twice), the file uses a bashism, the bin directory is not on PATH,
// or --check stops printing GSTACK_LAUNCH_FILE. why_existing_misses: the PATH-bearing case above
// is sourced by bash only, and the --check run below asserted only the GSTACK_LAUNCH line. seam=none.
const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT" "$(command -v gstack-paths)"', '_', files[0].replace(/\.json$/, '.env')],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } });
expect(sourced.status, sourced.stderr).toBe(0);
expect(sourced.stdout.trimEnd().split('\n')).toEqual(['/usr/bin:/bin', 'unattended', '1', files[0]]);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`/usr/bin:/bin:${f.root}/bin`, 'unattended', '1', files[0], path.join(f.root, 'bin', 'gstack-paths')]);
const v = JSON.parse(fs.readFileSync(files[0], 'utf8'));
expect(v).toMatchObject({ schema_version: 2, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan',
project: '', bun_path: path.join(f.stub, 'bun'), bun_version: '1.4.2', bun_rule: 'PATH' });
@@ -987,7 +989,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_INSTALL_VERDICT"', '_', launchFile],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } });
expect(sourced.status, sourced.stderr).toBe(0);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin:${f.root}/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]);
const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT);
+84
View File
@@ -0,0 +1,84 @@
/**
* Fixture for gstack-ship-preflight and gstack-ship-release tests (GSTA-167 PR 4): a bare
* origin and a clone under the OS temp directory, outside this checkout, with an explicit
* seed identity on every repository, a private state root, and a stub gh on PATH whose
* output a test sets.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..', '..');
export const PREFLIGHT = path.join(ROOT, 'bin', 'gstack-ship-preflight');
export const RELEASE = path.join(ROOT, 'bin', 'gstack-ship-release');
export interface ShipFx {
root: string; work: string; origin: string; state: string;
git(...args: string[]): { status: number | null; stdout: string; stderr: string };
write(files: Record<string, string>): void;
commit(msg: string): void;
/** Push to origin/main from a second clone, moving the base under the feature branch. */
advanceBase(files: Record<string, string>): void;
gh(script: string): void;
run(bin: string, args: string[], extraEnv?: Record<string, string>): { status: number | null; stdout: string; stderr: string };
cleanup(): void;
}
/** origin's URL as a GitHub remote; the clone's insteadOf maps it to the bare repository, so gh calls see a GitHub repo and git still works offline. */
export const GITHUB_URL = 'https://github.com/fx/fx.git';
const IDENTITY = ['-c', 'user.email=fixture@example.com', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false'];
export function shipFixture(base: Record<string, string> = {}, feat: Record<string, string> = { 'src/b.ts': 'export const b = 2;\n' }): ShipFx {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ship-bin-')));
if (!path.relative(ROOT, root).startsWith('..')) throw new Error('fixture must live outside the checkout');
const origin = path.join(root, 'origin.git');
const work = path.join(root, 'work');
const state = path.join(root, 'state');
const stub = path.join(root, 'stub');
fs.mkdirSync(stub, { recursive: true });
const gitIn = (cwd: string, args: string[]) => {
const r = spawnSync('git', [...IDENTITY, ...args], { cwd, encoding: 'utf8', timeout: 30_000 });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
};
const writeIn = (dir: string, files: Record<string, string>) => {
for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); }
};
const must = (r: { status: number | null; stderr: string }, what: string) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr}`); };
must(gitIn(root, ['init', '-q', '--bare', '-b', 'main', origin]), 'init origin');
must(gitIn(root, ['clone', '-q', origin, work]), 'clone');
writeIn(work, { VERSION: '1.0.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [Unreleased]\n\n- pending\n', 'package.json': '{"name":"fx","version":"1.0.0"}\n',
'README.md': '# Fx\n', 'src/a.ts': 'export const a = 1;\n', ...base });
must(gitIn(work, ['add', '-A']), 'add'); must(gitIn(work, ['commit', '-qm', 'base']), 'seed commit');
must(gitIn(work, ['push', '-q', 'origin', 'main']), 'push base'); gitIn(work, ['remote', 'set-head', 'origin', 'main']);
must(gitIn(work, ['config', `url.${origin}.insteadOf`, GITHUB_URL]), 'insteadOf'); must(gitIn(work, ['config', 'remote.origin.url', GITHUB_URL]), 'github origin');
must(gitIn(work, ['checkout', '-qb', 'feat']), 'branch');
writeIn(work, feat);
must(gitIn(work, ['add', '-A']), 'add feat'); must(gitIn(work, ['commit', '-qm', 'feat']), 'feat commit');
const env = { ...process.env, PATH: `${stub}:${process.env.PATH}`, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com',
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const fx: ShipFx = {
root, work, origin, state,
git: (...args) => gitIn(work, args),
write: files => writeIn(work, files),
commit: msg => { must(gitIn(work, ['add', '-A']), 'add'); must(gitIn(work, ['commit', '-qm', msg]), msg); },
advanceBase: files => {
const other = fs.mkdtempSync(path.join(root, 'other-'));
must(gitIn(root, ['clone', '-q', origin, other]), 'clone other');
writeIn(other, files);
must(gitIn(other, ['add', '-A']), 'add'); must(gitIn(other, ['commit', '-qm', 'base moves']), 'base moves'); must(gitIn(other, ['push', '-q', 'origin', 'main']), 'push');
},
gh: script => { fs.writeFileSync(path.join(stub, 'gh'), `#!/bin/sh\n${script}\n`, { mode: 0o755 }); },
run: (bin, args, extraEnv = {}) => {
const r = spawnSync(bin, args, { cwd: work, encoding: 'utf8', timeout: 300_000, env: { ...env, ...extraEnv } });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
},
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
};
fx.gh('exit 0');
return fx;
}
export const lineOf = (out: string, key: string) => out.split('\n').find(l => l.startsWith(`${key}: `)) ?? '';
+2 -2
View File
@@ -107,7 +107,7 @@ function markdownTargets(): string[] {
return listFiles('.', rel =>
/^[^/]+\/SKILL\.md(\.tmpl)?$/.test(rel) ||
/^[^/]+\/sections\/[^/]+\.md(\.tmpl)?$/.test(rel) ||
/^autoplan\/DRIVER\.md(\.tmpl)?$/.test(rel) ||
/^(autoplan|ship)\/DRIVER\.md(\.tmpl)?$/.test(rel) ||
/^capy\/skill\/gstack\/[^/]+\.md$/.test(rel));
}
@@ -162,7 +162,7 @@ describe('launch-env echo ratchet', () => {
test('the scan covers the skill surfaces and the shell helpers', () => {
const md = markdownTargets();
for (const want of ['ship/SKILL.md', 'ship/sections/documentation.md', 'review/SKILL.md', 'qa/SKILL.md',
'document-release/SKILL.md', 'autoplan/DRIVER.md', 'capy/skill/gstack/SKILL.md']) expect(md).toContain(want);
'document-release/SKILL.md', 'autoplan/DRIVER.md', 'ship/DRIVER.md', 'capy/skill/gstack/SKILL.md']) expect(md).toContain(want);
const sh = shellTargets();
for (const want of ['bin/gstack-capy-install', 'bin/gstack-capy-session', 'bin/gstack-skill-start', 'setup', 'scripts/build.sh', 'scripts/sandbox-doctor.sh']) expect(sh).toContain(want);
});
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, 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 { BriefError, promptHash, renderBrief, sectionTexts } from '../lib/review-brief';
import { judge, parseFindings, type MergedLine } from '../lib/review-ledger';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-review-dispatch');
const dirs: string[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'review-dispatch-')); dirs.push(d); return d; };
const run = (args: string[], cwd = ROOT) => spawnSync(BIN, args, { cwd, encoding: 'utf8', timeout: 60_000 });
const SHA = 'a'.repeat(40);
const section = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const field = (out: string, k: string) => new RegExp(`${k}=(\\S+)`).exec(out)?.[1] ?? '';
describe('gstack-review-dispatch render: briefs from the sections, slots only', () => {
// Value: protects=every blocking reader's brief is cut from its section's own bytes with only declared slots filled, so a driven /ship sends what ship/SKILL.md sends; fails_when=a renderer paraphrases, drops a line, leaves a placeholder, or a section edit silently stops matching (GSTA-167 no-loss round 4: 37 of 112 dispatched briefs matched; a batched brief lost two lines);
// why_new=no renderer existed; the driver relied on the model to fill placeholders; seam=lib/review-brief.ts and bin/gstack-review-dispatch; no_claim=that the reader then follows its brief
test('each role renders, carries its section lines verbatim and leaves no placeholder', () => {
const t = sectionTexts(ROOT);
const slots = { diff_base: SHA, exclude: "-- . ':(exclude)*.lock'", stack: 'TypeScript', test_fw: 'bun:test', name: 'testing', checklist: '/x/testing.md', checklists: '/x/a.md,/x/b.md',
findings: '[CRITICAL] a.ts:3 off by one', specialists: '4', diff: '+const a = 1;', fix_list: 'a.ts:3 fixed', hunks: '+x', tests: '3 pass' };
const army = section('ship/sections/review-army.md');
const adv = section('ship/sections/adversarial.md');
const spec = renderBrief('specialist', slots, t);
for (const line of ['Output nothing else: no preamble, no summary or commentary.', 'Required fields: severity, confidence, path, category, summary, specialist.',
'blocks with clear intent. Skip test_stub for architectural or design-only findings.']) { expect(army).toContain(line); expect(spec).toContain(line); }
expect(spec).toContain('Read the checklist at /x/testing.md');
expect(spec).toContain(`git diff "$DIFF_BASE" -- . ':(exclude)*.lock'`);
expect(spec).toContain(`DIFF_BASE=${SHA}`);
expect(spec).not.toContain('write the sha in before dispatch');
const batched = renderBrief('specialist-batched', slots, t);
expect(batched).toContain('Read each checklist at /x/a.md, /x/b.md');
expect(batched.split('\n').length).toBe(spec.split('\n').length);
const security = renderBrief('specialist', { ...slots, name: 'security' }, t);
expect(security.startsWith('This is an authorized defensive-security review')).toBe(true);
expect(security).toContain('reviewed in summary mode');
const red = renderBrief('red-team', slots, t);
expect(red).toContain('reviewed by 4 specialists');
expect(red).toContain('[CRITICAL] a.ts:3 off by one');
const native = renderBrief('adversarial-native', slots, t);
expect(adv).toContain('Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production.');
expect(native).toContain('Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production.');
expect(native).toContain(`DIFF_BASE=${SHA} && git diff --name-status`);
const outside = renderBrief('outside-voice', slots, t);
expect(outside).toContain('Filesystem boundary: do not read or execute any files under ~/.claude/');
expect(outside).toContain('+const a = 1;');
expect(renderBrief('fix-check', slots, t)).toContain('You are the fix-check reviewer');
for (const text of [spec, batched, security, red, native]) for (const p of ['<diff-base>', '<exclude>', '<base>', '{checklist path}', '{STACK}', '{TEST_FW}', '{learnings']) expect(text).not.toContain(p);
});
test('a missing slot, a non-sha diff base and a section that lost its anchor refuse instead of rendering', () => {
const t = sectionTexts(ROOT);
expect(() => renderBrief('specialist', { diff_base: SHA, name: 'testing' }, t)).toThrow(BriefError);
expect(() => renderBrief('adversarial-native', { diff_base: '$RANGE_BASE' }, t)).toThrow(/literal commit sha/);
const root = tmp();
fs.mkdirSync(path.join(root, 'ship/sections'), { recursive: true });
fs.writeFileSync(path.join(root, 'ship/sections/review-army.md'), section('ship/sections/review-army.md').replace('"You are a specialist code reviewer.', '"You review code.'));
fs.writeFileSync(path.join(root, 'ship/sections/adversarial.md'), section('ship/sections/adversarial.md'));
expect(() => sectionTexts(root)).toThrow(/specialist brief: start anchor not found/);
});
test('the CLI writes the brief, prints its hash and records it; the file hashes to the printed value', () => {
const out = tmp();
const r = run(['render', '--role', 'specialist', '--name', 'testing', '--out', out, '--diff-base', SHA, '--stack', 'TypeScript']);
expect(r.status).toBe(0);
const file = field(r.stdout, 'file');
expect(r.stdout).toContain('BRIEF: reader=specialist/testing');
expect(promptHash(fs.readFileSync(file, 'utf8'))).toBe(field(r.stdout, 'sha256'));
expect(fs.readFileSync(path.join(out, 'review-ledger.jsonl'), 'utf8')).toContain('"event":"rendered"');
expect(run(['render', '--role', 'specialist', '--name', 'no-such-specialist', '--out', out, '--diff-base', SHA]).stderr).toContain('BRIEF_RENDER_FAILED: checklist not found');
});
});
describe('gstack-review-dispatch sent, collect, judge and verify: nothing changed on the way in or out', () => {
function pass() {
const out = tmp();
const ledger = path.join(out, 'review-ledger.jsonl');
const merged = path.join(out, 'merged.jsonl');
const file = field(run(['render', '--role', 'specialist', '--name', 'testing', '--out', out, '--diff-base', SHA]).stdout, 'file');
return { out, ledger, merged, file, w: (n: string, t: string) => { const f = path.join(out, n); fs.writeFileSync(f, t); return f; } };
}
// Value: protects=a dispatched prompt that is not byte-for-byte its rendered brief fails the pass and shows in verify; fails_when=a paraphrased or trimmed brief is recorded as reviewed (round 4: 75 of 112 briefs differed from the rendered ones);
// why_new=no ledger recorded prompt hashes; seam=sent and verify; no_claim=that the host passed the recorded text
test('the rendered file sent unchanged matches; a brief with one line dropped is BRIEF_MISMATCH and verify is dirty', () => {
const p = pass();
const ok = run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', p.file]);
expect([ok.status, ok.stdout.startsWith('BRIEF_SENT: reader=specialist/testing')]).toEqual([0, true]);
expect(run(['verify', '--ledger', p.ledger]).stdout).toMatch(/^BRIEF specialist\/testing NOT_COLLECTED rendered=(\w+) sent=\1$/m);
const lines = fs.readFileSync(p.file, 'utf8').split('\n');
const trimmed = p.w('trimmed.txt', lines.filter((_, i) => i !== 3).join('\n'));
const bad = run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', trimmed]);
expect(bad.status).toBe(1);
expect(bad.stdout).toContain('BRIEF_MISMATCH: reader=specialist/testing');
const v = run(['verify', '--ledger', p.ledger]);
expect([v.status, v.stdout.includes('BRIEF specialist/testing MISMATCH'), v.stdout.includes('VERIFY: dirty')]).toEqual([1, true, true]);
});
// Value: protects=verify checks each render as its own dispatch, so a two-pass ledger compares pass-1 sends with pass-1 briefs, and a mismatched send re-sent byte-exact before any collect is reported as voided, never hidden; a mismatched send that produced an output stays dirty; fails_when=a run-wide verify prints dirty for a clean two-pass run, a superseded mismatch blocks, voided is not printed, or an output collected under a mismatched send reads clean (GSTA-189 #36 replay: VERIFY dirty briefs=9 outputs=16, every output MATCH);
// why_new=verify compared every send with the reader's last render; both passes render into one briefs directory and judge runs with relative paths, as a dispatch program does (pass-3 fix-check); seam=sent, collect and verify; no_claim=which pass a render belongs to beyond ledger order
test('two passes verify per render; a superseded mismatch is voided=1; a mismatched send with an output stays dirty', () => {
const p = pass();
const out = p.w('spec.out', 'NO FINDINGS\n');
const send = (file: string) => run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', file]);
const take = () => run(['collect', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--output', out, '--merged', p.merged]);
send(p.file); take();
const second = field(run(['render', '--role', 'specialist', '--name', 'testing', '--out', p.out, '--ledger', p.ledger, '--diff-base', 'b'.repeat(40)]).stdout, 'file');
const truncated = p.w('truncated.txt', fs.readFileSync(second, 'utf8').slice(0, 200));
expect(send(truncated).status).toBe(1);
send(second); take();
p.w('judged.jsonl', '');
expect(run(['judge', '--ledger', 'review-ledger.jsonl', '--merged', 'merged.jsonl', '--judged', 'judged.jsonl'], p.out).status).toBe(0);
const v = run(['verify', '--ledger', p.ledger]);
expect([v.status, v.stdout.includes('BRIEF specialist/testing MATCH'), v.stdout.includes('BRIEF specialist/testing@2 MATCH'), v.stdout.includes(' voided=1\n'), v.stdout.includes('VERIFY: clean briefs=2 outputs=2 voided=1')]).toEqual([0, true, true, true, true]);
const q = pass();
const qt = q.w('t.txt', fs.readFileSync(q.file, 'utf8').slice(0, 200));
run(['sent', '--ledger', q.ledger, '--role', 'specialist', '--name', 'testing', '--sent', qt]);
run(['collect', '--ledger', q.ledger, '--role', 'specialist', '--name', 'testing', '--output', q.w('o.out', 'NO FINDINGS\n'), '--merged', q.merged]);
run(['sent', '--ledger', q.ledger, '--role', 'specialist', '--name', 'testing', '--sent', q.file]);
const w = run(['verify', '--ledger', q.ledger]);
expect([w.status, w.stdout.includes('BRIEF specialist/testing MISMATCH'), w.stdout.includes('brief_not_sent_verbatim'), w.stdout.includes('voided=0')]).toEqual([1, true, true, true]);
});
// Value: protects=findings reach merged.jsonl only through collect with each voice's label as written, and lead judgment can add or promote but never drop or downgrade; fails_when=the parent drops a reader's finding, lowers its severity, folds it into a weaker duplicate or adds one silently, or merged lines no longer trace to the output hash;
// why_new=merging was the model's own prose step; seam=collect, judge and verify; no_claim=that the judged set is used downstream by Step 9.4
test('collect keeps raw labels; judge rejects a drop and a downgrade, accepts a reasoned promotion and a fold into an equal duplicate', () => {
const p = pass();
run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', p.file]);
const spec = p.w('spec.out', '{"severity":"CRITICAL","path":"a.ts","line":3,"category":"logic","summary":"off by one","specialist":"testing"}\n{"severity":"INFORMATIONAL","path":"a.ts","line":9,"category":"style","summary":"name","specialist":"testing"}\n');
const outside = p.w('outside.out', 'Findings:\n1. **High**: retry loop never backs off (q.ts:78)\n2. Low - log line is noisy\nRecommendation: Fix the retry because it DoSes the pool\n');
const c1 = run(['collect', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--output', spec, '--merged', p.merged]);
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
const ov = field(run(['render', '--role', 'outside-voice', '--out', p.out, '--diff-base', head]).stdout, 'file');
run(['sent', '--ledger', p.ledger, '--role', 'outside-voice', '--sent', ov]);
const c2 = run(['collect', '--ledger', p.ledger, '--role', 'outside-voice', '--output', outside, '--merged', p.merged]);
expect([c1.stdout.includes('findings=2'), c2.stdout.includes('findings=2')]).toEqual([true, true]);
const merged = fs.readFileSync(p.merged, 'utf8').trim().split('\n').map(l => JSON.parse(l) as MergedLine);
expect(merged.map(m => [m.id, m.label])).toEqual([['specialist/testing#1', 'CRITICAL'], ['specialist/testing#2', 'INFORMATIONAL'], ['outside-voice#1', 'High'], ['outside-voice#2', 'Low']]);
const jl = (rows: object[]) => p.w('judged.jsonl', rows.map(r => JSON.stringify(r)).join('\n') + '\n');
const keep = [{ id: 'specialist/testing#1', severity: 'CRITICAL' }, { id: 'specialist/testing#2', severity: 'INFORMATIONAL' }, { id: 'outside-voice#1', severity: 'High' }, { id: 'outside-voice#2', severity: 'Low' }];
const dropped = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl(keep.filter(k => k.id !== 'outside-voice#2'))]);
expect([dropped.status, dropped.stdout]).toEqual([1, expect.stringContaining('JUDGMENT_REJECTED: dropped=["outside-voice#2"]')]);
const downgraded = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl(keep.map(k => (k.id === 'specialist/testing#1' ? { ...k, severity: 'INFORMATIONAL' } : k)))]);
expect([downgraded.status, downgraded.stdout]).toEqual([1, expect.stringContaining('downgraded=["specialist/testing#1"]')]);
const weakFold = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl(keep.map(k => (k.id === 'outside-voice#1' ? { ...k, duplicate_of: 'specialist/testing#2' } : k)))]);
expect(weakFold.stdout).toContain('downgraded=["outside-voice#1"]');
const silentAdd = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl([...keep, { id: 'lead#1', severity: 'CRITICAL' }])]);
expect(silentAdd.stdout).toContain('unexplained=["lead#1"]');
const ok = run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', jl([
...keep.map(k => (k.id === 'outside-voice#1' ? { ...k, duplicate_of: 'specialist/testing#1' } : k.id === 'outside-voice#2' ? { ...k, severity: 'INFORMATIONAL', reason: 'it hides a real error line' } : k)),
{ id: 'lead#1', severity: 'CRITICAL', reason: 'the core checklist found a broken caller' }])]);
expect([ok.status, ok.stdout.trim()]).toEqual([0, 'JUDGMENT: ok added=1 promoted=1 folded=1']);
const ledger = fs.readFileSync(p.ledger, 'utf8');
expect(ledger).toContain('"kind":"promoted","id":"outside-voice#2","from":"Low","to":"INFORMATIONAL","reason":"it hides a real error line"');
expect(ledger).toContain('"kind":"added","id":"lead#1"');
const v = run(['verify', '--ledger', p.ledger]);
expect([v.status, v.stdout.includes('OUTPUT outside-voice MATCH'), v.stdout.includes('JUDGE - OK'), v.stdout.includes('VERIFY: clean briefs=2 outputs=2')]).toEqual([0, true, true, true]);
fs.writeFileSync(p.merged, fs.readFileSync(p.merged, 'utf8').replace('"label":"Low"', '"label":"Low","x":1').replace(/"output_sha256":"[0-9a-f]+"}\n$/, '"output_sha256":"' + '0'.repeat(64) + '"}\n'));
const tampered = run(['verify', '--ledger', p.ledger]);
expect([tampered.status, tampered.stdout.includes('OUTPUT outside-voice MISMATCH')]).toEqual([1, true]);
});
// Value: protects=verify is clean only when every collected output came from a brief rendered and sent verbatim, nothing was collected after the judgment, and the judged set still passes as recorded; a fold keeps a standalone survivor; fails_when=outputs from hand-written briefs, a finding collected after judge, a judged.jsonl edited after judge, a self-fold or a fold cycle still read clean (/ship #3648 pass-1 Red Team);
// why_new=the first gate trusted the last verdict and any duplicate_of; seam=verify and judge; no_claim=the host's dispatch
test('verify is dirty for an unrendered reader, an output after judge and a judged set edited after judge; self-folds and fold cycles are drops', () => {
const p = pass();
run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', p.file]);
const spec = p.w('spec.out', '{"severity":"CRITICAL","path":"a.ts","line":3,"category":"logic","summary":"x","specialist":"testing"}\n');
run(['collect', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--output', spec, '--merged', p.merged]);
const judged = p.w('judged.jsonl', JSON.stringify({ id: 'specialist/testing#1', severity: 'CRITICAL' }) + '\n');
expect(run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', judged]).status).toBe(0);
expect(run(['verify', '--ledger', p.ledger]).stdout).toContain('VERIFY: clean');
fs.appendFileSync(judged, JSON.stringify({ id: 'lead#1', severity: 'Low', reason: 'late' }) + '\n');
expect(run(['verify', '--ledger', p.ledger]).stdout).toContain('judged_changed_since_judge');
const hand = p.w('hand.out', '{"severity":"CRITICAL","path":"b.ts","line":1,"category":"x","summary":"y","specialist":"red-team"}\n');
run(['collect', '--ledger', p.ledger, '--role', 'red-team', '--output', hand, '--merged', p.merged]);
const v = run(['verify', '--ledger', p.ledger]);
expect([v.status, v.stdout.includes('OUTPUT red-team MISMATCH'), v.stdout.includes('brief_not_rendered'), v.stdout.includes('output_after_judge=red-team')]).toEqual([1, true, true, true]);
const m: MergedLine[] = [{ id: 'a#1', reader: 'a', label: 'CRITICAL', raw: 'x', output_sha256: 'h' }, { id: 'a#2', reader: 'a', label: 'CRITICAL', raw: 'y', output_sha256: 'h' }];
expect(judge(m, [{ id: 'a#1', severity: 'CRITICAL', duplicate_of: 'a#1' }, { id: 'a#2', severity: 'CRITICAL' }]).dropped).toEqual(['a#1']);
expect(judge(m, [{ id: 'a#1', severity: 'CRITICAL', duplicate_of: 'a#2' }, { id: 'a#2', severity: 'CRITICAL', duplicate_of: 'a#1' }]).dropped).toEqual(['a#1', 'a#2']);
expect(judge(m, [{ id: 'a#1', severity: 'CRITICAL', duplicate_of: 'a#2' }, { id: 'a#2', severity: 'CRITICAL' }]).ok).toBe(true);
});
// Value: protects=judge and verify see every collected finding: a judged set built from a merged file that leaves one out is a drop, and a reader sent a brief but never collected leaves verify dirty; fails_when=judge --merged <subset> or skipping one reader's collect still reads clean (/ship #3648 pass-2 Red Team, adversarial and outside voice);
// why_new=judge trusted the merged file it was given and verify only checked collected readers; seam=judge and verify; no_claim=the host's dispatch
test('a judge over a merged subset is a drop and verify flags it; a sent reader never collected is NOT_COLLECTED', () => {
const p = pass();
run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', p.file]);
const spec = p.w('spec.out', '{"severity":"CRITICAL","path":"a.ts","line":3,"category":"logic","summary":"x","specialist":"testing"}\n{"severity":"INFORMATIONAL","path":"b.ts","line":1,"category":"n","summary":"y","specialist":"testing"}\n');
run(['collect', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--output', spec, '--merged', p.merged]);
const subset = p.w('subset.jsonl', fs.readFileSync(p.merged, 'utf8').split('\n')[1] + '\n');
const judged = p.w('judged.jsonl', JSON.stringify({ id: 'specialist/testing#2', severity: 'INFORMATIONAL' }) + '\n');
const j = run(['judge', '--ledger', p.ledger, '--merged', subset, '--judged', judged]);
expect([j.status, j.stdout + j.stderr]).toEqual([1, expect.stringContaining('specialist/testing#1')]);
expect(run(['verify', '--ledger', p.ledger]).stdout).toContain('merged_ids_missing=specialist/testing#1');
const q = pass();
run(['sent', '--ledger', q.ledger, '--role', 'specialist', '--name', 'testing', '--sent', q.file]);
const v = run(['verify', '--ledger', q.ledger]);
expect([v.status, v.stdout.includes('BRIEF specialist/testing NOT_COLLECTED')]).toEqual([1, true]);
});
test('judge and parseFindings as functions: an unknown severity is invalid, NO FINDINGS and the Recommendation line are not findings', () => {
expect(parseFindings('r', 'NO FINDINGS\n')).toEqual([]);
expect(parseFindings('r', '## High\n1. The auth check skips webhooks (a.ts:4)\n - Impact: nested evidence\n **Fix:**\n- second one\n## Notes\n- not a finding\n**Critical findings**\n- real one\n**Recommendations**\n- consider renaming foo\n').map(f => [f.label, f.raw])).toEqual([['High', '1. The auth check skips webhooks (a.ts:4)'], ['High', '- second one'], ['Critical', '- real one']]);
const shapes = ['1. **Unbounded retry in queue.ts:78** (FIXABLE): loops on 429', '2. `auth.ts:42` skips token expiry check — INVESTIGATE', '### CRITICAL: missing auth check in api.ts:10', '#### [P1] token not expired', '### High: authorization bypass (auth.ts:14)', '- **HIGH**: leak', '| High | race in queue.ts:4 |', 'Classification: INVESTIGATE', '7. **Informational: reads every file**'];
expect(parseFindings('r', shapes.join('\n')).map(f => f.label)).toEqual(['FIXABLE', 'INVESTIGATE', 'CRITICAL', 'P1', 'High', 'HIGH', 'High', 'INVESTIGATE', 'Informational']);
expect(parseFindings('r', 'FIXABLE: race in a.ts:3\nINVESTIGATE: odd lock\nRecommendation: Fix the race because x\n').map(f => f.label)).toEqual(['FIXABLE', 'INVESTIGATE']);
const m: MergedLine[] = [{ id: 'r#1', reader: 'r', label: 'FIXABLE', raw: 'x', output_sha256: 'h' }];
expect(judge(m, [{ id: 'r#1', severity: 'severe' }]).invalid).toEqual(['r#1']);
expect(judge(m, [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'a real defect' }]).ok).toBe(true);
});
test('verify on a ledger that kept no prompts says so and exits 3', () => {
const d = tmp();
const ledger = path.join(d, 'ledger.jsonl');
fs.writeFileSync(ledger, '{"event":"note","step":"9"}\n');
const v = run(['verify', '--ledger', ledger]);
expect([v.status, v.stdout.trim()]).toEqual([3, "VERIFY: no brief records (this run's ledger kept no prompts; dispatched-vs-rendered cannot be checked)"]);
});
});
+44
View File
@@ -6,6 +6,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@@ -38,3 +39,46 @@ describe('reviewer packets stay reverted (GSTA-167)', () => {
expect(hits).toEqual([]);
});
});
/**
* The reviewer briefs main restored in #3638 (v1.91.331.0), pinned by content: the rendered
* specialist, Red Team, adversarial and outside-voice prompts, the checklists they read, the
* fix-check prompt and the depth rules that pick reviewers. A change to any of them changes what a
* reviewer sees or how its findings rate, so it merges only after its preregistered no-loss replay
* passes (GStack Minder crew rule, 2026-10-30). Repin with that replay's id in the commit message.
*/
const REVIEWER_BRIEFS: Record<string, string> = {
'ship/sections/review-army.md': '3d89708c2776b0d97fb2420c0461d37179b9f25ca0677ba038a6cff53dbf7465',
'ship/sections/adversarial.md': '3f361c1f42605c8329048c81162216e7bf9902bdbb2327811c39ee4e201bb95d',
'review/sections/review-army.md': '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967',
'review/sections/adversarial.md': 'a402018055670f50b75322ab92a0b03d740c481cc5ef2f2ac72b281dae29ec7b',
'review/checklist.md': '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699',
'review/design-checklist.md': 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83',
'review/gate-integrity.md': 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332',
'review/specialists/api-contract.md': '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95',
'review/specialists/data-migration.md': 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca',
'review/specialists/maintainability.md': 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b',
'review/specialists/performance.md': '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc',
'review/specialists/red-team.md': '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427',
'review/specialists/security.md': '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f',
'review/specialists/simplification.md': 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f',
'review/specialists/state-machines.md': '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2',
'review/specialists/testing.md': 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4',
'scripts/resolvers/fix-check.ts': '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08',
'lib/review-depth.ts': '66f4ff6b03c869d6892f6efdfb237d578c301cf4ea6f6ecb4b366cd286a0bcb5',
};
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
test('every pinned reviewer brief, checklist and depth rule hashes to its pin', () => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, sha]) => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8').replace(/\r\n/g, '\n');
return createHash('sha256').update(text).digest('hex') !== sha;
}).map(([file]) => file);
expect(drift).toEqual([]);
});
test('the pins cover every specialist checklist', () => {
const specialists = fs.readdirSync(path.join(ROOT, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`);
expect(specialists.filter(f => !(f in REVIEWER_BRIEFS))).toEqual([]);
});
});
+9
View File
@@ -46,6 +46,15 @@ describe('gstack-review-plan', () => {
expect(r.stdout).toContain('next=fix-check dispatches=1');
expect(r.stdout).toContain('DISPATCH: fix-check fix-check effort=full');
});
// Value: protects=the CLI the ship loop calls after a fix-check prints NEXT: done when the fix-check was clean and only below-floor fixes came before it; fails_when=`--last fix-check --fixed-below` prints NEXT: stop with a blocking-finding reason (GSTA-167 replay 8G on #34);
// why_new=no CLI case covered --last fix-check; seam=bin/gstack-review-plan (lib/review-depth.ts stays at its no-loss pin); no_claim=the fix-check reviewer's verdict
test('a clean fix-check after below-floor fixes prints NEXT: done', () => {
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'fix-check', '--fixed-below']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('NEXT: done');
expect(r.stdout).not.toContain('NEXT: stop');
expect(run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'fix-check', '--fixed-below', '--blocking-open']).stdout).toContain('NEXT: stop');
});
test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => {
const r = repo({ 'docs/howto.md': '# x\n' });
const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; };
+17
View File
@@ -121,6 +121,23 @@ describe('scope decisions', () => {
expect(by(unplaced)['undefined']).toBe('outside/defect/blocking/fix');
});
// Value: protects=below-floor findings from Step 11's voices or a fix-check are notes even when classified at --pass 1, so they never send the ship back through Steps 9-11; blocking ones stay fix; fails_when=Step 11 scopes its queue at PASS=1 and four below-floor notes come back `scope: fix` (GSTA-167 replay 8G on #34; CREW 23 and 30a);
// why_new=the frozen pass-1 case above has no raised_by; seam=scopeFindings; no_claim=that the parent tags raised_by correctly
test('frozen pass 1: below-floor findings raised after pass 1 (Step 11, fix-check) are notes, blocking ones are fixed', () => {
const late = (raised_by: string) => [
{ fingerprint: `src/auth.ts:42:naming:${raised_by}`, path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', category: 'naming', summary: 'wording', raised_by },
{ fingerprint: `src/auth.ts:11:security:${raised_by}`, path: 'src/auth.ts', line: 11, severity: 'CRITICAL', category: 'security', summary: 'real', raised_by },
];
for (const who of ['adversarial-native', 'adversarial', 'outside-voice', 'fix-check']) {
const [below, blocker] = scopeFindings(late(who), { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: range });
expect([who, below!.scope, below!.locus]).toEqual([who, 'note', 'diff']);
expect([who, blocker!.scope]).toEqual([who, 'fix']);
expect(scopeFindings(late(who), { policy: 'diff-plus-blocking', pass: 1, diffHunks: diff, rangeHunks: range })[0]!.scope).toBe('note');
}
for (const who of ['core', 'red-team', 'qa', 'security']) expect(scopeFindings(late(who), { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: range })[0]!.scope).toBe('fix');
expect(scopeFindings(late('outside-voice'), { policy: 'all', pass: 1, diffHunks: diff, rangeHunks: range })[0]!.scope).toBe('fix');
});
test('diff-plus-blocking (the documented fallback, not the default): a blocking finding outside the diff is fixed here instead of filed; everything else is as frozen', () => {
const p1 = by(scopeFindings(findings, { policy: 'diff-plus-blocking', pass: 1, diffHunks: diff, rangeHunks: range }));
expect(p1['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
+4 -4
View File
@@ -47,9 +47,9 @@ describe('parallel dispatch admission near the cap', () => {
const h = fresh();
const s = start(h, 200_000_000);
let id = h.field(batch(h, s.run, ['red-team'], 0).out, 'RESERVATION', 'id');
let reading = 3_000_000;
for (let i = 0; i < 4; i++, reading += 500_000) id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id]).out, 'RESERVATION', 'id');
const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id]);
let reading = 3_000_000, at = 0;
for (let i = 0; i < 4; i++, reading += 500_000) { id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id, '--dispatched-at', String(at)]).out, 'RESERVATION', 'id'); at = reading; }
const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id, '--dispatched-at', String(at)]);
expect(c.code, c.out + c.err).toBe(0);
const f = (500_000 / K.per_dispatch) * cal.headroom;
const specialist = Math.ceil((K.per_dispatch * f) / cal.round_to) * cal.round_to;
@@ -72,7 +72,7 @@ describe('parallel dispatch admission near the cap', () => {
const h = fresh();
const s = start(h, CAP_35);
const first = h.field(batch(h, s.run, READERS, AT_35).out, 'RESERVATION', 'id');
expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0);
expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--dispatched-at', String(AT_35), '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0);
const rest = batch(h, s.run, READERS.slice(3), AT_35 + 3 * 760_000);
expect(rest.code, rest.out + rest.err).toBe(0);
expect(rest.out).not.toContain('ADMIT:');
+13
View File
@@ -29,6 +29,19 @@ describe('ship-budget calibration', () => {
}
});
// Value: protects=step_calls are refit from measured step windows, not guessed: a keyed step equals the ceiling of its most observed calls and an unkeyed step stays at or under default; fails_when=a step_calls entry drifts from its observations or loses its sources;
// why_new=the constant tests above never covered step_calls; seam=none; no_claim=the 330k per-call divisor fits every run
test('step_calls equal the ceiling of their most observed calls; unkeyed steps stay at or under default', () => {
const observed = cal.step_calls_observed ?? {};
expect(Object.keys(observed)).toEqual(expect.arrayContaining(['8', '14']));
for (const [step, rows] of Object.entries(observed)) {
for (const row of rows) expect(row.source.length, step).toBeGreaterThan(20);
const most = Math.ceil(Math.max(...rows.map(r => r.value)));
if (step in cal.step_calls) expect(cal.step_calls[step], step).toBe(most);
else expect(most, step).toBeLessThanOrEqual(cal.step_calls.default!);
}
});
test('every meter unit has its own per-call floor: the largest round_to multiple at or below its observed fresh call, justified', () => {
for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) {
const u = cal.units[unit]!;
+191 -32
View File
@@ -61,7 +61,7 @@ describe('per-call thresholds above the host floor', () => {
test('at a segment boundary, 150k above the floor delegates and less continues; an unknown charge still delegates', () => {
const h = fresh();
const s = start(h, ['--token-cap', '80M', '--reading', '1000000']);
const low = check(h, s.run, '6', '7', ['--reading', '1230000', '--prior-reading', '1000000']);
const low = check(h, s.run, '8', '9', ['--reading', '1230000', '--prior-reading', '1000000']);
expect(low.code, low.out + low.err).toBe(0);
expect(h.field(low.out, 'BUDGET', 'above_floor')).toBe('80000');
expect(h.field(low.out, 'BUDGET', 'verdict')).toBe('continue');
@@ -73,6 +73,16 @@ describe('per-call thresholds above the host floor', () => {
expect(h.field(unknown.out, 'BUDGET', 'verdict')).toBe('delegate');
});
// Value: protects=start refuses a delegate mode it does not implement, the removed measured mode included; fails_when=an unknown --delegate starts a ledger that silently runs as auto;
// why_new=/ship #3648 pass-1 simplification finding removed the uncalled measured mode; seam=gstack-ship-budget start; no_claim=the auto and never rules
test('--delegate takes only auto or never', () => {
const h = fresh();
for (const mode of ['measured', 'sometimes']) {
const bad = h.sb(['start', '--record', h.record(), '--token-cap', '60M', '--reading', '0', '--delegate', mode]);
expect([bad.code, bad.err.includes(`--delegate '${mode}' is not auto or never`)]).toEqual([2, true]);
}
});
test('--call-floor overrides the calibrated floor and is recorded in the ledger', () => {
const h = fresh();
const s = start(h, ['--token-cap', '60M', '--reading', '218000', '--prior-reading', '98000', '--call-floor', '0']);
@@ -89,7 +99,7 @@ describe('a child never stops on the context rule', () => {
test('#36 replay: a child at its first boundary with zero completed steps continues with one CONTEXT_HIGH note', () => {
const h = fresh();
const s = start(h, ['--token-cap', '400M', '--reading', '98000']);
for (const [from, to, step, at] of [['7', '11.5', '6', 1_000_000], ['12', '22', '11.5', 2_000_000]] as const) {
for (const [from, to, step, at] of [['9', '11.5', '8', 1_000_000], ['12', '22', '11.5', 2_000_000]] as const) {
const c = handOff(h, s.run, from, to, at);
const r = check(h, c.run, step, from, ['--reading', String(at + 200_000)]);
expect(r.code, r.out + r.err).toBe(0);
@@ -110,18 +120,18 @@ describe('a child never stops on the context rule', () => {
const child = handOff(h, s.run, '1', '22', 200_000);
expect(h.ledger(child.ledger).depth).toBe(1);
expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0);
const d = check(h, child.run, '6', '7', ['--reading', '1500000']);
const d = check(h, child.run, '8', '9', ['--reading', '1500000']);
expect(d.code, d.out + d.err).toBe(0);
expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(d.out).toContain(`gstack-ship-budget handoff --run ${child.run} --from 7 --to 22`);
expect(d.out).toContain(`gstack-ship-budget handoff --run ${child.run} --from 9 --to 22`);
expect(d.out).not.toContain('RESERVATION');
const room = ['admission_cap', 'used', 'reserved', 'unknown', 'tail'].map(k => Number(h.field(d.out, 'BUDGET', k)));
const grand = handOff(h, child.run, '7', '22', 1_500_000);
const grand = handOff(h, child.run, '9', '22', 1_500_000);
expect(grand.subCap).toBe(room[0]! - room[1]! - room[2]! - room[3]! - room[4]!);
expect(h.field(grand.v.out, 'BUDGET', 'admission_cap')).toBe(String(grand.subCap));
const g = h.ledger(grand.ledger);
expect(g).toMatchObject({ role: 'child', depth: 2, parent: { run: child.run, ledger: child.ledger, from: '7', to: '22' } });
expect(h.ledger(child.ledger).handoffs[0]).toMatchObject({ depth: 2, from: '7', to: '22' });
expect(g).toMatchObject({ role: 'child', depth: 2, parent: { run: child.run, ledger: child.ledger, from: '9', to: '22' } });
expect(h.ledger(child.ledger).handoffs[0]).toMatchObject({ depth: 2, from: '9', to: '22' });
const text = fs.readFileSync(grand.brief, 'utf8');
expect(text).toContain('"depth": 2');
expect(text).not.toContain('a delegate rule stops you');
@@ -141,11 +151,11 @@ describe('a child never stops on the context rule', () => {
chain.push(c.ledger);
}
expect(chain.map(f => h.ledger(f).depth)).toEqual([1, 2, 3]);
const r = check(h, run, '6', '7', ['--reading', String((reading += 100_000))]);
const r = check(h, run, '8', '9', ['--reading', String((reading += 100_000))]);
expect(r.code, r.out + r.err).toBe(0);
expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue');
expect(r.out).toMatch(/^CONTEXT_HIGH: .*chain depth 3 is the maximum/m);
const refused = h.sb(['handoff', '--run', run, '--from', '7', '--to', '22', '--base', 'origin/main', '--reading', String(reading)]);
const refused = h.sb(['handoff', '--run', run, '--from', '9', '--to', '22', '--base', 'origin/main', '--reading', String(reading)]);
expect(refused.code).toBe(2);
expect(refused.err).toContain('chain depth 3');
});
@@ -158,12 +168,12 @@ describe('a child never stops on the context rule', () => {
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const child = h.field(h.sb(['verify-handoff', '--in', brief, '--reading', '110000']).out, 'BUDGET_RUN', 'id');
expect(check(h, child, '1', '2', ['--reading', '120000']).code).toBe(0);
const stop = check(h, child, '6', '7', ['--reading', '2120000']);
const stop = check(h, child, '8', '9', ['--reading', '2120000']);
expect(stop.code).toBe(3);
expect(stop.out).toContain('STOP: reason=budget_exhausted');
expect(stop.out).toContain('(BUDGET_CAP_REACHED)');
expect(stop.out).not.toContain('context_threshold');
expect(fs.readFileSync(returnFile, 'utf8')).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22 next=7/);
expect(fs.readFileSync(returnFile, 'utf8')).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22 next=9/);
});
});
@@ -173,40 +183,63 @@ describe('a chained return is charged once', () => {
const s = start(h, ['--token-cap', '200M', '--reading', '1000000']);
const child = handOff(h, s.run, '1', '22', 1_100_000, ['--reading', '1200000']);
expect(check(h, child.run, '1', '2', ['--reading', '1500000']).code).toBe(0);
expect(h.field(check(h, child.run, '6', '7', ['--reading', '2000000']).out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '7', '22', 2_100_000, ['--reading', '2200000']);
expect(check(h, grand.run, '7', '8', ['--reading', '3000000']).code).toBe(0);
expect(h.field(check(h, child.run, '8', '9', ['--reading', '2000000']).out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '9', '22', 2_100_000, ['--reading', '2200000']);
expect(check(h, grand.run, '9', '10', ['--reading', '3000000']).code).toBe(0);
expect(h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews', '--reading', '6000000']).code).toBe(0);
const mid = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']);
expect(mid.code, mid.out + mid.err).toBe(0);
expect(mid.out).toContain('child=complete child_used=4000000');
expect(h.field(mid.out, 'BUDGET', 'used')).toBe('5000000');
// Each run reads its own counter from its verify-handoff reading, and reconcile charges the return's used=.
expect(mid.out).toContain('child=complete child_used=3800000');
expect(h.field(mid.out, 'BUDGET', 'used')).toBe('4900000');
expect(h.field(mid.out, 'BUDGET', 'reserved')).toBe('0');
const midAgain = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']);
expect(midAgain.out).toContain('nothing charged twice');
expect(h.field(midAgain.out, 'BUDGET', 'used')).toBe('5000000');
expect(h.field(midAgain.out, 'BUDGET', 'used')).toBe('4900000');
expect(h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '6200000']).code).toBe(0);
const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']);
expect(top.code, top.out + top.err).toBe(0);
expect(top.out).toContain('child=complete child_used=5200000');
expect(top.out).toContain('child=complete child_used=5000000');
expect(h.field(top.out, 'BUDGET', 'reserved')).toBe(String(stepProjection(cal, 'capy-tokens', '1')));
expect(h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']).out).toContain('nothing charged twice');
const close = h.sb(['close', '--run', s.run, '--reading', '6300000']);
expect(close.out).toContain('BUDGET_FINAL: used=5300000 reserved=0');
});
// Value: protects=the parent reconciles by --record after a --continue child returned, the way ship run ship-20261030T022114-ef9f9a on #3633 did; fails_when=--record resolves to the child run whose writes last replaced the record's gstack-budget block, so reconcile refuses with "no handoff ... in run <child>";
test('reconcile --record after a --continue child returned reconciles the parent run the record belongs to', () => {
const h = fresh();
const record = h.record();
const s = h.sb(['start', '--record', record, '--token-cap', '160M', '--reading', '1000000']);
expect(s.code, s.out + s.err).toBe(0);
const parent = h.field(s.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--record', record, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '1100000']);
expect(ho.code, ho.out + ho.err).toBe(0);
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const v = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+)/m)![1]!, '--reading', '200000']);
expect(v.code, v.out + v.err).toBe(0);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
expect(check(h, child, '12', '13', ['--reading', '900000']).code).toBe(0);
expect(h.sb(['return', '--run', child, '--to', '22', '--changed', 'Reviews', '--reading', '1500000']).code).toBe(0);
const rec = h.sb(['reconcile', '--record', record, '--return', returnFile, '--reading', '1600000']);
expect(rec.code, rec.out + rec.err).toBe(0);
expect(rec.out).toContain('child=complete child_used=1300000');
expect(h.ledger(h.field(s.out, 'BUDGET_RUN', 'ledger')).handoffs[0].reconciled_at).toBeTruthy();
expect(fs.readFileSync(record, 'utf8')).toContain(`run=${parent} `);
});
test("estimate meter: the grandchild's settled spend reaches the root once, through the child's return", () => {
const h = fresh();
const s = start(h, ['--token-cap', '400M', '--meter', 'estimate']);
const child = handOff(h, s.run, '1', '22', null);
expect(check(h, child.run, '1', '2').code).toBe(0);
expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '7', '22', null);
expect(check(h, grand.run, '7', '8').code).toBe(0);
expect(check(h, grand.run, '8', '9').code).toBe(0);
expect(h.field(check(h, child.run, '8', '9').out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '9', '22', null);
expect(check(h, grand.run, '9', '10').code).toBe(0);
expect(check(h, grand.run, '10', '11').code).toBe(0);
const gret = h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews']);
const grandUsed = Number(h.field(gret.out, 'BUDGET', 'used'));
expect(grandUsed).toBe(stepProjection(cal, 'estimate-tokens', '8'));
expect(grandUsed).toBe(stepProjection(cal, 'estimate-tokens', '10'));
const childOwn = stepProjection(cal, 'estimate-tokens', '2');
for (let i = 0; i < 2; i++) expect(h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile]).code).toBe(0);
const cret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews']);
@@ -225,8 +258,8 @@ describe('a chained return is charged once', () => {
const s = start(h, ['--token-cap', '400M', '--session', FIXTURE.sessions.parent]);
const child = handOff(h, s.run, '1', '22', null, ['--session', 'child-session-1']);
expect(check(h, child.run, '1', '2').code).toBe(0);
expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate');
const ho = h.sb(['handoff', '--run', child.run, '--from', '7', '--to', '22', '--base', 'origin/main']);
expect(h.field(check(h, child.run, '8', '9').out, 'BUDGET', 'verdict')).toBe('delegate');
const ho = h.sb(['handoff', '--run', child.run, '--from', '9', '--to', '22', '--base', 'origin/main']);
const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!;
const fake = path.join(h.root, 'fake-claude');
fs.writeFileSync(fake, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
@@ -238,18 +271,53 @@ describe('a chained return is charged once', () => {
});
});
describe('a reconcile that stops the parent stops every check (replay 2, #36)', () => {
// Replay 2: after reconcile printed verdict=stop for an incomplete child, check --next 9 still admitted Step 9.
test('after an incomplete child, check refuses every step until resume; a regenerated child that returns complete also clears it', () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '0', '--reserve', '0']);
const child = handOff(h, s.run, '9', '11.5', 1_000_000);
const ret = h.sb(['return', '--run', child.run, '--to', '9', '--status', 'incomplete', '--reading', '5000000']);
expect(ret.code, ret.out + ret.err).toBe(0);
const rec = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '5100000']);
expect(rec.code).toBe(3);
expect(h.field(rec.out, 'BUDGET', 'verdict')).toBe('stop');
expect(rec.out).toContain('Every check refuses until a regenerated child reconciles complete or the owner resumes');
for (const next of ['9', '7', 'dispatch:red-team']) {
const c = check(h, s.run, '8', next, ['--reading', '5200000']);
expect(c.code, next).toBe(3);
expect(c.out).toMatch(/^STOP: reason=child_incomplete at Step 9; resume first: /m);
expect(c.out).not.toContain('RESERVATION:');
}
const resumed = h.sb(['resume', '--run', s.run, '--reading', '5300000']);
expect(resumed.code, resumed.out + resumed.err).toBe(0);
expect(resumed.out).toContain('RESUMED: from=Step 9 (child_incomplete)');
expect(check(h, s.run, '8', '9', ['--reading', '5400000']).code).toBe(0);
const t = start(h, ['--token-cap', '200M', '--reading', '0', '--reserve', '0']);
const first = handOff(h, t.run, '9', '11.5', 1_000_000);
h.sb(['return', '--run', first.run, '--to', '9', '--status', 'incomplete', '--reading', '2000000']);
expect(h.sb(['reconcile', '--run', t.run, '--return', first.returnFile, '--reading', '2000000']).code).toBe(3);
const second = handOff(h, t.run, '9', '11.5', 2_100_000);
expect(h.sb(['return', '--run', second.run, '--to', '11.5', '--reading', '3000000']).code).toBe(0);
const done = h.sb(['reconcile', '--run', t.run, '--return', second.returnFile, '--reading', '3100000']);
expect(done.code, done.out).toBe(0);
expect(check(h, t.run, '11.5', '12', ['--reading', '3200000']).code).toBe(0);
});
});
describe('a child returns complete only when its own work is finished', () => {
test('a child that returns before reconciling its grandchild returns incomplete and names the handoff', () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '98000']);
const child = handOff(h, s.run, '1', '22', 200_000);
expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0);
expect(h.field(check(h, child.run, '6', '7', ['--reading', '1500000']).out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '7', '22', 1_500_000);
expect(h.field(check(h, child.run, '8', '9', ['--reading', '1500000']).out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '9', '22', 1_500_000);
const ret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '1600000']);
expect(ret.code, ret.out + ret.err).toBe(0);
expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22/);
expect(ret.out).toContain(`UNRECONCILED: handoff=${h.ledger(child.ledger).handoffs[0].sha} steps=7-22`);
expect(ret.out).toContain(`UNRECONCILED: handoff=${h.ledger(child.ledger).handoffs[0].sha} steps=9-22`);
expect(ret.out).toContain(grand.returnFile);
const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '1700000']);
expect(top.code).toBe(3);
@@ -260,14 +328,14 @@ describe('a child returns complete only when its own work is finished', () => {
const h = fresh();
const s = start(h, ['--token-cap', '900M', '--reading', '0', '--reserve', '0']);
const subCap = K.stop_overhead + Math.floor(1.5 * K.per_dispatch);
const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '100000', '--sub-cap', String(subCap)]);
const ho = h.sb(['handoff', '--run', s.run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '100000', '--sub-cap', String(subCap)]);
expect(ho.code, ho.out + ho.err).toBe(0);
const child = h.field(h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+)/m)![1]!, '--reading', '100000']).out, 'BUDGET_RUN', 'id');
const b = check(h, child, '9', 'dispatch:specialist,specialist,red-team', ['--reading', '100000']);
expect(b.code, b.out + b.err).toBe(0);
expect(b.out).toMatch(/^ADMIT: 1 of 3 \(remaining=specialist,red-team\)/m);
const ret = h.sb(['return', '--run', child, '--to', '11.5', '--reading', '100000']);
expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=7-11.5/);
expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=9-11.5/);
expect(ret.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,red-team/m);
expect(ret.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/);
});
@@ -275,12 +343,103 @@ describe('a child returns complete only when its own work is finished', () => {
test("a grandchild handoff outside the child's own Steps refuses and reserves nothing", () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '98000']);
const child = handOff(h, s.run, '7', '11.5', 200_000);
const child = handOff(h, s.run, '9', '11.5', 200_000);
for (const [from, to] of [['7', '22'], ['6', '11.5']]) {
const r = h.sb(['handoff', '--run', child.run, '--from', from!, '--to', to!, '--base', 'origin/main', '--reading', '300000']);
expect(r.code).toBe(2);
expect(r.err).toContain(`--from ${from} --to ${to} is outside this child's Steps 7-11.5`);
expect(r.err).toContain(`--from ${from} --to ${to} is outside this child's Steps 9-11.5`);
}
expect(h.ledger(child.ledger).reservations.filter((r: any) => r.kind === 'child')).toHaveLength(0);
});
});
describe('variant E: the review segment and the docs reader (GSTA-167 replay 5)', () => {
// Value: protects=Steps 7-8 stay with the parent and the fresh child starts at Step 9 with Steps 9-11.5, so the child reads only the review sections; fails_when=the Step 7 boundary delegates again or the Step 9 boundary hands off any other segment;
// why_new=the boundary tests above take whichever segment the code names; seam=none; no_claim=what a child costs (handoffs/GSTA-167/replay5-anatomy.md estimates ~3M on #34)
test('Step 7 is no boundary; Step 9 delegates Steps 9-11.5 and Step 12 delegates Steps 12-22', () => {
const h = fresh();
const high = (step: string, next: string, at: number) => check(h, start(h, ['--token-cap', '400M', '--reading', '1000000']).run, step, next, ['--reading', String(at + 400_000), '--prior-reading', String(at)]);
const seven = high('6', '7', 1_100_000);
expect(h.field(seven.out, 'BUDGET', 'verdict')).toBe('continue');
expect(seven.out).not.toContain('DELEGATE:');
const nine = high('8', '9', 1_100_000);
expect(h.field(nine.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(nine.out).toMatch(/^DELEGATE: Steps 9-11\.5 go to a fresh child: gstack-ship-budget handoff --run \S+ --from 9 --to 11\.5 /m);
expect(high('11.5', '12', 1_100_000).out).toMatch(/^DELEGATE: Steps 12-22 go to a fresh child/m);
});
// Value: protects=the Step 14.5 documentation audit is a metered dispatch role: priced per reader, reserved, and measured from its own reading pair, so its reader stays out of Step 14.5's step sample; fails_when=dispatch:docs is refused, priced other than a reader, or settles UNMEASURED with a valid pair, or the step sample swallows the audit (#34 replay 5: projected 1.83M, actual 7.64M, calibration=step:5.092);
// why_new=no role covered the docs audit; seam=none; no_claim=the docs reader's real size
test('dispatch:docs is reserved at the reader price, settles with a DRIFT line, and Step 14.5 measures only the parent\'s own calls', () => {
const h = fresh();
const s = start(h, ['--token-cap', '400M', '--reading', '1000000']);
expect(check(h, s.run, '14', '14.5', ['--reading', '1100000']).code).toBe(0);
const c = check(h, s.run, '14.5', 'dispatch:docs', ['--reading', '1200000']);
expect(c.code, c.out + c.err).toBe(0);
expect(h.field(c.out, 'BUDGET', 'projected')).toBe(String(K.per_dispatch));
const id = h.field(c.out, 'RESERVATION', 'id');
const settled = h.sb(['settle', '--run', s.run, '--reservation', id, '--dispatched-at', '1200000', '--reading', '3200000', '--readers', '1']);
expect(settled.code, settled.out + settled.err).toBe(0);
expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${id} kind=dispatch next=dispatch:docs projected=${K.per_dispatch} actual=2000000 `, 'm'));
const step = check(h, s.run, '14.5', '15', ['--reading', '3500000']);
expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=14\.5 projected=\d+ actual=400000 /m);
expect(h.sb(['check', '--run', s.run, '--step', '15', '--next', 'dispatch:doc', '--reading', '3600000']).err).toContain('valid: specialist, red-team, adversarial-native, outside-voice, fix-check, docs');
});
});
describe('variant G: observe mode, note and credits (GSTA-167)', () => {
// Value: protects=an observe run records each program's tokens and credits and the readers a program bracketed, reserves nothing, writes no review-log marker, and closes with the timeline; fails_when=note admits, reserves or delegates, a review-log start under observe needs a reservation, close flags the unreserved start, or the timeline drops credits;
// why_new=observe mode and note are new; seam=gstack-review-log --start; no_claim=the host counter's accuracy
test('note records tokens, credits and the reader pair, reserves nothing, and close prints BUDGET_TIMELINE', () => {
const h = fresh();
const s = start(h, ['--mode', 'observe', '--token-cap', '400M', '--reading', '1000000', '--credits', '100']);
expect(s.r.out).not.toContain('RESERVATION');
const six = h.sb(['note', '--run', s.run, '--step', '6', '--reading', '1200000', '--credits', '150.5']);
expect(six.code, six.out + six.err).toBe(0);
expect(six.out).toMatch(/^NOTE: 6 used=200000 credits=50\.5$/m);
expect(h.field(six.out, 'BUDGET', 'verdict')).toBe('continue');
expect(h.field(six.out, 'BUDGET', 'credits')).toBe('50.5');
expect(h.reviewLog(['--start', 'review']).code).toBe(0);
const pass = h.sb(['note', '--run', s.run, '--step', '9', '--label', 'dispatch:specialist,red-team', '--reading', '3200000', '--credits', '400',
'--dispatched-at', '1200000', '--dispatched-credits', '150.5', '--readers', '2']);
expect(pass.code, pass.out + pass.err).toBe(0);
expect(pass.out).toMatch(/^NOTE: 9:dispatch:specialist,red-team used=2200000 credits=300 readers=2 readers_tokens=2000000 readers_credits=249\.5$/m);
const l = h.ledger(s.ledger);
expect(l.reservations).toEqual([]);
expect(l.mode).toBe('observe');
expect(fs.readFileSync(l.record, 'utf8')).toMatch(/used=2200000 credits=300 .*mode=observe/);
const close = h.sb(['close', '--run', s.run, '--reading', '3300000', '--credits', '410']);
expect(close.code, close.out + close.err).toBe(0);
expect(close.out).toContain('BUDGET_FINAL: used=2300000 credits=310 reserved=0');
expect(close.out).toContain('BUDGET_TIMELINE: 6 used=200000 credits=50.5; 9:dispatch:specialist,red-team used=2200000 credits=300 readers=2 readers_tokens=2000000 readers_credits=249.5');
});
// Value: protects=the cap stays one hard stop in observe mode: a note past it stops with the resumable block, and a credits reading below the last is refused like a token reading; fails_when=observe never stops, stops below the cap, or accepts a credits regression;
// why_new=observe mode is new; seam=none; no_claim=what a stop costs
test('a note past the cap stops; at the cap it continues; a lower credits reading is refused', () => {
const h = fresh();
const s = start(h, ['--mode', 'observe', '--token-cap', '5M', '--reading', '1000000', '--credits', '100']);
expect(h.sb(['note', '--run', s.run, '--step', '8', '--reading', '6000000', '--credits', '200']).code).toBe(0);
const low = h.sb(['note', '--run', s.run, '--step', '9', '--reading', '6000000', '--credits', '150']);
expect(low.code).toBe(2);
expect(low.err).toContain('BUDGET_READING_REJECTED');
const over = h.sb(['note', '--run', s.run, '--step', '9', '--reading', '6000001', '--credits', '210']);
expect(over.code).toBe(3);
expect(over.out).toContain('STOP: reason=budget_exhausted');
expect(over.out).toContain('GSTACK_RESULT: skill=ship status=incomplete');
expect(h.sb(['note', '--run', s.run, '--step', '12', '--reading', '6100000']).code).toBe(3);
});
// Value: protects=credits ride every reading in admission mode too, so the ship/SKILL.md path reports both units; fails_when=check or settle refuse --credits, or the BUDGET line omits credits once a reading exists, or prints it before any;
// why_new=credits are new; seam=none; no_claim=credits-to-dollars conversion
test('admission mode takes --credits on start and check and prints credits only once one is read', () => {
const h = fresh();
const plain = start(h, ['--token-cap', '400M', '--reading', '1000000']);
expect(plain.r.out).not.toMatch(/credits=/);
const s = start(h, ['--token-cap', '400M', '--reading', '1000000', '--credits', '12.25']);
const c = check(h, s.run, '6', '7', ['--reading', '1100000', '--credits', '20']);
expect(c.code, c.out + c.err).toBe(0);
expect(h.field(c.out, 'BUDGET', 'credits')).toBe('7.75');
expect(h.sb(['note', '--run', s.run, '--step', '7', '--reading', '1200000', '--credits', 'x']).code).toBe(2);
});
});
+130
View File
@@ -0,0 +1,130 @@
/**
* GSTA-167 #3633: on Capy the budget meter is the agent's own jam counter.
* `tools.thread.get({ thread_id: "this" })` is the ROOT thread, so a lane
* subagent's budget counted every sibling tree under the root (on #3633 the
* root moved 37.3M while the lane's own counter moved 22.9M). A jam's own
* counter includes its own subagents (jam_01M658JBRMTMQ1WQWYT6WFD22W,
* 2026-10-30: +10.92M across a 6-reader dispatch with two ~0.35M parent calls
* between), so each run reads its own jam and a handoff child reads its own
* counter, starting below the parent's; reconcile charges the child's own delta.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { METER_CONTRACTS } from '../lib/ship-budget';
import { RESULT_CODES } from '../lib/result-codes';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const OWN = 'tools.thread.get({ thread_id: "<this agent\'s own jam id>" }).cost.tokens';
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
describe('the Capy meter is the agent\'s own jam counter', () => {
// Value: protects=a lane subagent's ledger meters its own jam (its subagents included), never the root thread's siblings; fails_when=the capy contract names thread_id "this" or a root scope;
// why_new=no test pinned the contract fields; seam=none; no_claim=that Capy's counter rolls up descendants (measured, not tested)
test('METER_CONTRACTS.capy reads the agent\'s own jam id with descendants, scope thread', () => {
expect(METER_CONTRACTS.capy).toMatchObject({ meter: 'capy', unit: 'capy-tokens', host_field: OWN, scope: 'thread', descendants: true });
expect(RESULT_CODES.BUDGET_READING_MISSING.fix).toContain("own jam id");
expect(RESULT_CODES.BUDGET_READING_MISSING.fix).not.toContain('thread_id: "this"');
});
// Value: protects=every place that tells the agent how to read the meter names its own jam, so prose and contract agree; fails_when=a section, the Capy skill, docs/ship-budget.md or the troubleshooting fix still reads thread_id "this";
// why_new=the contract test above reads only lib; seam=none; no_claim=the agent finds its jam id
test('the budget prose reads the agent\'s own jam counter, never thread_id "this"', () => {
for (const rel of ['ship/sections/budget-handoff.md.tmpl', 'capy/skill/gstack/SKILL.md', 'docs/ship-budget.md', 'docs/troubleshooting.md']) {
const text = read(rel);
expect(text, rel).not.toContain('thread_id: "this"');
expect(text, rel).toMatch(/thread_id: "<(?:this agent's|your) own jam id>"/);
}
const capy = read('capy/skill/gstack/SKILL.md');
expect(capy.slice(capy.indexOf('Token caps on /ship'), capy.indexOf('/ship reviewers on Capy'))).toMatch(/own jam id/);
});
// Value: protects=a handoff child meters its own counter, which starts far below the parent's, and the parent charges the child's own delta; fails_when=verify-handoff baselines the child at the parent's reading (its own lower reading is rejected) or reconcile charges the parent's counter delta;
// why_new=the chain tests feed child readings above the parent's, so they pass on a shared counter; seam=none; no_claim=the child passes its own counter
test('a child reads its own counter from below the parent\'s reading; reconcile charges its returned own-counter delta', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000']);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '8000000']);
expect(ho.code, ho.out + ho.err).toBe(0);
const brief = ho.out.match(/^HANDOFF: (\S+) /m)![1]!;
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const v = h.sb(['verify-handoff', '--in', brief, '--reading', '200000']);
expect(v.code, v.out + v.err).toBe(0);
expect(h.field(v.out, 'BUDGET', 'used')).toBe('0');
const child = h.field(v.out, 'BUDGET_RUN', 'id');
const c = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '1200000']);
expect(c.code, c.out + c.err).toBe(0);
expect(h.field(c.out, 'BUDGET', 'used')).toBe('1000000');
const ret = h.sb(['return', '--run', child, '--to', '11.5', '--reading', '3200000']);
expect(h.field(ret.out, 'BUDGET', 'used')).toBe('3000000');
const rec = h.sb(['reconcile', '--run', run, '--return', returnFile, '--reading', '11500000']);
expect(rec.code, rec.out + rec.err).toBe(0);
expect(rec.out).toContain('child=complete child_used=3000000');
expect(h.field(rec.out, 'BUDGET', 'used')).toBe('10500000');
});
// Value: protects=a Capy handoff child never needs its own jam id: the brief tells it to verify with no reading and never to borrow the parent's or root's counter, and the parent's own counter measures it; fails_when=the brief asks the child for its own jam counter again (replay 7e #36: every child failed verify-handoff with BUDGET_READING_MISSING, ~5M wasted, then metered off the root);
// why_new=replaces the own-counter brief test, whose contract replay 7e showed a subagent cannot meet; seam=none; no_claim=the child obeys
test('the handoff brief tells a Capy child to take no reading and says the parent charges it from its own counter', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000']);
const ho = h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '2000000']);
const text = fs.readFileSync(ho.out.match(/^HANDOFF: (\S+) /m)![1]!, 'utf8');
const start = text.slice(text.indexOf('## Start'), text.indexOf('## Read'));
expect(start).toContain('with no `--reading` on Capy: a subagent cannot read its own jam id');
expect(start).toMatch(/never pass the parent's or the root's counter/);
expect(start).not.toContain('<your own jam id>');
const budget = text.slice(text.indexOf('## Budget'), text.indexOf('## Return contract'));
expect(budget).toContain('On Capy pass no --reading or --credits: the parent charges child_used from its own counter around your work');
});
// Value: protects=a child's return settles its open step reservation, so no step reservation stays open in the child ledger after it hands back; fails_when=return leaves the last step's reservation open (replay 7e #36: return left the Step 17 reservation r8, 742,152 tokens, open);
// why_new=replay 7e; seam=none; no_claim=the settled amount's accuracy
test('return settles the child\'s open Step 17 reservation', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '1000000']);
const ho = h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '1500000']);
const v = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!, '--reading', '200000']);
expect(v.code, v.out + v.err).toBe(0);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
const c = h.sb(['check', '--run', child, '--step', '16', '--next', '17', '--reading', '900000']);
expect(c.code, c.out + c.err).toBe(0);
const id = h.field(c.out, 'RESERVATION', 'id');
const ret = h.sb(['return', '--run', child, '--to', '22', '--reading', '1600000']);
expect(ret.code, ret.out + ret.err).toBe(0);
const ledger = h.ledger(h.field(v.out, 'BUDGET_RUN', 'ledger'));
expect(ledger.reservations.find((r: any) => r.id === id).state).toBe('settled');
expect(ledger.reservations.filter((r: any) => r.state === 'open')).toEqual([]);
});
// Value: protects=a Capy child with no jam id of its own verifies, checks, returns and is reconciled from the parent's counter delta; fails_when=verify-handoff or a child check refuses with BUDGET_READING_MISSING, an unknown charge delegates the child onward, or reconcile charges the child's own BUDGET used=0 instead of the parent's delta;
// why_new=replay 7e #36 (handoffs/GSTA-167/replay7e-36.md); seam=none; no_claim=that the parent's counter excludes its own pings
test('a child handoff passes verify-handoff with no jam id of its own and the parent measures it', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '1000000']);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1500000']);
expect(ho.code, ho.out + ho.err).toBe(0);
const v = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]);
expect(v.code, v.out + v.err).toBe(0);
expect(v.out).toContain('HANDOFF_OK: steps=9-11.5');
expect(v.out).toMatch(/^METER: parent \(no counter of its own/m);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
const c = h.sb(['check', '--run', child, '--step', '9', '--next', '10']);
expect(c.code, c.out + c.err).toBe(0);
expect(c.err).not.toContain('BUDGET_READING_MISSING');
expect(h.field(c.out, 'BUDGET', 'verdict')).toBe('continue');
expect(h.sb(['check', '--run', child, '--step', '10', '--next', '11']).out).not.toContain('DELEGATE:');
const ret = h.sb(['return', '--run', child, '--to', '11.5']);
expect(ret.code, ret.out + ret.err).toBe(0);
expect(ret.out).toContain('METER: parent');
const rec = h.sb(['reconcile', '--run', run, '--return', ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, '--reading', '9500000']);
expect(rec.code, rec.out + rec.err).toBe(0);
expect(rec.out).toContain('child=complete child_used=8000000');
});
});
+16 -11
View File
@@ -39,7 +39,7 @@ const LEAN_DISPATCH = 500_000;
const STOP_26 = 1_650_000;
describe('projection calibrated from the run\'s own meter (GST-101)', () => {
test('GSTA-2 replay: an 8M cap admits Steps 6 and 6.5 after Steps 1-5 cost 0.27M each, and stops with room to stop', () => {
test('GSTA-2 replay: an 8M cap admits Steps 6, 6.5 and 7 after Steps 1-5 cost 0.27M each, and stops with room to stop', () => {
const h = fresh();
const cap = 8_000_000;
const s = started(h, '8M');
@@ -49,7 +49,7 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => {
let stop = '';
for (let i = 0; i + 1 < steps.length; i++) {
const calls = cal.step_calls[steps[i]!] ?? cal.step_calls.default!;
reading += Math.round((GSTA2_STEP / 3) * calls * (1 + 0.05 * i)) + (cal.step_dispatches[steps[i]!] ?? 0) * LEAN_DISPATCH;
reading += Math.round((GSTA2_STEP / 3) * calls * (1 + 0.05 * i)) + (['7', '8', '14.5'].includes(steps[i]!) ? (cal.segment_dispatches[steps[i]!] ?? 0) * LEAN_DISPATCH : 0);
expect(reading, `spend after Step ${steps[i]}`).toBeLessThanOrEqual(cap);
const c = h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]);
if (c.code === 3) { stop = c.out; break; }
@@ -57,7 +57,8 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => {
admitted.push(steps[i + 1]!);
if (steps[i + 1] === '6') expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBeLessThan(1_000_000);
}
expect(admitted).toEqual(['2', '3', '4', '5', '6', '6.5']);
// GSTA-167 round 3: Step 7's prior no longer double-counts its audit dispatch (reserved by its own check), so Step 7 fits too.
expect(admitted).toEqual(['2', '3', '4', '5', '6', '6.5', '7']);
expect(stop).toContain('STOP: reason=budget_exhausted');
expect(reading + GSTA2_STOP).toBeLessThanOrEqual(cap);
});
@@ -72,10 +73,11 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => {
reading += 750_000;
expect(h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]).code).toBe(0);
}
let previous = '';
let previous = '', at = 0;
const dispatch = (role: string) => {
reading += PARENT_CALL;
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading), ...(previous ? ['--returned', previous] : [])]);
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading), ...(previous ? ['--returned', previous, '--dispatched-at', String(at)] : [])]);
at = reading;
return c;
};
for (const [role, cost] of [...PASS2, ...PASS3]) {
@@ -87,7 +89,7 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => {
}
const lean = h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch' && r.actual !== undefined);
expect(lean.length).toBe(PASS2.length + PASS3.length - 1);
const settled = h.sb(['settle', '--run', s.run, '--reservation', previous, '--reading', String(reading)]);
const settled = h.sb(['settle', '--run', s.run, '--reservation', previous, '--dispatched-at', String(at), '--reading', String(reading)]);
expect(settled.out).toMatch(/^DRIFT: id=\S+ kind=dispatch next=dispatch:fix-check projected=\d+ actual=368801 ratio=\d+\.\d\d$/m);
reading = 17_790_000;
previous = '';
@@ -104,8 +106,9 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => {
for (let round = 0; round < 40 && !stop; round++) {
parent += 50_000;
reading += parent;
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:fix-check', '--reading', String(reading), '--returned', previous]);
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:fix-check', '--reading', String(reading), '--returned', previous, '--dispatched-at', String(at)]);
if (c.code === 3) { stop = c.out; break; }
at = reading;
previous = h.field(c.out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review-fix-check', '--reservation', previous]).code).toBe(0);
reading += 368_801;
@@ -149,7 +152,7 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
const a = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']);
const first = h.field(a.out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review', '--reservation', first]).code).toBe(0);
const b = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', first]);
const b = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', first, '--dispatched-at', '0']);
expect(b.code, b.out + b.err).toBe(0);
expect(h.field(b.out, 'BUDGET', 'reserved')).toBe('0');
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === first)).toMatchObject({ state: 'settled', actual: 400_000 });
@@ -158,7 +161,7 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
expect(bad.err).toContain('--returned');
});
test('--finish settles every consumed dispatch reservation of the pass, and the next reading measures them as one group', () => {
test('--finish settles every consumed dispatch reservation of the pass; settled without a reading, they stay unmeasured (GSTA-167)', () => {
const h = fresh();
const s = started(h, '80M');
const tokens: string[] = [];
@@ -175,7 +178,8 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
expect(dispatches().map((r: any) => r.state)).toEqual(['settled', 'settled', 'settled']);
const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', '10', '--reading', String(reading)]);
expect(h.field(next.out, 'BUDGET', 'reserved')).toBe('0');
expect(dispatches().map((r: any) => r.actual)).toEqual([500_000, 500_000, 500_000]);
expect(dispatches().map((r: any) => [r.actual, r.unmeasured])).toEqual([[undefined, true], [undefined, true], [undefined, true]]);
expect(next.out.split('\n').filter(l => l.startsWith('UNMEASURED:') && l.includes('kind=dispatch'))).toHaveLength(3);
});
test('moving to the next step settles dispatch reservations a reviewer consumed in the step before', () => {
@@ -185,7 +189,8 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
expect(h.reviewLog(['--start', 'adversarial-review', '--reservation', id]).code).toBe(0);
const c = h.sb(['check', '--run', s.run, '--step', '11', '--next', '11.5', '--reading', '600000']);
expect(c.code).toBe(0);
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', actual: 600_000 });
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', unmeasured: true });
expect(c.out).toContain(`UNMEASURED: id=${id} kind=dispatch next=dispatch:outside-voice reason=settled_without_reading`);
});
test('close prints projected against actual for the run', () => {
+285
View File
@@ -0,0 +1,285 @@
/**
* GSTA-167 PR 3a: dispatch drift is measured per reader and only from the
* dispatch program's reading right before its readers started (--dispatched-at)
* to the one it was settled with after they returned, never across the parent's
* own work; the parent's interleaved calls stay in the step's sample. The per-call
* charge takes a prior reading from the previous call only. Replays
* gstack-evals #36 (a six-reader batch taught a 2.041 factor and priced the Red
* Team at 15.17M, a false STOP with 14.4M of sub-cap unused) and #34 (the Red
* Team's reservation interval covered the child's own review and QA, and a
* prior reading six calls old fired CONTEXT_HIGH at call=1068386); evidence in
* handoffs/GSTA-167/replay-36.md and replay-34.md in the GStack Minder drive.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { loadCalibration, unitConstants } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const cal = loadCalibration();
const K = unitConstants(cal, 'capy-tokens');
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
/** Opus medium readers on #34/#36 cost about 1.7M each (replay-36: ~1.69M per specialist). */
const READER = 1_690_000;
const SIX = Array(6).fill('specialist');
const roundUp = (n: number) => Math.ceil(n / cal.round_to) * cal.round_to;
/** The per-reader price after a measured batch: the prior times actual/(prior x readers) times the headroom. */
const priced = (actual: number, readers: number) => roundUp(K.per_dispatch * ((actual / (K.per_dispatch * readers)) * cal.headroom));
function start(h: Harness, cap: number, reading = 0) {
const r = h.sb(['start', '--record', h.record(), '--token-cap', String(cap), '--reading', String(reading), '--reserve', '0']);
expect(r.code, r.out + r.err).toBe(0);
return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') };
}
function ok(h: Harness, args: string[]) {
const r = h.sb(args);
expect(r.code, r.out + r.err).toBe(0);
return r;
}
const check = (h: Harness, run: string, step: string, next: string, reading: number, extra: string[] = []) =>
ok(h, ['check', '--run', run, '--step', step, '--next', next, '--reading', String(reading), ...extra]);
describe('per-reader dispatch drift (#36)', () => {
test('#36 replay: six readers measured at their return price the Red Team per reader, so it is admitted instead of a 15.17M STOP', () => {
const h = fresh();
const cap = 35_244_164;
const s = start(h, cap);
let reading = 10_710_000;
const first = check(h, s.run, '9', 'dispatch:' + SIX.join(','), reading);
expect(first.out).toMatch(/^ADMIT: 2 of 6 /m);
const r1 = h.field(first.out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review', '--reservation', r1]).code).toBe(0);
let at = reading;
reading += 2 * READER;
const rest = check(h, s.run, '9', 'dispatch:' + SIX.slice(2).join(','), reading, ['--returned', r1, '--dispatched-at', String(at)]);
expect(rest.out).toMatch(new RegExp(`^DRIFT: id=${r1} kind=dispatch next=dispatch:specialist,specialist projected=${2 * K.per_dispatch} actual=${2 * READER} ratio=0\\.23$`, 'm'));
expect(rest.out).not.toContain('ADMIT:');
expect(Number(h.field(rest.out, 'BUDGET', 'projected'))).toBe(4 * priced(2 * READER, 2));
const r2 = h.field(rest.out, 'RESERVATION', 'id');
at = reading;
reading += 4 * READER;
const red = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', r2, '--dispatched-at', String(at)]);
expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue');
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(priced(2 * READER, 2));
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBeLessThan(3_000_000);
expect(red.out).not.toContain('STOP:');
});
test('a measured role prices its next reader at the larger of the per-reader projection and its per-reader mean', () => {
const h = fresh();
const s = start(h, 200_000_000);
const a = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id');
const b = check(h, s.run, '9', 'dispatch:red-team', 6 * READER, ['--returned', a, '--dispatched-at', '0']);
const r = h.ledger(s.ledger).reservations.find((x: any) => x.id === a);
expect(r.actual).toBe(6 * READER);
expect(Number(h.field(b.out, 'BUDGET', 'projected'))).toBe(priced(6 * READER, 6));
const red = h.field(b.out, 'RESERVATION', 'id');
const heavy = 3_910_000;
const c = check(h, s.run, '9', 'dispatch:red-team,specialist', 6 * READER + heavy, ['--returned', red, '--dispatched-at', String(6 * READER)]);
const factor = Math.max(READER / K.per_dispatch, heavy / K.per_dispatch) * cal.headroom;
expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(Math.max(roundUp(K.per_dispatch * factor), heavy) + Math.max(roundUp(K.per_dispatch * factor), READER));
});
});
describe('a dispatch is measured to its return, never across the parent\'s own work (#34)', () => {
test('#34 replay: a Red Team settled by the step change after the child\'s review and QA is UNMEASURED, so the voices keep the per-reader factor', () => {
const h = fresh();
const s = start(h, 200_000_000);
check(h, s.run, '8', '9', 3_500_000, ['--prior-reading', '3400000']);
const specialists = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 4_000_000).out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review', '--reservation', specialists]).code).toBe(0);
let reading = 4_000_000 + 6 * READER;
const redCheck = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', specialists, '--dispatched-at', '4000000']);
const red = h.field(redCheck.out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0);
reading += READER + 3_000_000;
const step = check(h, s.run, '9', '10', reading);
expect(step.out).toMatch(new RegExp(`^UNMEASURED: id=${red} kind=dispatch next=dispatch:red-team reason=settled_without_reading$`, 'm'));
expect(step.out).not.toMatch(new RegExp(`^DRIFT: id=${red} `, 'm'));
expect(step.out).toMatch(/^UNMEASURED: id=\S+ kind=step next=9 reason=dispatch_unmeasured$/m);
expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === red)).toMatchObject({ state: 'settled', unmeasured: true });
check(h, s.run, '10', '11', reading + 400_000);
const voices = check(h, s.run, '11', 'dispatch:adversarial-native,outside-voice', reading + 800_000);
expect(Number(h.field(voices.out, 'BUDGET', 'projected'))).toBe(2 * priced(6 * READER, 6));
});
test('settled with a reading at its return, the Red Team measures only its own interval; the parent\'s QA after it lands in Step 9\'s sample', () => {
const h = fresh();
const s = start(h, 200_000_000);
check(h, s.run, '8', '9', 3_500_000, ['--prior-reading', '3400000']);
const red = h.field(check(h, s.run, '9', 'dispatch:red-team', 4_000_000).out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0);
const settled = ok(h, ['settle', '--run', s.run, '--reservation', red, '--dispatched-at', '4000000', '--reading', String(4_000_000 + READER)]);
expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${red} kind=dispatch next=dispatch:red-team projected=${K.per_dispatch} actual=${READER} ratio=0\\.23$`, 'm'));
const step = check(h, s.run, '9', '10', 4_000_000 + READER + 3_000_000);
expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=9 projected=\d+ actual=3500000 ratio=\d+\.\d\d$/m);
expect(step.out).not.toContain('UNMEASURED:');
});
test('--finish settles the pass without a reading, so its dispatches are UNMEASURED and the factor stays at the prior', () => {
const h = fresh();
const s = start(h, 200_000_000);
const id = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id');
const token = h.reviewLog(['--start', 'review', '--reservation', id]).out.trim();
const row = JSON.stringify({ skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 });
expect(h.reviewLog([row, '--finish', token]).code).toBe(0);
const next = check(h, s.run, '9', 'dispatch:red-team', 6 * READER + 5_000_000);
expect(next.out).toMatch(new RegExp(`^UNMEASURED: id=${id} kind=dispatch `, 'm'));
expect(h.field(next.out, 'BUDGET', 'calibration')).toEndWith('dispatch:prior');
expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(K.per_dispatch);
});
});
/**
* GSTA-167 PR 3, replay 2 on gstack-evals #36 (handoffs/GSTA-167/replay2-36.md): the
* batched reader's reservation r5 was settled only at the next --returned check
* (17.72M -> 27.01M), so its interval held the child's own core review, QA and
* build, and the Red Team was priced at 7.43M x 1.875 = 13.94M: a false STOP with
* 14.8M of sub-cap unused. Only the dispatch program's own pair (read right before
* the readers start, again after they all settle) measures now.
*/
describe("only the dispatch program's reading pair measures readers (replay 2, #36)", () => {
const R5 = { cap: 34_044_378, spent: 19_258_962, reserved: 17_720_000, settled: 27_010_000 };
const childStart = (h: Harness) => start(h, R5.cap, R5.settled - R5.spent);
test('r5 replay: settled at the next --returned check with no dispatch reading, r5 is UNMEASURED and the Red Team keeps the prior, not 13.94M', () => {
const h = fresh();
const s = childStart(h);
const r5 = h.field(check(h, s.run, '9', 'dispatch:specialist', R5.reserved).out, 'RESERVATION', 'id');
expect(h.reviewLog(['--start', 'review', '--reservation', r5]).code).toBe(0);
const red = check(h, s.run, '9', 'dispatch:red-team', R5.settled, ['--returned', r5]);
expect(red.out).toMatch(new RegExp(`^UNMEASURED: id=${r5} kind=dispatch next=dispatch:specialist reason=no_dispatch_reading$`, 'm'));
expect(red.out).not.toMatch(/^DRIFT: id=\S+ kind=dispatch /m);
expect(h.field(red.out, 'BUDGET', 'used')).toBe(String(R5.spent));
expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue');
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(K.per_dispatch);
expect(red.out).not.toContain('STOP:');
});
test("the dispatch program's pair measures only the readers and prices the next reader from that measurement", () => {
const h = fresh();
const s = childStart(h);
const r5 = h.field(check(h, s.run, '9', 'dispatch:specialist', R5.reserved).out, 'RESERVATION', 'id');
const before = R5.settled - 1_510_000;
const settled = ok(h, ['settle', '--run', s.run, '--reservation', r5, '--dispatched-at', String(before), '--reading', String(R5.settled), '--readers', '1']);
expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${r5} kind=dispatch next=dispatch:specialist projected=${K.per_dispatch} actual=1510000 `, 'm'));
const red = check(h, s.run, '9', 'dispatch:red-team', R5.settled);
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(priced(1_510_000, 1));
expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue');
});
test('the learned factor never prices a reader above the larger of the prior and the clean per-reader mean', () => {
const h = fresh();
const s = start(h, 400_000_000);
const id = h.field(check(h, s.run, '9', 'dispatch:specialist', 1_000_000).out, 'RESERVATION', 'id');
const actual = 2 * K.per_dispatch;
ok(h, ['settle', '--run', s.run, '--reservation', id, '--dispatched-at', '1000000', '--reading', String(1_000_000 + actual), '--readers', '1']);
const red = check(h, s.run, '9', 'dispatch:red-team', 1_000_000 + actual);
expect(priced(actual, 1)).toBeGreaterThan(actual);
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(actual);
});
test('a dispatch reading older than another command\'s reading is UNMEASURED (a later program), and one above the settle reading is refused', () => {
const h = fresh();
const s = childStart(h);
const r5 = h.field(check(h, s.run, '9', 'dispatch:specialist', R5.reserved).out, 'RESERVATION', 'id');
const late = ok(h, ['settle', '--run', s.run, '--reservation', r5, '--dispatched-at', String(R5.reserved - 1), '--reading', String(R5.settled)]);
expect(late.out).toMatch(new RegExp(`^UNMEASURED: id=${r5} kind=dispatch next=dispatch:specialist reason=reading_between$`, 'm'));
const r6 = h.field(check(h, s.run, '9', 'dispatch:red-team', R5.settled).out, 'RESERVATION', 'id');
const bad = h.sb(['settle', '--run', s.run, '--reservation', r6, '--dispatched-at', String(R5.settled + 2), '--reading', String(R5.settled + 1)]);
expect(bad.code).toBe(2);
expect(bad.err).toContain('BUDGET_READING_REJECTED');
expect(h.sb(['settle', '--run', s.run, '--reservation', r6, '--readers', '2', '--reading', String(R5.settled + 1)]).code).toBe(2);
});
test('--readers splits the pair\'s interval per reader for the role mean', () => {
const h = fresh();
const s = start(h, 200_000_000);
const id = h.field(check(h, s.run, '9', 'dispatch:specialist,specialist', 1_000_000).out, 'RESERVATION', 'id');
ok(h, ['settle', '--run', s.run, '--reservation', id, '--dispatched-at', '1000000', '--reading', '4000000', '--readers', '2']);
expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === id)).toMatchObject({ actual: 3_000_000, readers: 2, dispatched_at: 1_000_000 });
const next = check(h, s.run, '9', 'dispatch:specialist', 4_000_000);
expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(priced(3_000_000, 2));
});
test('--next takes plain roles only: a role@bytes item is refused', () => {
const h = fresh();
const s = start(h, 200_000_000);
const r = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist@40000', '--reading', '0']);
expect(r.code).toBe(2);
expect(r.err).toContain('names unknown roles');
});
});
describe('the per-call charge takes a prior reading from the previous call only (#34)', () => {
test('a prior reading older than the run\'s last reading is ignored: call=unknown, never a six-call span', () => {
const h = fresh();
const s = start(h, 60_000_000, 1_000_000);
check(h, s.run, '4', '5', 8_900_000);
const stale = check(h, s.run, '5', '6', 9_162_515, ['--prior-reading', '8094129']);
expect(h.field(stale.out, 'BUDGET', 'call')).toBe('unknown');
expect(stale.out).toMatch(/^CALL_UNKNOWN: --prior-reading 8094129 is older than the run's last reading 8900000; /m);
expect(stale.out).not.toContain('1068386');
const fine = check(h, s.run, '6', '6.5', 9_450_000, ['--prior-reading', '9162515']);
expect(h.field(fine.out, 'BUDGET', 'call')).toBe('287485');
expect(fine.out).not.toContain('CALL_UNKNOWN');
});
test('#34 replay: a child passing the parent\'s handoff reading as its prior gets call=unknown, not 1068386', () => {
const h = fresh();
const s = start(h, 400_000_000, 2_110_477);
const ho = ok(h, ['handoff', '--run', s.run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '8094129']);
const brief = ho.out.match(/^HANDOFF: (\S+) /m)![1]!;
const v = ok(h, ['verify-handoff', '--in', brief, '--reading', '8800000']);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
const c = check(h, child, '6.5', '7', 9_162_515, ['--prior-reading', '8094129']);
expect(h.field(c.out, 'BUDGET', 'call')).toBe('unknown');
expect(c.out).not.toContain('call=1068386');
});
});
describe('the budget section states both contracts', () => {
const tmpl = fs.readFileSync(path.join(import.meta.dir, '..', 'ship', 'sections', 'budget-handoff.md.tmpl'), 'utf8');
test('the meter read happens inside the same program as the gstack-ship-budget command, never as its own call', () => {
expect(tmpl).not.toContain('pass the earlier value');
expect(tmpl).toMatch(/same (?:execute )?program as the\s+(?:`)?gstack-ship-budget(?:`)? command, never (?:as )?its own call/);
expect(tmpl).toMatch(/--prior-reading[\s\S]{0,200}previous call/);
expect(tmpl).toMatch(/older than the run's last reading is ignored/);
});
// Value: protects=no gstack prose tells the agent to read the meter in a call of its own (each such call costs a full context, about 0.2M on #34/#36); fails_when=capy SKILL.md keeps "one execute program returning only that number" or the section asks for a meter-only call;
// why_new=the template test above never read the Capy skill; seam=none; no_claim=how often an agent obeys the prose
test('the Capy skill reads the meter inside the budget command\'s program and names no meter-only call', () => {
const capy = fs.readFileSync(path.join(import.meta.dir, '..', 'capy', 'skill', 'gstack', 'SKILL.md'), 'utf8');
const caps = capy.slice(capy.indexOf('Token caps on /ship'), capy.indexOf('/ship on Capy is about a dozen calls'));
expect(caps.length).toBeGreaterThan(0);
expect(caps).not.toContain('returning only that number');
expect(caps).toMatch(/inside the same (?:execute )?program as the\s+`gstack-ship-budget` command/);
for (const text of [capy, tmpl]) expect(text).not.toMatch(/read it again in your next call|meter-only call(?!s? (?:is|are) never)/);
});
// Value: protects=each review pass (and Step 11, and the Step 14.5 audit) is one execute program in a fixed order, so the readers' reading pair brackets only them and no plan, start, poll or later batch costs its own parent call, and each reviewer's brief is copied from its section; fails_when=the order breaks (a start after the readers are created, a note before the poll, the plan after the dispatch) or the Capy paragraph goes back to one program per batch or stops copying the section's brief;
// why_new=no test reads the pass order; seam=none; no_claim=the model's adherence (GSTA-167 replay 5: arm D walked Steps 9-9.4 in 27 calls)
test('a review pass is one execute program: plan, review-log --start, BEFORE, create, poll, note with the pair, collect (Capy, observe mode); the admission-mode section keeps check and settle', () => {
const capy = fs.readFileSync(path.join(import.meta.dir, '..', 'capy', 'skill', 'gstack', 'SKILL.md'), 'utf8');
const pass = capy.slice(capy.indexOf('/ship on Capy is about a dozen calls'), capy.indexOf('## Plan, approve, ship'));
expect(pass).toMatch(/A review pass, Step 11 and the Step 14\.5 audit each run inside one program/);
expect(pass).not.toMatch(/one execute program per batch/);
expect(pass).toContain('its brief copied verbatim from the section that dispatches it');
expect(pass).toContain('never walks the dispatch steps call by call');
const order = ['gstack-review-plan --base <base>', 'gstack-review-log --start', '`BEFORE`', 'tools.subagent.create', 'tools.subagent.get', 'gstack-ship-budget note', '--dispatched-at <BEFORE>', 'return only each reader\'s final output'];
const block = tmpl.slice(tmpl.indexOf('**One program per review pass.**'));
const tmplOrder = ['gstack-review-plan', 'check --step <n> --next dispatch:<roles>', 'gstack-review-log --start', 'BEFORE', 'AFTER', 'gstack-ship-budget settle', 'the collect'];
for (const [text, want] of [[pass, order], [block, tmplOrder]] as const) {
const at = want.map(o => text.indexOf(o));
expect(at.every(i => i >= 0), want.filter((_, k) => at[k]! < 0).join(',')).toBe(true);
expect([...at].sort((a, b) => a - b)).toEqual(at);
}
});
test('a batch names one role per reader and is settled with the dispatch program\'s own reading pair', () => {
expect(tmpl).toMatch(/one role per (?:reader|`DISPATCH:` line)/);
expect(tmpl).toMatch(/Settle each batch in the program that dispatched it[\s\S]{0,300}--dispatched-at <before> --reading <after> --readers <n>/);
expect(tmpl).toMatch(/Only that pair is measured: `check --returned`, a settle from a later\s+program \(no `--dispatched-at`\), `--finish` or a step change settles without measuring/);
});
});
+144 -1
View File
@@ -73,7 +73,7 @@ describe('gstack-review-log --start under a token cap', () => {
test('a reused reservation refuses; the first use consumes it and --finish settles it', () => {
const h = fresh();
const run = capped(h);
const id = reserve(h, run.run);
const id = reserve(h, run.run, 'dispatch:specialist');
const ok = h.reviewLog(['--start', 'review', '--reservation', id]);
expect(ok.code).toBe(0);
const token = ok.out.trim();
@@ -90,6 +90,119 @@ describe('gstack-review-log --start under a token cap', () => {
expect(h.sb(['close', '--run', run.run, '--reading', '10']).code).toBe(0);
});
// GSTA-167 #34 replay: Step 11 reserved adversarial-native and outside-voice as one batch, and the second
// voice's --start refused (one-shot), so it ran unreserved and close stopped with budget_check_skipped.
test('a multi-role reservation admits one start per role, refuses the extra, and close finds nothing unreserved', () => {
const h = fresh();
const run = capped(h);
const id = reserve(h, run.run, 'dispatch:adversarial-native,outside-voice');
const tokens = [1, 2].map(() => {
const r = h.reviewLog(['--start', 'adversarial-review', '--reservation', id]);
expect(r.code, r.err).toBe(0);
return r.out.trim();
});
expect(new Set(tokens).size).toBe(2);
const extra = h.reviewLog(['--start', 'adversarial-review', '--reservation', id]);
expect(extra.code).toBe(1);
expect(extra.err).toContain('one start per role: 2 of 2 used');
expect(h.ledger(run.ledger).reservations.find((r: any) => r.id === id).starts.map((s: any) => s.token)).toEqual(tokens);
for (const token of tokens) {
const row = { skill: 'adversarial-review', timestamp: new Date().toISOString(), status: 'clean', source: 'in-host' };
expect(h.reviewLog([JSON.stringify(row), '--finish', token]).code).toBe(0);
}
expect(h.ledger(run.ledger).reservations.find((r: any) => r.id === id).state).toBe('settled');
const close = h.sb(['close', '--run', run.run, '--reading', '10']);
expect(close.out).not.toContain('BUDGET_CHECK_SKIPPED');
expect(close.out).not.toContain('reason=budget_check_skipped');
expect(close.code, close.out).toBe(0);
});
// Value: protects=--finish settles a later Red Team batch even after the pass program already settled the first batch, which is the documented order (the outside voice on #3633 pass 2: the started match required the first batch to stay consumed, so the fallback never ran); fails_when=settleReviewFinish only matches a still-consumed start reservation;
// why_new=the test above finishes before any settle; seam=none; no_claim=that the Red Team program's own settle ran
test('--finish still settles the open Red Team batch after the pass program settled the first batch', () => {
const h = fresh();
const run = capped(h);
const first = reserve(h, run.run, 'dispatch:specialist');
const start = h.reviewLog(['--start', 'review', '--reservation', first]);
expect(start.code, start.err).toBe(0);
const settled = h.sb(['settle', '--run', run.run, '--reservation', first, '--dispatched-at', '0', '--reading', '5', '--readers', '1']);
expect(settled.code, settled.out).toBe(0);
const redTeam = h.field(h.sb(['check', '--run', run.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', '5']).out, 'RESERVATION', 'id');
const row = { skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 };
const fin = h.reviewLog([JSON.stringify(row), '--finish', start.out.trim()]);
expect(fin.code, fin.err).toBe(0);
expect(fin.err).toContain(`settled budget reservations ${redTeam}`);
expect(h.ledger(run.ledger).reservations.find((r: any) => r.id === redTeam).state).toBe('settled');
expect(h.sb(['close', '--run', run.run, '--reading', '10']).out).toContain('reserved=0');
});
// Value: protects=--finish settles only its own pass: an open dispatch reserved before the start in the same step, and an open dispatch of another step stay open (the batched specialist on #3633 pass 2: untested cells of the filter); fails_when=the index or step filter in settleReviewFinish is dropped;
// why_new=the tests above hold only the pass's own batches; seam=none; no_claim=the filters are the right pass boundary for design-review-lite
test('--finish leaves an earlier open batch of its step and another step\'s open batch open', () => {
const h = fresh();
const run = capped(h);
const earlier = reserve(h, run.run, 'dispatch:red-team');
const first = reserve(h, run.run, 'dispatch:specialist');
const start = h.reviewLog(['--start', 'review', '--reservation', first]);
expect(start.code, start.err).toBe(0);
const other = h.field(h.sb(['check', '--run', run.run, '--step', '11', '--next', 'dispatch:adversarial-native', '--reading', '0']).out, 'RESERVATION', 'id');
const state = (id: string) => h.ledger(run.ledger).reservations.find((r: any) => r.id === id)?.state;
expect([state(earlier), state(other)]).toEqual(['open', 'open']);
const row = { skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 };
expect(h.reviewLog([JSON.stringify(row), '--finish', start.out.trim()]).code).toBe(0);
expect([state(first), state(earlier), state(other)]).toEqual(['settled', 'open', 'open']);
});
test('--finish leaves a step reservation made after the start open', () => {
const h = fresh();
const run = capped(h);
const first = reserve(h, run.run, 'dispatch:specialist');
const start = h.reviewLog(['--start', 'review', '--reservation', first]);
expect(start.code, start.err).toBe(0);
const step = h.field(h.sb(['check', '--run', run.run, '--step', '9', '--next', '10', '--reading', '0']).out, 'RESERVATION', 'id');
const row = { skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 };
expect(h.reviewLog([JSON.stringify(row), '--finish', start.out.trim()]).code).toBe(0);
expect(h.ledger(run.ledger).reservations.find((r: any) => r.id === step)?.state).toBe('open');
});
// Value: protects=a later batch of the same Step 9 pass (the Red Team, built after collect) takes no start of its own and still settles when the pass finishes, so no start token is left unfinished and no reservation stays open (gstack-evals #34 round 3: an extra red-team --start review token was never finished); fails_when=--finish settles only reservations a start consumed, leaving the Red Team's open and counted as reserved at close;
// why_new=the --finish tests above start every batch; seam=none; no_claim=that the program's own settle ran
test('--finish REVIEW_START settles the pass\'s later Red Team batch that took no start; close finds nothing reserved or unreserved', () => {
const h = fresh();
const run = capped(h);
const first = reserve(h, run.run, 'dispatch:specialist');
const start = h.reviewLog(['--start', 'review', '--reservation', first]);
expect(start.code, start.err).toBe(0);
const redTeam = reserve(h, run.run, 'dispatch:red-team');
const earlier = h.ledger(run.ledger).reservations.find((r: any) => r.id === redTeam);
expect(earlier.state).toBe('open');
const row = { skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 };
const fin = h.reviewLog([JSON.stringify(row), '--finish', start.out.trim()]);
expect(fin.code, fin.err).toBe(0);
expect(fin.err).toContain(`settled budget reservations ${first},${redTeam}`);
expect(h.ledger(run.ledger).reservations.filter((r: any) => r.kind === 'dispatch').map((r: any) => r.state)).toEqual(['settled', 'settled']);
const close = h.sb(['close', '--run', run.run, '--reading', '10']);
expect(close.out).toContain('reserved=0');
expect(close.out).not.toContain('BUDGET_CHECK_SKIPPED');
expect(close.code, close.out).toBe(0);
});
// Value: protects=the reservation id is printed alone on its own line, so a child copies it without parsing the RESERVATION: line (gstack-evals #36 round 3: a misparsed id gave UNMEASURED, then BUDGET_CHECK_SKIPPED at close); fails_when=check prints no RESERVATION_ID: line, or it differs from RESERVATION: id=;
// why_new=every test reads the id out of the RESERVATION: line; seam=none; no_claim=that the agent copies the right line
test('check prints RESERVATION_ID: <id> alone beside the RESERVATION: line, for a dispatch and a step, and the id starts a review', () => {
const h = fresh();
const run = capped(h);
const dispatch = h.sb(['check', '--run', run.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']);
const step = h.sb(['check', '--run', run.run, '--step', '1', '--next', '2', '--reading', '0']);
for (const out of [dispatch.out, step.out]) {
const ids = out.split('\n').filter(l => l.startsWith('RESERVATION_ID:'));
expect(ids).toHaveLength(1);
expect(ids[0]).toBe(`RESERVATION_ID: ${h.field(out, 'RESERVATION', 'id')}`);
}
const id = /^RESERVATION_ID: (\S+)$/m.exec(dispatch.out)![1]!;
expect(h.reviewLog(['--start', 'review', '--reservation', id]).code).toBe(0);
});
test('close prints one BUDGET_CHECK_SKIPPED line per unreserved dispatch and ends incomplete', () => {
const h = fresh();
const run = capped(h);
@@ -108,6 +221,36 @@ describe('gstack-review-log --start under a token cap', () => {
expect(accepted.out).toContain('ACCEPTED: 1 unreserved dispatches by Garry');
});
// Value: protects=a dispatch reserved and finished inside a handoff child is not flagged at the root's close (gstack-evals #34 round 3: close printed budget_check_skipped dispatches=3 for Steps 9 and 11, which ran in child 1); fails_when=close audits review-log rows against the root ledger's own reservations only;
// why_new=every close test above runs on one ledger; seam=none; no_claim=that the child passed the reservation (the start refuses without one)
test('close counts starts reserved in its handoff children; an unreserved row in the child window is still flagged', () => {
const h = fresh();
const root = capped(h, '200M');
const ho = h.sb(['handoff', '--run', root.run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1000000']);
expect(ho.code, ho.out + ho.err).toBe(0);
const v = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!, '--reading', '100000']);
expect(v.code, v.out + v.err).toBe(0);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
const id = h.field(h.sb(['check', '--run', child, '--step', '8', '--next', 'dispatch:specialist', '--reading', '200000']).out, 'RESERVATION', 'id');
const start = h.reviewLog(['--start', 'review', '--reservation', id]);
expect(start.code, start.err).toBe(0);
const row = { skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 };
expect(h.reviewLog([JSON.stringify(row), '--finish', start.out.trim()]).code).toBe(0);
expect(h.sb(['return', '--run', child, '--to', '11.5', '--reading', '300000']).code).toBe(0);
expect(h.sb(['reconcile', '--run', root.run, '--return', ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, '--reading', '1400000']).code).toBe(0);
const clean = h.sb(['close', '--run', root.run, '--reading', '1500000']);
expect(clean.out).not.toContain('BUDGET_CHECK_SKIPPED');
expect(clean.code, clean.out).toBe(0);
const h2 = fresh();
const root2 = capped(h2, '200M');
const ho2 = h2.sb(['handoff', '--run', root2.run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1000000']);
expect(h2.sb(['verify-handoff', '--in', ho2.out.match(/^HANDOFF: (\S+) /m)![1]!, '--reading', '100000']).code).toBe(0);
expect(h2.reviewLog([JSON.stringify({ ...row, timestamp: new Date(Date.now() + 1000).toISOString() })]).code).toBe(0);
const flagged = h2.sb(['close', '--run', root2.run, '--reading', '1500000']);
expect(flagged.code).toBe(3);
expect(flagged.out.split('\n').filter(l => l.endsWith('(BUDGET_CHECK_SKIPPED)'))).toHaveLength(1);
});
test('a stopped run refuses every later dispatch until resume', () => {
const h = fresh();
const run = capped(h, '20M');
+35
View File
@@ -35,6 +35,41 @@ describe('ship budget skill structure', () => {
for (const m of text.matchAll(/--next dispatch:([a-z,-]+)/g)) for (const role of m[1]!.split(',')) expect(DISPATCH_ROLES as readonly string[]).toContain(role);
});
// Value: protects=the budget section's dispatch program starts the review log once per logged row on the batch's reservation, and a later batch of the same pass (the Red Team, an ADMIT remainder) takes no start, so no start token is left unfinished; fails_when=the program starts once per role or tells every batch to start;
// why_new=main's tests only check that each review file mentions --reservation; seam=none; no_claim=the agent follows the order
test('the dispatch program starts once per logged row on its reservation, and a later batch of the pass takes no start', () => {
const handoff = read('ship/sections/budget-handoff.md.tmpl');
expect(handoff).toContain('`gstack-review-log --start <skill> --reservation <id>` once per\nrow the step logs');
expect(handoff).not.toContain('once per role on the printed reservation');
expect(handoff).toMatch(/later batch of the same pass \(the Red Team or an ADMIT remainder\) takes no start/);
});
// Value: protects=every concrete check command in the ship sections passes the counter, since on the Capy meter a check without --reading exits 2 and the dispatch it gates never starts (#3633 pass 2); fails_when=a section spells `gstack-ship-budget check --run` without `--reading`;
// why_new=no test reads the check commands; seam=none; no_claim=the agent fills the reading
test('every gstack-ship-budget check --run command in the ship sections passes --reading', () => {
for (const rel of fs.readdirSync(path.join(ROOT, 'ship/sections')).filter(n => n.endsWith('.md.tmpl'))) {
for (const cmd of read(`ship/sections/${rel}`).match(/gstack-ship-budget check --run [^`]*/g) ?? []) {
expect(cmd, rel).toContain('--reading');
}
}
});
// Value: protects=the prose that takes a reservation id reads the RESERVATION_ID: line, never a field cut from RESERVATION:; fails_when=the budget section or the Capy skill names the id without that line;
// why_new=the line is new; seam=none; no_claim=the agent copies it
test('the budget section and the Capy skill read the id from the RESERVATION_ID: line', () => {
for (const rel of ['ship/sections/budget-handoff.md.tmpl', 'capy/skill/gstack/SKILL.md']) {
expect(read(rel), rel).toContain('`RESERVATION_ID:`');
}
});
// Value: protects=the budget section's Capy meter reads tokens and credits in the program that runs the command, never spends a call only to read the meter, and points a driver run at observe mode; fails_when=the section asks for a per-call meter read again (budget-handoff.md:12-13 at b617445: 11 meter-only calls at 286k each on #34's child), drops --credits, or loses the observe pointer;
// why_new=variant G; seam=none; no_claim=the agent's adherence
test('the budget section reads credits beside tokens, never spends a call on the meter, and points driver runs at observe mode', () => {
const text = read('ship/sections/budget-handoff.md.tmpl').replace(/\s+/g, ' ');
for (const s of ['--reading <cost.tokens> --credits <cost.credits>', 'never spend a call only to read the meter', 'A `ship/DRIVER.md` run starts `--mode observe` instead: no check, reservation or handoff; each program ends with `note`']) expect(text).toContain(s);
expect(read('docs/ship-budget.md')).toContain('## Observe mode (ship/DRIVER.md)');
});
test('every review-log --start in the dispatch sections takes a reservation when a cap is set', () => {
for (const rel of ['ship/sections/review-army.md.tmpl', 'ship/sections/adversarial.md.tmpl']) expect(read(rel)).toContain('--reservation');
});
+58
View File
@@ -0,0 +1,58 @@
/**
* GSTA-167 PR 4: a small run keeps its segment instead of delegating it. A child
* costs a fixed 2-4.5M (boot, install check, verify-handoff, section reads,
* reconcile; gstack-evals #34), so a light-depth diff whose segment costs less
* than a child can save continues in the parent's context.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { CHILD_FIXED_COST, keepsSmallRun, loadCalibration, SMALL_RUN_LINES, SMALL_RUN_SEGMENT, smallRunSegment } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const cal = loadCalibration();
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
describe('small-run rule', () => {
// Value: protects=the threshold is the child's fixed cost over the share of a call a fresh child can save, so a segment under it never pays for a child; fails_when=the constants drift apart or the segment ignores this run's own per-call charge;
// why_new=no prior test prices a segment from the measured per-call charge; seam=none; no_claim=the #34 measurements themselves
test('the segment threshold is derived from the child cost; the segment is its calibrated calls at this run\'s per-call charge', () => {
expect(SMALL_RUN_SEGMENT).toBe(12_000_000);
expect(SMALL_RUN_SEGMENT * 0.375).toBe(CHILD_FIXED_COST);
const calls = ['7', '8', '9', '10', '11', '11.5'].reduce((n, s) => n + (cal.step_calls[s] ?? cal.step_calls.default!), 0);
expect(smallRunSegment(cal, '7', '11.5', 200_000)).toBe(calls * 200_000);
expect(smallRunSegment(cal, '7', '11.5', null)).toBe(calls * 250_000);
});
// Value: protects=only a light diff with a cheap segment keeps it, and a missing runtime count keeps today's delegation; fails_when=a large diff or a costly context stops delegating, or an unknown diff size is treated as small;
// why_new=keepsSmallRun is new; seam=none; no_claim=what the parent does after continuing
test('keeps only a light diff whose segment is under the threshold; an unknown runtime count never keeps', () => {
expect(keepsSmallRun(cal, { runtimeLines: 0, from: '7', to: '11.5', call: 220_000 })).toContain('SMALL_RUN: runtime_lines=0');
expect(keepsSmallRun(cal, { runtimeLines: SMALL_RUN_LINES, from: '12', to: '22', call: 300_000 })).not.toBeNull();
expect(keepsSmallRun(cal, { runtimeLines: SMALL_RUN_LINES + 1, from: '7', to: '11.5', call: 220_000 })).toBeNull();
expect(keepsSmallRun(cal, { runtimeLines: 0, from: '7', to: '11.5', call: 400_000 })).toBeNull();
expect(keepsSmallRun(cal, { runtimeLines: undefined, from: '7', to: '11.5', call: 220_000 })).toBeNull();
});
});
describe('check --runtime-lines', () => {
// Value: protects=the registered check command continues a small run at a segment boundary and still delegates a large one; fails_when=check ignores --runtime-lines or applies it to a large diff;
// why_new=the delegation tests never pass a diff size; seam=none; no_claim=the handoff itself
test('a light diff continues at the 6->7 boundary with a SMALL_RUN line; a large diff and an omitted count still delegate', () => {
const h = fresh();
const start = (cap: string) => {
const r = h.sb(['start', '--record', h.record(`r${cap}.md`), '--token-cap', cap, '--reading', '0', '--call-floor', '0']);
return h.field(r.out, 'BUDGET_RUN', 'id');
};
const small = h.sb(['check', '--run', start('80M'), '--step', '8', '--next', '9', '--reading', '1220000', '--prior-reading', '1000000', '--runtime-lines', '0']);
expect(small.code).toBe(0);
expect(h.field(small.out, 'BUDGET', 'verdict')).toBe('continue');
expect(small.out).toContain('SMALL_RUN: runtime_lines=0');
expect(small.out).not.toContain('DELEGATE:');
const large = h.sb(['check', '--run', start('81M'), '--step', '8', '--next', '9', '--reading', '1220000', '--prior-reading', '1000000', '--runtime-lines', '900']);
expect(h.field(large.out, 'BUDGET', 'verdict')).toBe('delegate');
const omitted = h.sb(['check', '--run', start('82M'), '--step', '8', '--next', '9', '--reading', '1220000', '--prior-reading', '1000000']);
expect(h.field(omitted.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(h.sb(['check', '--run', start('83M'), '--step', '8', '--next', '9', '--reading', '5', '--runtime-lines', 'many']).code).toBe(2);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* GSTA-167 round 3: step priors were far too high. Step 8 was projected at
* 16.43M (12 calls x the calibrated 750k per call + one 7.43M dispatch) and
* cost 0.34M on gstack-evals #36 (ledger ship-20261030T020847-c940ef), and that
* inflation is what blocks capped runs. On the Capy meter (the agent's own jam
* counter) a step is priced at its calibrated call count times the run's own
* median measured per-call charge (--prior-reading), once three calls are
* measured; its dispatches are reserved by their own check, so a step prior
* carries no dispatch.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { loadCalibration, stepProjection } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const cal = loadCalibration();
const stepCalls = (c: typeof cal, step: string) => c.step_calls[step] ?? c.step_calls.default ?? 0;
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
describe('step priors from the run\'s own per-call charge', () => {
// Value: protects=after three measured calls a step is priced at its calibrated calls x the run's median per-call charge, so #36's Step 8 no longer reserves 16.43M; fails_when=the step prior keeps the calibrated per_call (or a dispatch) after the run has measured its calls;
// why_new=no test fed per-call charges into a step projection; seam=none; no_claim=that the calibrated call counts fit every diff
test('#36 replay: measured calls of 300k, 340k, 360k and 340k price Step 8 at its calls x their 340k median', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '40M', '--reading', '1300000', '--prior-reading', '1000000']);
expect(s.code, s.out + s.err).toBe(0);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
expect(h.field(s.out, 'BUDGET', 'projected')).toBe(String(stepProjection(cal, 'capy-tokens', '1')));
const a = h.sb(['check', '--run', run, '--step', '3', '--next', '4', '--reading', '1640000', '--prior-reading', '1300000']);
expect(h.field(a.out, 'BUDGET', 'call')).toBe('340000');
const b = h.sb(['check', '--run', run, '--step', '6', '--next', '7', '--reading', '2000000', '--prior-reading', '1640000']);
expect(b.code, b.out + b.err).toBe(0);
const c = h.sb(['check', '--run', run, '--step', '7', '--next', '8', '--reading', '2440000', '--prior-reading', '2100000']);
expect(c.code, c.out + c.err).toBe(0);
expect(h.field(c.out, 'BUDGET', 'projected')).toBe(String(stepCalls(cal, '8') * 340_000));
expect(h.field(c.out, 'BUDGET', 'calibration')).toEndWith(',call:340000');
expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBeLessThan(5_000_000);
});
// Value: protects=a step prior holds only the step's own calls: the audits of Steps 7, 8 and 14.5 are reserved by their own dispatch check, and a child's segment estimate still counts them; fails_when=step_dispatches prices a dispatch into the step (double counted with its reservation) or the segment estimate drops it;
// why_new=no test separated the step prior from its dispatches; seam=none; no_claim=the audit always runs
test('a step prior carries no dispatch; the segment estimate keeps the audits', () => {
const k = cal.constants;
for (const step of ['7', '8', '14.5']) {
expect(stepProjection(cal, 'capy-tokens', step), step).toBe(stepCalls(cal, step) * k.per_call);
expect(cal.segment_dispatches[step] ?? 0, step).toBeGreaterThanOrEqual(1);
}
});
});
+10 -9
View File
@@ -92,12 +92,13 @@ describe('reservations', () => {
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']);
const id = h.field(c.out, 'RESERVATION', 'id');
expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(K.per_dispatch);
h.sb(['settle', '--run', s.run, '--reservation', id]);
const spent = 2 * K.per_dispatch;
h.sb(['settle', '--run', s.run, '--reservation', id, '--dispatched-at', '0', '--reading', String(spent)]);
const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(spent)]);
expect(Number(h.field(next.out, 'BUDGET', 'used'))).toBe(spent);
const projected = Number(h.field(next.out, 'BUDGET', 'projected'));
expect(projected).toBe(Math.ceil((K.per_dispatch * 2 * cal.headroom) / cal.round_to) * cal.round_to);
// The factor (2 x headroom) is bounded by the clean per-reader mean it was learned from (GSTA-167).
expect(projected).toBe(Math.min(Math.ceil((K.per_dispatch * 2 * cal.headroom) / cal.round_to) * cal.round_to, spent));
expect(next.code).toBe(spent + projected + K.stop_overhead <= cap ? 0 : 3);
const later = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(cap - K.stop_overhead - K.per_dispatch + 1)]);
expect(later.code).toBe(3);
@@ -188,11 +189,11 @@ describe('delegation', () => {
test('a segment boundary with an unknown per-call charge delegates; --delegate never stops instead', () => {
const h = fresh();
const a = started(h, ['--token-cap', '80M', '--reading', '0']);
const d = h.sb(['check', '--run', a.run, '--step', '6', '--next', '7', '--reading', '100']);
const d = h.sb(['check', '--run', a.run, '--step', '8', '--next', '9', '--reading', '100']);
expect(d.code).toBe(0);
expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(d.out).not.toContain('RESERVATION');
const small = h.sb(['check', '--run', a.run, '--step', '6', '--next', '7', '--reading', '1100', '--prior-reading', '1000']);
const small = h.sb(['check', '--run', a.run, '--step', '8', '--next', '9', '--reading', '1100', '--prior-reading', '1000']);
expect(h.field(small.out, 'BUDGET', 'verdict')).toBe('continue');
const n = started(h, ['--token-cap', '80M', '--reading', '0', '--delegate', 'never']);
const stop = h.sb(['check', '--run', n.run, '--step', '11.5', '--next', '12', '--reading', '100']);
@@ -203,9 +204,9 @@ describe('delegation', () => {
test('full chain: delegate, handoff, verify, child checks, 40-line return, idempotent reconcile, close', () => {
const h = fresh();
const s = started(h, ['--token-cap', '80M', '--reading', '1000000']);
const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '800000']);
const d = h.sb(['check', '--run', s.run, '--step', '8', '--next', '9', '--reading', '1200000', '--prior-reading', '800000']);
expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate');
const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
const ho = h.sb(['handoff', '--run', s.run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
expect(ho.code).toBe(0);
const brief = ho.out.match(/^HANDOFF: (\S+) sha256=([0-9a-f]{64}) sub_cap=(\d+)$/m)!;
expect(brief).toBeTruthy();
@@ -219,12 +220,12 @@ describe('delegation', () => {
expect(v.code).toBe(0);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
expect(h.field(v.out, 'BUDGET', 'admission_cap')).toBe(brief[3]);
expect(h.field(v.out, 'BUDGET', 'used')).toBe('100000');
expect(h.field(v.out, 'BUDGET', 'used')).toBe('0');
const cc = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '5000000']);
expect(cc.code).toBe(0);
const past = h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']);
expect(past.code).toBe(0);
expect(past.out).toMatch(/^CONTEXT_HIGH: .*Step 12 is past this child's Steps 7-11\.5/m);
expect(past.out).toMatch(/^CONTEXT_HIGH: .*Step 12 is past this child's Steps 9-11\.5/m);
const ret = h.sb(['return', '--run', child, '--to', '11.5', '--changed', 'Reviews,Checks', '--reading', '9000000']);
expect(ret.code).toBe(0);
expect(ret.out.trim().split('\n').length).toBeLessThanOrEqual(40);
@@ -232,7 +233,7 @@ describe('delegation', () => {
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const rec = h.sb(['reconcile', '--run', s.run, '--return', returnFile, '--reading', '9100000']);
expect(rec.code).toBe(0);
expect(rec.out).toContain('child=complete child_used=7800000');
expect(rec.out).toContain('child=complete child_used=7600000');
expect(h.field(rec.out, 'BUDGET', 'reserved')).toBe('0');
const again = h.sb(['reconcile', '--run', s.run, '--return', returnFile, '--reading', '9200000']);
expect(again.out).toContain('nothing charged twice');
+268
View File
@@ -0,0 +1,268 @@
/**
* GSTA-167 PR 5: ship/DRIVER.md is the compact, host-neutral entry a parent
* agent reads to run an unattended /ship instead of ship/SKILL.md (61 KB) plus
* its sections. Its contract, derived from the skeleton rather than a hand list:
* every step id ship/SKILL.md and its sections name, and every gate or result
* code the skeleton prints, appears in the driver or in a bin the driver invokes;
* every STOP anchor resolves to a real heading; the render stays at or under
* 12 KB, host-neutral and fresh against the generator.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { SHIP_STEPS } from '../lib/ship-budget';
import { RESULT_CODES } from '../lib/result-codes';
import { SHIP_DRIVER_STOPS } from '../scripts/resolvers/ship-driver';
import { runGeneration, SHIP_DRIVER_RELPATH } from '../scripts/gen-skill-docs';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const doc = read(SHIP_DRIVER_RELPATH);
const skill = read('ship/SKILL.md');
const body = skill.slice(skill.indexOf('\n# Ship: '));
const sectionFiles: string[] = JSON.parse(read('ship/sections/manifest.json')).sections
.filter((s: { runner_only?: boolean }) => !s.runner_only).map((s: { file: string }) => `ship/sections/${s.file}`);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-driver-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const STEP_ID = String.raw`\d+(?:\.\d+)*(?:-[A-Z])?`;
const headingSteps = (text: string) => [...text.matchAll(new RegExp(`^#{2,4} Step (${STEP_ID})[:\s]`, 'gm'))].map(m => m[1]!);
/** Step ids a text names: "Step 9.4", "Steps 9, 9.1 and 9.2"; a range ("Steps 1-3") expands over SHIP_STEPS only when asked. */
function stepsNamed(text: string, expandRanges: boolean): Set<string> {
const out = new Set<string>();
const sep = '(?:, and |, | and |–|-(?=\\d))';
for (const m of text.matchAll(new RegExp(`\\bSteps?\\s+(${STEP_ID}(?:${sep}${STEP_ID})*)`, 'g'))) {
const parts = m[1]!.split(/(, and |, | and |–|-(?=\d))/);
for (let i = 0; i < parts.length; i += 2) {
out.add(parts[i]!);
const range = parts[i + 1] === '–' || parts[i + 1] === '-';
if (!range || !expandRanges) continue;
const from = SHIP_STEPS.indexOf(parts[i] as any), to = SHIP_STEPS.indexOf(parts[i + 2] as any);
for (const s of SHIP_STEPS.slice(Math.max(0, from), to + 1)) out.add(s);
}
}
return out;
}
/** The bins the driver runs, each with the source of its bin file and the modules it imports (two levels). */
const invoked = [...new Set([...doc.matchAll(/\bgstack-[a-z0-9-]+/g)].map(m => m[0]))].filter(n => fs.existsSync(path.join(ROOT, 'bin', n)));
function binSource(name: string): string {
const seen = new Set<string>();
const texts: string[] = [];
const visit = (file: string, depth: number) => {
if (seen.has(file) || !fs.existsSync(file)) return;
seen.add(file);
const text = fs.readFileSync(file, 'utf8');
texts.push(text);
if (depth === 0) return;
for (const m of text.matchAll(/from '(\.{1,2}\/[^']+)'/g)) visit(path.resolve(path.dirname(file), m[1]!.endsWith('.ts') ? m[1]! : `${m[1]}.ts`), depth - 1);
};
visit(path.join(ROOT, 'bin', name), 2);
return texts.join('\n');
}
const bins = invoked.map(binSource).join('\n');
/** GitHub's heading anchors, plus explicit <a id> anchors. */
function anchors(text: string): Set<string> {
const out = new Set<string>();
for (const m of text.matchAll(/^#{1,6} (.+)$/gm)) out.add(m[1]!.toLowerCase().replace(/[^a-z0-9 _-]/g, '').replace(/ /g, '-'));
for (const m of text.matchAll(/<a id="([^"]+)"><\/a>/g)) out.add(m[1]!);
return out;
}
describe('ship/DRIVER.md covers the skeleton', () => {
test('every step id in ship/SKILL.md and its sections appears in the driver or in a bin the driver invokes', () => {
const skeleton = new Set([...SHIP_STEPS, ...headingSteps(skill), ...sectionFiles.flatMap(f => headingSteps(read(f)))]);
expect(skeleton.size).toBeGreaterThanOrEqual(SHIP_STEPS.length + 8);
const covered = new Set([...stepsNamed(doc, false), ...stepsNamed(bins, true)]);
expect([...skeleton].filter(s => !covered.has(s))).toEqual([]);
});
test('every gate and result code the skeleton prints appears in the driver or in a bin it invokes', () => {
const codes = new Set([
...[...body.matchAll(/`([A-Z][A-Z0-9_]+):/g)].map(m => `${m[1]}:`),
...[...body.matchAll(/^\s*- \*\*([A-Z][A-Z0-9_]+)\*\* →/gm)].map(m => m[1]!),
...[...body.matchAll(/^\| ([A-Z][A-Z-]+(?:\/[A-Z][A-Z-]+)*)(?::| \()/gm)].flatMap(m => m[1]!.split('/')),
...[...body.matchAll(/`[A-Z][A-Z0-9_]+: ([A-Z][A-Z0-9_]+)`/g)].map(m => m[1]!),
...Object.keys(RESULT_CODES).filter(k => new RegExp(`\\b${k}\\b`).test(body)),
]);
for (const code of ['STOP:', 'NEXT:', 'CARRY:', 'GSTACK_RESULT:', 'ALREADY_BUMPED', 'NO_VERSION', 'FRESH', 'ZERO-RUN', 'ALREADY_PUSHED', 'QUEUE_RECEIPT_STALE']) expect(codes).toContain(code);
expect([...codes].filter(c => !doc.includes(c) && !bins.includes(c))).toEqual([]);
});
test('every gate the skeleton holds keeps a driver line: tests, pregate, receipt, push, queue, the budget stop', () => {
for (const line of ['ZERO-RUN', 'gstack-ship-release finish', 'gstack-pregate clear', 'One re-audit at most', 'gstack-ship-receipt docs-caps',
'Step 14.5, every ship', 'never clean', 'gstack-ship-receipt write', '`SHIP_RECEIPT:`', 'gstack-ship-receipt result --pr <n>',
'gstack-ship-queue ready --pr <n>', 'Never force-push', '--mode observe', 'the one stop check']) {
expect(doc, line).toContain(line);
}
});
// Value: protects=a driven /ship sends every reviewer the brief ship/SKILL.md sends (GSTA-167 no-loss round 3: shortened reviewer context lost 9 of 19 blockers); fails_when=a review phase paraphrases its section instead of running it, or the driver copies a reviewer prompt line it could let drift;
// why_new=the coverage tests above check step ids and codes only; seam=none; no_claim=that the parent reads the section
test('the review phases run their sections as written and the driver restates no reviewer prompt', () => {
for (const [phase, sections] of [['## 3. Audits and review', ['test-coverage', 'plan-completion', 'review-army']], ['## 4. Judgment', ['adversarial']]] as const) {
const text = doc.slice(doc.indexOf(phase), doc.indexOf('\n## ', doc.indexOf(phase) + 1));
for (const sec of sections) expect(text, phase).toContain(`\`sections/${sec}.md\``);
expect(text, phase).toMatch(/as\s+(?:they are\s+)?written/);
}
expect(doc).toContain('read `sections/documentation.md` and run its audit as written');
expect(doc).toContain('never shortens a reviewer\'s brief');
const prompts = ['review-army', 'adversarial', 'test-coverage', 'plan-completion', 'documentation']
.flatMap(sec => [...read(`ship/sections/${sec}.md`).matchAll(/````?text\n([\s\S]*?)\n````?/g)].flatMap(m => m[1]!.split('\n')))
.map(l => l.trim()).filter(l => l.length >= 40);
expect(prompts.length).toBeGreaterThan(20);
expect(prompts.filter(l => doc.includes(l))).toEqual([]);
});
test('the driver says when to fall back to ship/SKILL.md and never reads sections up front', () => {
expect(doc).toContain('A STOP or decision this file does not cover: read `ship/SKILL.md` at that step');
expect(doc).toContain('never\nup front');
});
});
describe('one program per pass (GSTA-167 variant E)', () => {
const flat = (s: string) => s.replace(/\s+/g, ' ');
const phase = (h: string) => flat(doc.slice(doc.indexOf(h), doc.indexOf('\n## ', doc.indexOf(h) + 1)));
// Value: protects=a review pass, Step 11 and the Step 14.5 audit each run as one program in the fixed order plan, reserve and start, BEFORE, readers, poll, AFTER and settle, later batches, collect, so the parent spends no call inside a pass and the reading pair brackets only the readers; fails_when=a phase goes back to walking Steps 9-9.4 call by call (replay 5: arm D's 69 orchestrator calls in Steps 7-14.5 against round 4's 21), the order breaks, or a phase stops pointing at the shared program;
// why_new=the coverage tests above check step ids, codes and section names only; seam=none; no_claim=the parent's adherence or the call count it reaches
test('the shared program lists its acts in order and the review pass, Step 11 and Step 14.5 run through it', () => {
const block = flat(doc.slice(doc.indexOf('**One program per pass.**'), doc.indexOf('\n## ', doc.indexOf('**One program per pass.**'))));
expect(block).toContain('Each review pass, Step 11 and the Step 14.5 audit is one program, and the parent makes no other call while it runs');
const order = ['gstack-review-plan --base <base>', 'gstack-review-log --start <skill>` once per row the step logs', '(3) `BEFORE`',
'(4) one subagent per reader', '(5) a poll until every reader settles', '(6) `note` with the reading pair',
'a later batch (the Red Team', '(7) the collect: return only each reader\'s final output', 'it never walks the dispatch steps call by call'];
const at = order.map(o => block.indexOf(o));
expect(order.filter((_, k) => at[k]! < 0)).toEqual([]);
expect([...at].sort((a, b) => a - b)).toEqual(at);
expect(block).not.toContain('--reservation');
expect(phase('## 3. Audits and review')).toContain('and the pass (above)');
expect(phase('## 4. Judgment')).toContain('Step 11 from `sections/adversarial.md` as written (above)');
expect(phase('## 6. Notes and documentation')).toContain('read `sections/documentation.md` and run its audit as written, as one program (above)');
});
// Value: protects=the parent reads the diff and TODOS.md once, through gstack-ship-preflight --audit's files, and the audit sections only when their reader is needed; fails_when=a phase sends the parent back to TODOS.md (346 KB on gstack, read twice per run at b617445) or the full diff (read three times), or reads test-coverage.md and plan-completion.md unconditionally;
// why_new=variant G moves those reads into the Phase 2 program; seam=none; no_claim=the bytes a parent actually reads
test('Phase 2 runs --audit and the reads in its program, and no phase reads TODOS.md or the full diff into the parent', () => {
const p2 = phase('## 2. Preflight');
for (const s of ['gstack-ship-preflight --audit --base <base> --out <run dir>', 'writes the diff once (`CONTEXT:`)', '(`TODOS:`)', 'READER: coverage=<needed|none> plan=<needed|none>',
'only when its reader is `needed`', 'Never read TODOS.md or the full diff into the parent']) expect(p2).toContain(s);
expect(phase('## 4. Judgment')).toContain('on the todos and commits files');
expect(phase('## 6. Notes and documentation')).toContain('edited from the todos file');
});
});
describe('variant G: the call plan, observe budget, waits and audit readers (GSTA-167)', () => {
const flat = (s: string) => s.replace(/\s+/g, ' ');
// Value: protects=the driver is a fixed plan of one call per phase that covers every ship step, at 9 calls for one clean pass and at most 12; fails_when=a step loses its phase, a phase is split back into per-step calls, or the stated call count drifts from the phase count (GSTA-185 appendix: 195 literal / 70 batched calls at b617445);
// why_new=the coverage tests check step ids anywhere in the driver, not which call runs them; seam=none; no_claim=the model's real call count
test('each phase is one call, the phase headings cover every ship step, and the stated count is phases plus the report', () => {
const headings = [...doc.matchAll(/^## (\d+)\. (.+)$/gm)];
expect(headings.map(h => Number(h[1]))).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
const covered = new Set(headings.flatMap(h => [...stepsNamed(h[2]!, false)]));
expect(SHIP_STEPS.filter(s => !covered.has(s))).toEqual([]);
expect(doc).toContain('Each phase below is one call');
const calls = flat(doc.slice(doc.indexOf('**Calls.**'), doc.indexOf('\n\n', doc.indexOf('**Calls.**'))));
expect(calls).toContain(`One pass with no fixes is ${headings.length + 1} calls: Phases 1-${headings.length} and the report; at most 12`);
});
// Value: protects=the ledger records instead of admitting: one note at each program's end with tokens and credits, the reader pair inside the dispatching program, and the cap as the single stop; fails_when=the driver brings back a check per boundary, a reservation, a meter-only call or a handoff (replay 5: 11 meter-only calls at 286k on #34's child), or drops the credits reading;
// why_new=observe mode is new; seam=none; no_claim=the host's counter accuracy
test('the budget paragraph runs observe mode: note with --reading and --credits, no check per step, the cap as the one stop', () => {
const budget = flat(doc.slice(doc.indexOf('**Budget: observe.**'), doc.indexOf('\n\n', doc.indexOf('**Budget: observe.**'))));
for (const s of ['gstack-ship-budget start --mode observe', 'gstack-ship-budget note --step <last step> --reading R --credits C', '`cost.credits`',
'No check per step, no reservation, no meter-only call, no handoff', '--dispatched-at <R> --dispatched-credits <C> --readers <n>']) expect(budget).toContain(s);
expect(doc).not.toMatch(/check --step|--next dispatch:|--delegate measured/);
expect(doc).toContain('gstack-ship-budget close --reading R --credits C');
});
// Value: protects=no call waits past the 5-minute prompt cache: programs return within 240 s and the next call resumes the wait, with the choice of a ping over a waiting child stated where the parent and Capy read it; fails_when=a phase waits in one program until slow work ends (cache probe: a call after a 7-minute gap cost ~2,820 credits against ~70), the cadence goes back to 270 s (16 s from the cache lifetime), or the reason is dropped;
// why_new=wait hygiene is new; seam=none; no_claim=the host's cache lifetime
test('waits ping every 240 s in the driver and the Capy skill, and both say why a waiting child is not used', () => {
const waits = flat(doc.slice(doc.indexOf('**Waits.**'), doc.indexOf('\n\n', doc.indexOf('**Waits.**'))));
for (const s of ['No call waits past 240 s', 'the next call resumes', 'A small waiting child saves nothing', 'Past 100 minutes']) expect(waits).toContain(s);
const capy = read('capy/skill/gstack/SKILL.md');
for (const s of ['never let 5 minutes pass between calls', '`duration_seconds: 240`', 'A small waiting child saves nothing']) expect(capy).toContain(s);
for (const text of [doc, capy]) expect(text).not.toMatch(/\b270 ?s\b/);
});
// Value: protects=a reader counts as settled only once it has run (status done or failed, or a final summary), in the driver, the Capy skill and the budget section, so a poll never closes a batch on a subagent that was just created; fails_when=the settled rule is dropped or lets a bare idle status through (replay 7e #36: the Red Team reservation settled actual=0 because a just-created subagent read idle);
// why_new=replay 7e; seam=none; no_claim=the host's status semantics beyond done, failed, idle and summary
test('a poll settles a reader only at done, failed or a summary, never on a fresh idle', () => {
expect(flat(doc)).toContain('(5) a poll until every reader settles (status `done` or `failed`, or a final summary; a fresh `idle` has not run)');
expect(read('capy/skill/gstack/SKILL.md')).toContain('a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run)');
expect(flat(read('ship/sections/budget-handoff.md.tmpl'))).toContain('the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle` has not run)');
});
// Value: protects=audits that cannot block (Steps 7, 8, 9.2.1, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
// why_new=audit model routing is new; seam=none; no_claim=the audits' recall
test('audit readers run on a small model at low effort, named for Capy, and Phase 3 dispatches only what READER names', () => {
expect(flat(doc)).toContain('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');
expect(flat(doc)).toContain('`Coverage:` and Step 8\'s line come from `--audit` when no reader ran');
const capy = read('capy/skill/gstack/SKILL.md');
expect(capy).toContain('model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }');
expect(capy).toContain('`anthropic/claude-haiku-5-5` also fits');
});
// Value: protects=a driven /ship dispatches every blocking reader on its rendered brief file unchanged, checks it with `sent`, merges findings only through `collect`, gates judgment with `judge` and verifies the run before the bind; fails_when=the driver lets the model write or trim a brief again or merge findings in prose (round 4b: arm D lost 8 blockers with 75 of 112 briefs paraphrased);
// why_new=the driver named no renderer or hash check; seam=none; no_claim=the parent's adherence, which verify checks after the fact
test('briefs go out as rendered and findings come back unchanged: render, sent, collect, judge, verify', () => {
const f = flat(doc);
for (const s of ["A blocking reader's prompt is its `gstack-review-dispatch render` file, unchanged", 'only via `collect`', '`judge` refuses a drop or downgrade',
'(4) one subagent per reader on its rendered file, then `sent` (`BRIEF_MISMATCH` voids it)', 'final output and status, via `collect`', '`JUDGMENT: ok` before any fix',
'`gstack-review-dispatch verify` (`VERIFY: clean`)']) expect(f).toContain(s);
expect(f.indexOf('gstack-review-dispatch verify')).toBeLessThan(f.indexOf('`gstack-review-read`'));
});
});
describe('STOP anchors', () => {
test('every STOP row is rendered and its anchor names a heading that exists', () => {
for (const s of SHIP_DRIVER_STOPS) {
expect(doc).toContain(`- ${s.when}: \`${s.read}\``);
const [file, anchor] = s.read.split('#');
const text = read(path.join('ship', file!));
expect(anchors(text).has(anchor!), s.read).toBe(true);
}
});
test('the anchor reader matches GitHub on the headings the driver uses', () => {
expect(anchors('## Step 9.4: Fix-First and persistence\n## Step 14: TODOS.md (auto-update)\n## Step 9.1: Review Army — Specialist Dispatch'))
.toEqual(new Set(['step-94-fix-first-and-persistence', 'step-14-todosmd-auto-update', 'step-91-review-army--specialist-dispatch']));
});
});
describe('shape', () => {
test('at most 12 KB, no harness vocabulary, and every bin it names exists', () => {
expect(Buffer.byteLength(doc)).toBeLessThanOrEqual(12 * 1024);
for (const term of ['AskUserQuestion', 'Agent tool', 'PreToolUse', 'SessionStart', '~/.claude', 'run_in_background']) expect(doc).not.toContain(term);
const named = [...new Set([...doc.matchAll(/\bgstack-[a-z0-9-]+/g)].map(m => m[0]))];
expect(named.filter(n => !fs.existsSync(path.join(ROOT, 'bin', n)))).toEqual([]);
expect(invoked).toEqual(expect.arrayContaining(['gstack-ship-preflight', 'gstack-ship-release', 'gstack-ship-budget', 'gstack-review-plan', 'gstack-ship-receipt', 'gstack-ship-queue']));
});
test('the Capy skill points /ship at the driver with the autoplan line\'s shape', () => {
const capy = read('capy/skill/gstack/SKILL.md');
for (const s of ['autoplan', 'ship']) {
expect(capy).toContain(`For \`/${s}\` driven from Capy, read \`${s}/DRIVER.md\` in the checkout (\`~/.local/share/gstack/${s}/DRIVER.md\`)`);
expect(capy).toContain(`A checkout without that file predates it; follow its \`${s}/SKILL.md\` instead`);
}
expect(capy).toContain('--mode observe');
});
});
describe('generator inventory', () => {
test('the committed render names ship/DRIVER.md.tmpl as its source, and that template exists', () => {
const template = 'ship/DRIVER.md.tmpl';
expect(fs.existsSync(path.join(ROOT, template))).toBe(true);
expect(doc.split('\n')[0]).toBe(`<!-- AUTO-GENERATED from ${template} — do not edit directly -->`);
});
test('a fresh render carries ship/DRIVER.md as a claude asset, byte-identical to the committed file', async () => {
const written = await runGeneration({ host: 'claude', outputRoot: tmp, contentLinkRoot: null });
expect(written.artifacts).toContainEqual({ relativePath: SHIP_DRIVER_RELPATH, kind: 'asset', host: 'claude' });
expect(fs.readFileSync(path.join(tmp, SHIP_DRIVER_RELPATH), 'utf8')).toBe(doc);
const dry = await runGeneration({ host: 'claude', dryRun: true });
expect(dry.diagnostics.filter(d => d.relativePath === SHIP_DRIVER_RELPATH)).toEqual([]);
}, 120_000);
});
+128
View File
@@ -0,0 +1,128 @@
/**
* GSTA-167 PR 3a: a handoff child reads only its segment's sections. On #34
* and #36 the Steps 7-11.5 child booted at 1.29M by reading the whole ship
* skill, preamble.md and docs/ship-budget.md before its first step; the brief
* now names ship/SKILL.md's step list, the segment's sections and
* budget-handoff.md, and nothing else. Variant E (replay 5): Steps 7-8 stay
* with the parent, so the review child (Steps 9-11.5) reads review-army.md and
* adversarial.md, and the record the driver keeps carries the six headings.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import { RECORD_HEADINGS, segmentSections } from '../lib/ship-handoff';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const ROOT = `${import.meta.dir}/..`;
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
function briefFor(from: string, to: string): string {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '98000']);
expect(s.code, s.out + s.err).toBe(0);
const ho = h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', from, '--to', to, '--base', 'origin/main', '--reading', '1000000']);
expect(ho.code, ho.out + ho.err).toBe(0);
return fs.readFileSync(ho.out.match(/^HANDOFF: (\S+) /m)![1]!, 'utf8');
}
const readSection = (text: string) => text.slice(text.indexOf('## Read'), text.indexOf('\n## ', text.indexOf('## Read') + 1));
describe('the handoff brief names only the segment\'s sections', () => {
test('Steps 9-11.5: the step list plus review-army and adversarial only', () => {
const read = readSection(briefFor('9', '11.5'));
expect(read).toContain("ship/SKILL.md's step list");
for (const s of ['review-army.md', 'adversarial.md', 'budget-handoff.md']) expect(read).toContain(s);
for (const s of ['test-coverage.md', 'plan-completion.md', 'changelog.md', 'documentation.md', 'pr-body.md', 'capy-pr.md', 'tests.md']) expect(read).not.toContain(s);
expect(read).toMatch(/Never read ship\/sections\/preamble\.md in full or docs\/ship-budget\.md/);
});
test('Steps 12-22: the step list plus changelog, documentation, pr-body and capy-pr', () => {
const read = readSection(briefFor('12', '22'));
for (const s of ['changelog.md', 'documentation.md', 'pr-body.md', 'capy-pr.md', 'budget-handoff.md']) expect(read).toContain(s);
for (const s of ['test-coverage.md', 'plan-completion.md', 'review-army.md', 'adversarial.md']) expect(read).not.toContain(s);
expect(read).toMatch(/Never read ship\/sections\/preamble\.md in full or docs\/ship-budget\.md/);
});
test('a grandchild segment names only the sections of its own steps', () => {
expect(segmentSections('9', '11.5')).toEqual(['review-army', 'adversarial']);
expect(segmentSections('19', '22')).toEqual(['pr-body', 'capy-pr']);
expect(segmentSections('1', '22')).toEqual(['tests', 'greptile-early', 'test-coverage', 'plan-completion', 'review-army', 'adversarial', 'changelog', 'documentation', 'pr-body', 'capy-pr']);
});
// Value: protects=a handoff child reads exact line ranges: never ship/SKILL.md's preamble, the section index or another segment's steps, and every review section whole, so its reviewer briefs are the section's own text; fails_when=the brief names files without ranges, a range starts at line 1 of SKILL.md, a review section is cut, or a gate heading falls outside the ranges;
// why_new=the tests above check file names only; seam=none; no_claim=that a child obeys the ranges
test('the brief prints READ: ranges per file that skip the preamble, keep every gate heading and read each review section whole', () => {
const ranges = (text: string) => Object.fromEntries([...text.matchAll(/^READ: (\S+?):([\d,-]+)$/gm)].map(m => [m[1]!, m[2]!.split(',').map(r => r.split('-').map(Number) as [number, number])]));
const lineOf = (file: string, heading: string) => fs.readFileSync(`${ROOT}/${file}`, 'utf8').split('\n').findIndex(l => l.startsWith(heading)) + 1;
const inside = (rs: [number, number][], n: number) => n > 0 && rs.some(([a, b]) => a <= n && n <= b);
const total = (file: string) => fs.readFileSync(`${ROOT}/${file}`, 'utf8').replace(/\n$/, '').split('\n').length;
const seg = briefFor('9', '11.5');
expect(seg).toMatch(/Read only the `READ:` ranges below/);
const r = ranges(seg);
const skill = r['ship/SKILL.md']!;
expect(skill[0]![0]).toBe(lineOf('ship/SKILL.md', '# Ship:'));
for (const h of ['## Step 11.5:', '## Important Rules', '### Ship control flow']) expect(inside(skill, lineOf('ship/SKILL.md', h)), h).toBe(true);
for (const h of ['## Preamble (run first)', '## Section index', '## Step 3:', '## Step 7:', '## Step 8:', '## Step 12:']) expect(inside(skill, lineOf('ship/SKILL.md', h)), h).toBe(false);
for (const s of ['review-army', 'adversarial', 'budget-handoff']) expect(r[`ship/sections/${s}.md`]).toEqual([[1, total(`ship/sections/${s}.md`)]]);
for (const s of ['test-coverage', 'plan-completion']) expect(r[`ship/sections/${s}.md`]).toBeUndefined();
expect(Object.keys(r).filter(k => k.includes('audit-') || k.includes('changelog'))).toEqual([]);
const late = ranges(briefFor('12', '22'))['ship/SKILL.md']!;
for (const h of ['## Step 12:', '## Step 14.5:', '## Step 17:', '## Step 22:', '## Important Rules']) expect(inside(late, lineOf('ship/SKILL.md', h)), h).toBe(true);
for (const h of ['## Step 3:', '## Step 11.5:']) expect(inside(late, lineOf('ship/SKILL.md', h)), h).toBe(false);
});
// Value: protects=a segment ending at Step 22 is returned at Step 22, so the parent's reconcile reads it complete; fails_when=the brief leaves the return step to the child's reading of "Steps 1-21" (GSTA-167: --to 21 on a 12-22 handoff reconciled child=incomplete and stopped the parent);
// why_new=no test follows a brief through return and reconcile at Step 22; seam=none; no_claim=what the child did between verify and return
test('a Steps 12-22 brief names Step 22 as the last step and its return command; following it reconciles complete', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '98000']);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--run', run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '1000000']);
expect(ho.code, ho.out + ho.err).toBe(0);
const file = ho.out.match(/^HANDOFF: (\S+) /m)![1]!;
const text = fs.readFileSync(file, 'utf8');
expect(text).toContain('Your last step is Step 22');
expect(text).toContain('gstack-ship-budget return --run <your child run> --to 22');
const v = h.sb(['verify-handoff', '--in', file, '--reading', '1100000']);
expect(v.code, v.out + v.err).toBe(0);
const child = h.field(v.out, 'BUDGET_RUN', 'id');
const ret = h.sb(['return', '--run', child, '--to', '22', '--reading', '2000000']);
expect(ret.code, ret.out + ret.err).toBe(0);
const rec = h.sb(['reconcile', '--run', run, '--return', ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, '--reading', '2100000']);
expect(rec.out).toContain('child=complete');
expect(rec.code, rec.out).toBe(0);
});
test('every section the brief can name is a registered ship section', () => {
const manifest = JSON.parse(fs.readFileSync(`${import.meta.dir}/../ship/sections/manifest.json`, 'utf8'));
const ids = new Set(manifest.sections.map((s: any) => s.id));
for (const id of segmentSections('1', '22')) expect(ids.has(id)).toBe(true);
});
// Value: protects=the record ship/DRIVER.md tells the parent to keep is one gstack-ship-budget handoff accepts: the driver names each of the six headings with the phase that writes it, and a record holding them hands Steps 9-11.5 off while one missing a heading is refused by name; fails_when=the driver drops or renames a heading the handoff requires (GSTA-167 replay 5: "record ... lacks headings: Release, Decisions, Reviews, Checks, Documentation, Next steps"), or the refusal stops naming the missing heading;
// why_new=the harness record above is hand-written, never the driver's; seam=none; no_claim=that the parent writes each heading on time
test('the driver names each record heading with its phase, and a record built from it hands Steps 9-11.5 off', () => {
const driver = fs.readFileSync(`${ROOT}/ship/DRIVER.md`, 'utf8');
const named = [...driver.matchAll(/\*\*([A-Z][a-z]+(?: steps)?)\*\*\s+\((?:Phases? [\d-]+|Step [\d.]+|any\s+phase|every phase)/g)].map(m => m[1]);
expect(new Set(named)).toEqual(new Set(RECORD_HEADINGS));
expect(driver).toContain('write its six headings at Phase 1');
const h = fresh();
const record = (headings: readonly string[]) => {
const file = h.record(`driver-${headings.length}.md`);
fs.writeFileSync(file, ['# /ship invocation record', '', ...headings.flatMap(x => [`## ${x}`, x === 'Checks' ? '- Coverage: 89% value-weighted; Scope Check: CLEAN' : '', ''])].join('\n'));
return file;
};
const handoff = (file: string) => {
const s = h.sb(['start', '--record', file, '--token-cap', '400M', '--reading', '98000']);
expect(s.code, s.out + s.err).toBe(0);
return h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1000000']);
};
const ok = handoff(record(RECORD_HEADINGS));
expect(ok.code, ok.out + ok.err).toBe(0);
const read = readSection(fs.readFileSync(ok.out.match(/^HANDOFF: (\S+) /m)![1]!, 'utf8'));
expect(read).toContain('review-army.md, adversarial.md, budget-handoff.md');
const refused = handoff(record(RECORD_HEADINGS.filter(x => x !== 'Next steps')));
expect(refused.code).toBe(2);
expect(refused.err).toContain('lacks headings: Next steps');
});
});
+16 -17
View File
@@ -199,9 +199,13 @@ describe('ship/SKILL.md — Plan Completion gate invariants', () => {
});
});
// GSTA-167 PR 4: Step 17's idempotency block moved into gstack-ship-release push; this runs the
// bin the prose names, with the same remote states the block was tested against.
test('push idempotency requires the live remote SHA and fails closed on transport errors', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-push-state-'));
const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-push-root-'));
fs.writeFileSync(path.join(state, '.redact-prepush-prompted'), '');
const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GSTACK_STATE_ROOT: state, GSTACK_HOME: state,
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.invalid' };
const git = (...args: string[]) => {
@@ -215,25 +219,20 @@ test('push idempotency requires the live remote SHA and fails closed on transpor
const remote = path.join(cwd, '.git/remote.git');
git('init', '--bare', remote); git('remote', 'add', 'origin', remote);
const source = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md.tmpl'), 'utf8');
const block = source.slice(source.indexOf('**Idempotency check:** Check if the branch'))
.match(/```bash\n([\s\S]*?)\n```/)![1].replaceAll('<branch-name>', 'feature');
const inspect = () => {
const r = spawnSync('bash', ['-c', block], { cwd, env, encoding: 'utf8', timeout: 30_000 });
if (r.error) throw new Error(`idempotency check did not run: ${r.error.message}; stderr: ${r.stderr}`);
return r;
};
expect(inspect().stdout).toContain('PUSH_NEEDED');
git('push', '-u', 'origin', 'feature');
expect(inspect().stdout).toContain('ALREADY_PUSHED');
const step17 = source.slice(source.indexOf('## Step 17:'), source.indexOf('## Step 18:'));
expect(step17).toContain('gstack-ship-release push');
expect(step17).toContain('a failed lookup STOPs');
const push = () => spawnSync(path.join(SHIP_DIR, '..', 'bin', 'gstack-ship-release'), ['push', '--branch', 'feature'], { cwd, env, encoding: 'utf8', timeout: 60_000 });
expect(push().stdout).toMatch(/PUSH: pushed [0-9a-f]{12} to origin\/feature/);
expect(push().stdout).toContain('PUSH: ALREADY_PUSHED');
fs.appendFileSync(path.join(cwd, 'app'), 'new\n'); git('commit', '-am', 'new');
expect(inspect().stdout).toContain('PUSH_NEEDED');
git('push', 'origin', 'feature');
expect(push().stdout).toMatch(/PUSH: pushed /);
fs.renameSync(remote, remote + '-offline');
const unavailable = inspect();
expect(unavailable.status).toBe(1);
const unavailable = push();
expect(unavailable.status).toBe(3);
expect(unavailable.stdout).not.toContain('ALREADY_PUSHED');
expect(unavailable.stdout).toContain('BLOCKED');
} finally { fs.rmSync(cwd, { recursive: true, force: true }); }
expect(unavailable.stdout).toContain('STOP: cannot verify the remote branch');
} finally { fs.rmSync(cwd, { recursive: true, force: true }); fs.rmSync(state, { recursive: true, force: true }); }
}, 120_000);
// F1 (#2768, #2800): the Plan Completion Audit used to fall back to "the newest
+194
View File
@@ -0,0 +1,194 @@
/**
* gstack-ship-preflight (GSTA-167 PR 4): /ship Steps 1-3 and Step 5's pregate and lanes in
* one command. These are the checks Steps 1-3 and 5 used to spell out as shell blocks,
* moved into the bin; each STOP is a place the prose leaves the decision to the agent.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { lineOf, PREFLIGHT, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(60_000);
const fixtures: ShipFx[] = [];
const fx = (...a: Parameters<typeof shipFixture>) => { const f = shipFixture(...a); fixtures.push(f); return f; };
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
describe('gstack-ship-preflight', () => {
// Value: protects=one call reports branch, diff, PR, merge, policy, runtime lines, pregate and each lane with its count, in at most 30 lines; fails_when=a step is skipped, a lane passes on exit 0 alone, or the output grows past the cap;
// why_new=the preflight bin is new; seam=gstack-evidence, gstack-pregate, gstack-review-plan; no_claim=project test selection
test('a clean branch: Steps 1-3 then the pregate and one counted lane, NEXT Step 6, at most 30 lines', () => {
const f = fx();
const r = f.run(PREFLIGHT, ['--base', 'main', '--lane', LANE]);
expect(r.status).toBe(0);
for (const key of ['BRANCH', 'STATUS', 'DIFF', 'PR', 'SCOPE', 'MERGE', 'DISTRIBUTION', 'POLICY', 'RUNTIME_LINES', 'PREGATE']) expect(lineOf(r.stdout, key)).not.toBe('');
expect(lineOf(r.stdout, 'MERGE')).toBe('MERGE: up to date');
expect(lineOf(r.stdout, 'RUNTIME_LINES')).toMatch(/^RUNTIME_LINES: 1 /);
expect(lineOf(r.stdout, 'LANE')).toMatch(/^LANE: tests pass exit=0 tests_ran=3 log=/);
expect(r.stdout).toContain('NEXT: Step 6');
expect(r.stdout.trimEnd().split('\n').length).toBeLessThanOrEqual(30);
});
// Value: protects=Step 1's base-branch abort and Step 1.3's fetch failure stop before anything else runs; fails_when=preflight merges or tests on the base branch or after a failed fetch;
// why_new=moved from the Step 1 prose; seam=none; no_claim=recovery
test('on the base branch, or when the fetch fails, it stops before merging', () => {
const f = fx();
f.git('checkout', '-q', 'main');
const onBase = f.run(PREFLIGHT, ['--base', 'main']);
expect(onBase.status).toBe(3);
expect(onBase.stdout).toContain('STOP: on the base branch (main)');
f.git('checkout', '-q', 'feat');
f.git('remote', 'set-url', 'origin', path.join(f.root, 'missing.git'));
const fetch = f.run(PREFLIGHT, ['--base', 'main']);
expect(fetch.status).toBe(3);
expect(fetch.stdout).toContain('STOP: git fetch origin main failed');
expect(fetch.stdout).not.toContain('MERGE:');
});
// Value: protects=the default-branch abort reads the remote's HEAD, not a local origin/HEAD a clone pointed at the feature branch; fails_when=a checkout whose local origin/HEAD names the feature branch is refused as "on the base branch";
// why_new=GSTA-167 dogfood on #3633: a Capy checkout's origin/HEAD is the PR branch; seam=git ls-remote --symref; no_claim=offline fallback
test('a local origin/HEAD naming the feature branch is not the default branch', () => {
const f = fx();
f.git('update-ref', 'refs/remotes/origin/feat', 'HEAD');
f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feat');
const r = f.run(PREFLIGHT, ['--base', 'main']);
expect(r.stdout).not.toContain('STOP: on the base branch');
expect(r.status).toBe(0);
});
// Value: protects=Step 3 merges the base and a conflict stops with the files named, leaving the merge for the agent to resolve; fails_when=the bin resolves a conflict itself, aborts the merge, or continues to tests;
// why_new=moved from the Step 3 prose; seam=git merge; no_claim=which side is right
test('a merged base is reported; a conflict stops with the conflicted files and the merge left in place', () => {
const f = fx();
f.advanceBase({ 'docs/new.md': '# new\n' });
expect(lineOf(f.run(PREFLIGHT, ['--base', 'main']).stdout, 'MERGE')).toMatch(/^MERGE: merged origin\/main at [0-9a-f]{12}$/);
const g = fx({}, { 'src/a.ts': 'export const a = 2;\n' });
g.advanceBase({ 'src/a.ts': 'export const a = 3;\n' });
const r = g.run(PREFLIGHT, ['--base', 'main', '--lane', LANE]);
expect(r.status).toBe(3);
expect(r.stdout).toMatch(/STOP: merge conflicts in src\/a\.ts: resolve simple ones .* ask \(Step 3\)/);
expect(fs.existsSync(path.join(g.work, '.git', 'MERGE_HEAD'))).toBe(true);
expect(r.stdout).not.toContain('LANE:');
});
// Value: protects=under queue_mode stamp-at-merge /ship never merges the base into the branch (docs/ship-policy.md#ship-before-ready; the slot stamp does), and DIFF counts only the branch's own change; fails_when=the preflight merges main anyway or DIFF counts the base's movement;
// why_new=GSTA-167 dogfood on #3633 hit the merge under stamp-at-merge; seam=gstack-ship-policy show-effective; no_claim=the slot stamp's merge
test('queue_mode stamp-at-merge: the base is not merged and DIFF is the branch alone', () => {
const f = fx({ '.gstack/ship-policy.json': '{"queue_mode":"stamp-at-merge"}\n' });
f.advanceBase({ 'docs/new.md': '# new\n' });
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const r = f.run(PREFLIGHT, ['--base', 'main']);
expect(r.status).toBe(0);
expect(lineOf(r.stdout, 'MERGE')).toMatch(/^MERGE: skipped \(queue_mode stamp-at-merge/);
expect(f.git('rev-parse', 'HEAD').stdout.trim()).toBe(head);
expect(lineOf(r.stdout, 'DIFF')).toMatch(/^DIFF: 1 file changed, 1 insertion\(\+\); commits=1$/);
expect(lineOf(r.stdout, 'POLICY')).toContain('queue_mode=stamp-at-merge');
});
// Value: protects=Step 1's scope check reads the fetched origin/<base>, so a clone without a local base branch still sees frontend files; fails_when=the preflight reports frontend=false because gstack-diff-scope could not resolve a local <base>;
// why_new=GSTA-167 dogfood on #3633: a Capy checkout has no local main and SCOPE printed false; seam=gstack-diff-scope; no_claim=design review itself
test('no local base branch: SCOPE still reads the fetched base', () => {
const f = fx({}, { 'web/site.css': 'a { color: red; }\n' });
f.git('branch', '-D', 'main');
const r = f.run(PREFLIGHT, ['--base', 'main']);
expect(lineOf(r.stdout, 'SCOPE')).toMatch(/^SCOPE: frontend=true /);
});
// Value: protects=a failing lane and a zero-run lane both stop for Step 5's triage; fails_when=exit 0 with zero tests counts as a pass, or a failure continues to Step 6;
// why_new=moved from Step 5's lane block; seam=gstack-evidence tests_ran; no_claim=the triage outcome
test('a failing lane and a ZERO-RUN lane stop for Step 5 triage', () => {
const f = fx();
const failed = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', 'tests=echo boom; exit 1']);
expect(failed.status).toBe(3);
expect(lineOf(failed.stdout, 'LANE')).toMatch(/^LANE: tests FAIL exit=1/);
expect(failed.stdout).toContain('Step 5 triage');
const zero = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', 'tests=printf " 0 pass\\n 0 fail\\nRan 0 tests across 1 file. [5.00ms]\\n"']);
expect(zero.status).toBe(3);
expect(lineOf(zero.stdout, 'LANE')).toMatch(/^LANE: tests ZERO-RUN exit=0 tests_ran=0/);
});
// Value: protects=Step 2's candidates and Step 1.4's carry check are reported for the agent to judge, and an open PR's carry verdict is printed; fails_when=a new bin/ artifact goes unmentioned or the carry check is skipped when a PR is open;
// why_new=moved from Steps 1.4 and 2; seam=gh, gstack-ship-receipt carry; no_claim=whether a candidate is a distributable
test('a new artifact path is a Step 2 candidate; an open PR runs the carry dry-run and prints its verdict', () => {
const f = fx({}, { 'bin/fx-tool': '#!/bin/sh\necho hi\n' });
f.gh('echo 7');
const r = f.run(PREFLIGHT, ['--base', 'main']);
expect(lineOf(r.stdout, 'DISTRIBUTION')).toBe('DISTRIBUTION: candidates=bin/fx-tool release_workflow=none');
expect(r.stdout).toContain("NEXT: Step 2: read each candidate");
expect(lineOf(r.stdout, 'PR')).toBe('PR: #7');
expect(lineOf(r.stdout, 'CARRY')).toMatch(/^CARRY: /);
expect(r.stdout).toContain('NEXT: Step 4');
});
// Value: protects=the branch name reaches gh as one argv item, never through a shell; fails_when=a branch named with $(...) runs a command on the shipper's machine;
// why_new=GSTA-167 /ship review of #3633 (security, CRITICAL); seam=gh; no_claim=gh's own handling of the name
test('a branch name with shell syntax is never executed', () => {
const f = fx();
const name = 'x$(touch${IFS}PWNED)';
expect(f.git('checkout', '-qb', name).status).toBe(0);
f.gh('echo 7');
f.run(PREFLIGHT, ['--base', 'main']);
expect(fs.existsSync(path.join(f.work, 'PWNED'))).toBe(false);
});
// Value: protects=a local-path origin skips the open-PR and carry checks with a reason instead of asking gh, which cannot map it to a repository (and fails outright under a GH_HOST); fails_when=gh runs against a local remote and CARRY reports a GH_HOST failure;
// why_new=GSTA-167 replay 3 of gstack-evals #34 (CARRY: not checked, GH_HOST); seam=gh; no_claim=hosted remotes, which gh resolves itself
test('a local-path origin is not checked by gh', () => {
const f = fx();
f.gh('echo "none of the git remotes configured for this repository correspond to the GH_HOST environment variable" >&2; exit 1');
f.git('config', 'remote.origin.url', f.origin);
const local = f.run(PREFLIGHT, ['--base', 'main'], { GH_HOST: 'ghe.example.com' });
expect(lineOf(local.stdout, 'PR')).toMatch(/^PR: not checked \(origin is a local path/);
expect(local.stdout).not.toContain('CARRY:');
expect(local.status).toBe(0);
});
// Value: protects=RUNTIME_LINES (the small-run delegation input) counts code lines the way the packets classify paths, leaving data files out; fails_when=a JSON attachment inflates the runtime count;
// why_new=GSTA-167 replay 3 of gstack-evals #34 (RUNTIME_LINES 6687 counted JSON); seam=gstack-review-plan --json per_path; no_claim=the review depth's own count
test('RUNTIME_LINES leaves data files out', () => {
const f = fx({}, { 'src/b.ts': 'export const b = 2;\n', 'data/rows.json': Array.from({ length: 100 }, (_, i) => `{"i":${i}}`).join('\n') + '\n' });
expect(lineOf(f.run(PREFLIGHT, ['--base', 'main']).stdout, 'RUNTIME_LINES')).toMatch(/^RUNTIME_LINES: 1 /);
});
test('usage: --base is required and --tests-only needs a lane', () => {
const f = fx();
expect(f.run(PREFLIGHT, []).status).toBe(2);
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main']).status).toBe(2);
expect(f.run(PREFLIGHT, ['--base', 'main', '--lane', 'nolabel']).status).toBe(2);
});
});
// lib/ship-audit.ts: the --audit mode's logic.
describe('gstack-ship-preflight --audit (GSTA-167 variant G)', () => {
// Value: protects=one call writes the diff and the matching TODOS.md items to files the parent reads instead of TODOS.md and the diff, maps changed runtime files to tests, and names the audit readers still needed; fails_when=the diff or TODOS.md comes back whole, an untested runtime file is called covered, a changed test is missed, or a changed docs/designs/ plan is not offered;
// why_new=the --audit mode is new (GSTA-185: TODOS.md read in full twice and the diff three times per run); seam=git, gstack-slug; no_claim=coverage quality beyond file names
test('writes diff and todos files, maps coverage, and prints READER for the coverage and plan audits', () => {
const todos = '# TODOS\n\n## Ship\n- fix budget rounding in src/budget.ts\n- an unrelated item\n\n## Other\n- nothing here\n';
const f = fx({ 'TODOS.md': todos }, { 'src/budget.ts': 'export const budget = 1;\n', 'src/other.ts': 'export const o = 1;\n', 'test/budget.test.ts': "import { budget } from '../src/budget';\n" });
const out = path.join(f.root, 'audit');
const r = f.run(PREFLIGHT, ['--audit', '--base', 'main', '--out', out], { HOME: f.root });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(lineOf(r.stdout, 'CONTEXT')).toMatch(new RegExp(`^CONTEXT: diff=${out}/diff.patch bytes=\\d+ files=3 commits=1 `));
expect(fs.readFileSync(path.join(out, 'diff.patch'), 'utf8')).toContain('+export const o = 1;');
expect(lineOf(r.stdout, 'TODOS')).toMatch(/^TODOS: bytes=\d+ hits=1 file=/);
expect(fs.readFileSync(path.join(out, 'todos.md'), 'utf8')).toBe('## Ship\n- fix budget rounding in src/budget.ts\n');
expect(r.stdout).not.toContain('an unrelated item');
expect(r.stdout).toContain('COVERAGE_MAP: src/budget.ts changed_tests=1 referencing_tests=1');
expect(r.stdout).toContain('COVERAGE_MAP: src/other.ts changed_tests=0 referencing_tests=0');
expect(r.stdout).toContain('Coverage: 1 of 2 changed runtime files have no changed test naming them (src/other.ts)');
expect(r.stdout).toContain('Plan completion audit: not run (no plan is bound to this branch');
expect(lineOf(r.stdout, 'READER')).toBe('READER: coverage=needed plan=none');
});
// Value: protects=a branch whose runtime files all have changed tests skips the coverage reader, and a changed docs/designs/ plan asks for the plan reader; fails_when=the coverage reader runs on a fully tested diff, or a branch's design doc is not a candidate;
// why_new=the --audit mode is new; seam=git; no_claim=that the plan is the right one
test('a fully tested diff needs no coverage reader; a changed design doc needs the plan reader', () => {
const f = fx({}, { 'src/budget.ts': 'export const budget = 1;\n', 'test/budget.test.ts': "import { budget } from '../src/budget';\n", 'docs/designs/budget.md': '# Plan\n' });
const r = f.run(PREFLIGHT, ['--audit', '--base', 'main', '--out', path.join(f.root, 'audit')], { HOME: f.root });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('Coverage: deterministic (each of 1 changed runtime files has a changed test naming it; no audit reader)');
expect(r.stdout).toContain('PLAN_CANDIDATE: docs/designs/budget.md');
expect(lineOf(r.stdout, 'READER')).toBe('READER: coverage=none plan=needed');
expect(f.run(PREFLIGHT, ['--audit', '--base', 'main']).status).toBe(2);
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* gstack-ship-release (GSTA-167 PR 4): /ship Step 12 (version), Steps 15-16 (finish) and
* Step 17 (push). These are the checks those steps used to spell out as shell blocks,
* moved into the bin; each STOP is a place the prose leaves the decision to the agent.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(60_000);
const fixtures: ShipFx[] = [];
const fx = (...a: Parameters<typeof shipFixture>) => { const f = shipFixture(...a); fixtures.push(f); return f; };
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
const read = (f: ShipFx, p: string) => fs.readFileSync(path.join(f.work, p), 'utf8');
const wtree = (f: ShipFx) => f.run(path.join(path.dirname(RELEASE), 'gstack-wtree'), []).stdout.trim();
const snapshot = (f: ShipFx, name = 'post') => {
const out = path.join(f.root, `${name}.json`);
expect(f.run(path.join(path.dirname(RELEASE), 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
return out;
};
describe('version (Step 12)', () => {
// Value: protects=FRESH never picks a level itself (unless the policy pins patch), and a chosen level writes VERSION, the manifest and the CHANGELOG heading through gstack-version-bump; fails_when=the bin guesses a level, or hand-writes versions;
// why_new=moved from Step 12 items 1-5; seam=gstack-version-bump, gstack-next-version; no_claim=the queue's live host read
test('FRESH stops for BUMP_LEVEL; --bump micro writes VERSION, package.json and moves [Unreleased]', () => {
const f = fx();
const ask = f.run(RELEASE, ['version', '--base', 'main']);
expect(ask.status).toBe(3);
expect(ask.stdout).toMatch(/^STOP: BUMP_LEVEL needed \(FRESH, /m);
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
const w = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--why', 'one line']);
expect(w.status).toBe(0);
expect(lineOf(w.stdout, 'VERSION')).toMatch(/^VERSION: 1\.0\.0\.0 -> 1\.0\.0\.1 level=micro state=FRESH /);
expect(read(f, 'VERSION')).toBe('1.0.0.1\n');
expect(JSON.parse(read(f, 'package.json')).version).toBe('1.0.0');
expect(read(f, 'CHANGELOG.md')).toContain('1.0.0.1');
expect(w.stdout).toContain('NEXT: Step 13');
});
// Value: protects=ALREADY_BUMPED keeps its version without a write, DRIFT_UNEXPECTED stops, stamp-at-merge skips, and an explicit version must be above base; fails_when=a re-run double-bumps, a manual manifest edit is overwritten, or a stamp-at-merge repo gets a version;
// why_new=moved from Step 12's state dispatch; seam=gstack-version-bump classify, gstack-ship-policy; no_claim=the rebump question's answer
test('ALREADY_BUMPED keeps, DRIFT_UNEXPECTED stops, stamp-at-merge skips, a version at or below base stops', () => {
const bumped = fx({}, { VERSION: '1.0.1.0\n', 'package.json': '{"name":"fx","version":"1.0.1"}\n' });
const kept = bumped.run(RELEASE, ['version', '--base', 'main']);
expect(lineOf(kept.stdout, 'VERSION')).toMatch(/^VERSION: 1\.0\.0\.0 -> 1\.0\.1\.0 level=patch state=ALREADY_BUMPED .*\(kept, no write\)$/);
expect(read(bumped, 'VERSION')).toBe('1.0.1.0\n');
const drift = fx({}, { 'package.json': '{"name":"fx","version":"9.0.0"}\n' });
const d = drift.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']);
expect(d.status).toBe(3);
expect(d.stdout).toContain('STOP: package.json disagrees with VERSION');
const stamp = fx({ '.gstack/ship-policy.json': '{"queue_mode":"stamp-at-merge"}\n' });
const s = stamp.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']);
expect(s.status).toBe(0);
expect(lineOf(s.stdout, 'VERSION')).toContain('skipped (policy: queue_mode stamp-at-merge');
expect(read(stamp, 'VERSION')).toBe('1.0.0.0\n');
const low = fx();
expect(low.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--version', '0.9.0.0']).stdout).toContain('STOP: 0.9.0.0 is not above the base version 1.0.0.0');
});
});
describe('finish (Steps 15-16)', () => {
// Value: protects=finish commits only release metadata with the trailer, then checks the reviewed-tree route, docs freshness, each lane receipt and the pregate; fails_when=it commits feature files, skips a stage, or pushes;
// why_new=moved from Steps 15-16; seam=gstack-wtree, gstack-docs-candidate compare, gstack-evidence, gstack-pregate; no_claim=the stage 5 report
test('commits release metadata with the trailer and clears stages 2-4 and the pregate; feature files stop it', () => {
const f = fx();
const reviewed = wtree(f);
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
const post = snapshot(f);
f.write({ 'src/c.ts': 'export const c = 3;\n' });
const feature = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(feature.status).toBe(3);
expect(feature.stdout).toContain('STOP: uncommitted changes outside release metadata: src/c.ts');
fs.rmSync(path.join(f.work, 'src/c.ts'));
const ok = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE, '--trailer', 'Co-Authored-By: Fixture Bot <bot@example.com>']);
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: bump version and changelog \(v1\.0\.0\.1\)/m);
expect(f.git('log', '-1', '--format=%B').stdout).toContain('Co-Authored-By: Fixture Bot <bot@example.com>');
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=\d+/);
expect(lineOf(ok.stdout, 'DOCS')).toContain('fresh');
expect(ok.stdout).toMatch(/^(LANE: tests pass|EVIDENCE: FRESH label=tests)/m);
expect(lineOf(ok.stdout, 'PREGATE')).not.toBe('');
expect(ok.stdout).toContain('NEXT: Step 16 stage 5');
expect(ok.status).toBe(0);
expect(f.git('ls-remote', '--heads', 'origin', 'feat').stdout).toBe('');
});
// Value: protects=under changelog_fragments the branch's own fragment is release metadata: version writes nothing and names it in NEXT, finish commits it as the release commit and the route counts it as metadata, and another fragment is still a feature file; fails_when=finish stops on changelog.d/<slug>.md or routes it to re-review (/ship #3648 fix-check 4, CRITICAL);
// why_new=releasePaths predates changelog_fragments (#3654); seam=gstack-ship-policy show-effective, gstack-wtree; no_claim=the merge step's fold
test('under changelog_fragments the branch fragment is release metadata and Step 12 names it', () => {
const f = fx({ '.gstack/ship-policy.json': '{"changelog_fragments":{"dir":"changelog.d"},"min_gstack_version":"1.91.336.0"}\n', 'changelog.d/README.md': 'Fragments.\n' });
const reviewed = wtree(f);
const v = f.run(RELEASE, ['version', '--base', 'main']);
expect(v.status).toBe(0);
expect(lineOf(v.stdout, 'VERSION')).toContain('skipped (policy: changelog_fragments');
expect(v.stdout).toContain('NEXT: Step 13: write the entry as changelog.d/feat.md');
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
const post = snapshot(f);
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
f.write({ 'changelog.d/other.md': 'Not this branch.\n' });
expect(f.run(RELEASE, args).stdout).toContain('STOP: uncommitted changes outside release metadata: changelog.d/other.md');
fs.rmSync(path.join(f.work, 'changelog.d/other.md'));
f.write({ 'changelog.d/feat.md': 'One entry.\n' });
const ok = f.run(RELEASE, args);
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: changelog and TODOS \(changelog\.d\/feat\.md\)/m);
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
expect(ok.status).toBe(0);
});
// Value: protects=a behavior change after review inserts the re-review route, an authored doc change needs --docs-rechecked, stale docs and failing or absent lanes stop; fails_when=any of these continues to push;
// why_new=moved from Step 16 stages 2-4; seam=same; no_claim=the repair itself
test('behavior after review, unrechecked docs, stale docs, a failing lane and no lanes each stop', () => {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'src/a.ts': 'export const a = 9;\n' }); f.commit('late fix');
const behavior = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(behavior.stdout).toContain('STOP: behavior, tests or build inputs changed since review (src/a.ts): insert 5–11.5 → 12–14 → 16');
const g = fx();
const reviewed2 = wtree(g);
g.write({ 'docs/guide.md': '# guide\n' }); g.commit('docs');
const post2 = snapshot(g, 'post2');
const docs = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed2, '--docs-post', post2, '--lane', LANE]);
expect(docs.stdout).toContain('STOP: authored docs changed since review (docs/guide.md)');
g.write({ 'README.md': '# changed after the audit\n' }); g.commit('readme');
const stale = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed2, '--docs-post', post2, '--lane', LANE, '--docs-rechecked']);
expect(lineOf(stale.stdout, 'DOCS')).toBe('DOCS: stale (README.md)');
expect(stale.stdout).toContain('STOP: documentation inputs changed since the accepted audit');
const h = fx();
const reviewed3 = wtree(h);
const post3 = snapshot(h, 'post3');
const failing = h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3, '--docs-post', post3, '--lane', 'tests=exit 1']);
expect(failing.status).toBe(3);
expect(failing.stdout).toContain('STOP: lane tests failed or ran zero tests');
expect(h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3, '--docs-post', post3]).stdout).toContain("STOP: no test lanes: require Step 5's explicit untested-scope approval");
expect(h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3]).status).toBe(2);
});
// Value: protects=a dependency or script edit to package.json, and any lockfile edit, after review takes the re-review route; a version-only bump stays release metadata; fails_when=finish treats package.json or bun.lock as metadata whatever changed in them (/ship #3648 pass-1 security, CRITICAL);
// why_new=releasePaths exempted the manifests and lockfiles by name; seam=gstack-wtree, git; no_claim=the re-review itself
test('a script or dependency edit to package.json, or a bun.lock edit, after review stops finish', () => {
for (const [file, text] of [['package.json', '{"name":"fx","version":"1.0.0","scripts":{"test":"exit 0"}}\n'], ['bun.lock', '{"lockfileVersion":1}\n']]) {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ [file]: text }); f.commit(`late ${file}`);
const r = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect([r.status, r.stdout.includes(`STOP: behavior, tests or build inputs changed since review (${file})`)]).toEqual([3, true]);
}
const g = fx();
const reviewed = wtree(g);
const built = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', snapshot(g), '--lane', LANE, '--build', `printf '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"x"}}\\n' > package.json`]);
expect([built.status, built.stdout.includes('STOP: behavior, tests or build inputs changed since review (package.json)')]).toEqual([3, true]);
});
});
describe('push (Step 17)', () => {
// Value: protects=push refuses a branch other than Step 1's saved one, waits for the one-time guard offer, refuses open gate findings on a ready PR without approval, then pushes once and reports ALREADY_PUSHED after; fails_when=it pushes past an unanswered offer or a ready PR with open gate findings, or pushes twice;
// why_new=moved from Step 17's idempotency block; seam=gstack-config, gh, git ls-remote, gstack-pregate sealed; no_claim=the guard block's hook install
test('an unanswered guard offer stops; open gate findings on a ready PR stop; then it pushes once', () => {
const f = fx();
expect(f.run(RELEASE, ['push']).status).toBe(2);
const moved = f.run(RELEASE, ['push', '--branch', 'other']);
expect(moved.status).toBe(3);
expect(moved.stdout).toContain('STOP: HEAD is on feat, not the branch Step 1 saved (other)');
const unanswered = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(unanswered.status).toBe(3);
expect(unanswered.stdout).toContain("STOP: Step 17's one-time pre-push guard offer is unanswered");
fs.mkdirSync(f.state, { recursive: true });
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
f.gh('echo 12');
const gate = f.run(RELEASE, ['push', '--branch', 'feat', '--gate-open', '2']);
expect(gate.status).toBe(3);
expect(gate.stdout).toContain('STOP: 2 open gate finding(s) and ready PR #12');
const pushed = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(pushed.status).toBe(0);
expect(lineOf(pushed.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/feat$/);
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'PUSH')).toMatch(/^PUSH: ALREADY_PUSHED /);
});
// Value: protects=a rejected push stops with the merge-and-reverify route and never force-pushes; fails_when=the bin retries with force or reports success;
// why_new=moved from Step 17's failure rules; seam=git push; no_claim=the merge
test('a non-fast-forward push stops with the merge route', () => {
const f = fx();
fs.mkdirSync(f.state, { recursive: true });
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
expect(f.run(RELEASE, ['push', '--branch', 'feat']).status).toBe(0);
f.git('reset', '-q', '--hard', 'HEAD~1');
f.write({ 'src/z.ts': 'export const z = 1;\n' }); f.commit('diverge');
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(r.status).toBe(3);
expect(r.stdout).toContain('STOP: non-fast-forward');
expect(r.stdout).toContain('never force-push');
});
});
describe('gstack-ship-release push: branch names', () => {
// Value: protects=push's open-PR lookup passes the branch to gh as one argv item; fails_when=a branch named with $(...) runs a command before the push guards;
// why_new=GSTA-167 /ship review of #3633 (security, CRITICAL); seam=gh; no_claim=gh's own handling of the name
test('a branch name with shell syntax is never executed by the gate lookup', () => {
const f = fx();
const name = 'x$(touch${IFS}PWNED)';
expect(f.git('checkout', '-qb', name).status).toBe(0);
fs.mkdirSync(f.state, { recursive: true });
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
f.gh('echo 12');
f.run(RELEASE, ['push', '--branch', name, '--gate-open', '1']);
expect(fs.existsSync(path.join(f.work, 'PWNED'))).toBe(false);
});
});
@@ -0,0 +1,4 @@
{
"bytes": 1614,
"why": "GSTA-167: ship preflight/release bins and the compact driver (+841); variant E: budget-handoff.md runs one program per review pass and hands Steps 9-11.5 to the review child (+362); variant G: budget-handoff.md points driver runs at observe mode, reads credits beside tokens, verifies a Capy child with no reading and settles a reader only once it has run (+411)"
}
+1
View File
@@ -220,6 +220,7 @@ const BASELINE = [
'test/shared-libs-source-reads.test.ts', // slices test/skill-e2e-shared-libs-paths.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing)
'test/shared-libs-stage-actor.test.ts', // slices test/skill-e2e-shared-libs.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing)
'test/ship-coverage-audit-af.test.ts', // slices test/skill-e2e-workflow.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing)
'test/ship-release.test.ts', // GSTA-167 PR 4: runs bin/gstack-ship-release through test/helpers/ship-bin-fixture.ts's RELEASE path, so it names no bin/ literal itself
'test/ship-section-fixture.test.ts',
'test/skill-budget-regression.test.ts',
'test/skill-fixture.test.ts', // slices test/skill-routing-e2e.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing)