diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d6e809d..488849c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 --reading --readers `); 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/.md` made `gstack-ship-receipt carry` refuse with RECEIPT_CARRY_PRODUCT_CHANGED. The PR then needed a new review pass for a changelog edit. diff --git a/VERSION b/VERSION index 058587e4b..bb998b909 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.344.0 +1.91.345.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 26af38ff3..38ea47900 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index d65787aa5..93b94e254 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -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. `. ` 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. `. ` 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: . " 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}")" diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch new file mode 100755 index 000000000..66a47d9c4 --- /dev/null +++ b/bin/gstack-review-dispatch @@ -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 [--name ] --out [slots] write the brief, print its sha256 + * gstack-review-dispatch sent --ledger --role [--name ] --sent the prompt actually passed; mismatch fails + * gstack-review-dispatch collect --ledger --role [--name ] --output --merged + * gstack-review-dispatch judge --ledger --merged --judged no drop, no downgrade + * gstack-review-dispatch verify --ledger 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 [--name ] --out [--ledger ] [slots] + roles: ${BRIEF_ROLES.join(', ')} + slots: --diff-base (every role but fix-check) --exclude '' --stack --test-fw + --learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team) + --checklists (specialist-batched) --findings-file --specialists (red-team) + --fix-list --hunks --tests [--callers ] (fix-check) + writes /[-].prompt.md, records it in the ledger (default /review-ledger.jsonl) and prints + BRIEF: reader= sha256= bytes= file=. Pass that file's content as the reader's prompt, unchanged. + sent --ledger --role [--name ] --sent + the exact prompt a dispatch passed: BRIEF_SENT: … match, or BRIEF_MISMATCH: … (exit 1; the pass is not reviewed) + collect --ledger --role [--name ] --output --merged + the reader's raw output: hashes it and appends each finding to merged.jsonl with its label as written + judge --ledger --merged --judged + 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 + 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 \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 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 \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 and --merged \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 and --judged \n'); return EXIT.usage; } + const r = recordJudgment(ledger, readJsonl(merged), readJsonl(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; +} diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index c82b06c17..a0fcf3228 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -31,7 +31,8 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --pass 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')) { diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget index 3f1361c0e..83831d97f 100755 --- a/bin/gstack-ship-budget +++ b/bin/gstack-ship-budget @@ -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 beside it. * - * start --record [--token-cap ] [--meter capy|claude|estimate] [--reading ] [--prior-reading ] - * [--session |--nonce ] [--reserve ] [--delegate auto|never] [--calibration ] [--ledger ] - * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] [--returned [,...]] - * settle --run|--record --reservation [--unknown] [--reading ] release --run|--record --reservation + * start --record [--token-cap ] [--mode admit|observe] [--meter capy|claude|estimate] [--reading ] [--credits ] + * [--prior-reading ] [--session |--nonce ] [--reserve ] [--delegate auto|never] [--calibration ] [--ledger ] + * note --run|--record --step [--label ] --reading [--credits ] [--dispatched-at [--dispatched-credits ] --readers ] + * check --run |--record --step --next [,...]> [--reading ] [--credits ] [--prior-reading ] + * [--returned [,...]] [--runtime-lines ] [--dispatched-at --readers ] + * settle --run|--record --reservation [--unknown] [--reading ] [--credits ] [--dispatched-at --readers ] + * release --run|--record --reservation * handoff --run|--record --from --to --base origin/ [--reviewed-tree ] [--trail --trail-run ] * [--sub-cap ] [--reading ] [--child-session ] * launch --in (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 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); } diff --git a/bin/gstack-ship-preflight b/bin/gstack-ship-preflight new file mode 100755 index 000000000..3664d9353 --- /dev/null +++ b/bin/gstack-ship-preflight @@ -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); diff --git a/bin/gstack-ship-release b/bin/gstack-ship-release new file mode 100755 index 000000000..48ccff618 --- /dev/null +++ b/bin/gstack-ship-release @@ -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); diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 6088323b3..1952edf04 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -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=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: ` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: `: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. ` 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=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: ` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_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 `. ` 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= 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 ` 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=`. `~/.local/share/gstack/bin/gstack-artifact validate /run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract. +For `/ship` driven from Capy, read `ship/DRIVER.md` in the checkout (`~/.local/share/gstack/ship/DRIVER.md`): the phase loop at one or two calls per phase, and the section each STOP needs. A checkout without that file predates it; follow its `ship/SKILL.md` instead. 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/` (`--out ~/.capy/work//autoplan`), never `/tmp`: Capy wipes `/tmp` when a machine sleeps, and a vanished run cannot be resumed, answered or exported. The same holds for any file a later turn reads. Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render /gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent: --owner-reply ""`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out --from ` restores the newest valid checkpoint. -Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` from `tools.thread.get({ thread_id: "this" })` (one execute program returning only that number) into `gstack-ship-budget --reading ` and obeys `verdict=` (`docs/ship-budget.md`). +Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). + +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base ` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start ` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation ` 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 --reading --credits --dispatched-at --dispatched-credits --readers `; (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 diff --git a/docs/capy-install.md b/docs/capy-install.md index 96f98bc53..8187c7c34 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -423,6 +423,9 @@ GSTACK_LAUNCH_FILE: .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 diff --git a/docs/review-policy.md b/docs/review-policy.md index 24d4526c4..032d87fea 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -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: in ` 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 [--name ] --out ` 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 ` 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 + `@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=`: 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`). diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 2180eb82b..928132644 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -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 --step [--label ] --reading --credits \ + [--dispatched-at --dispatched-credits --readers ] + +which prints `NOTE: [: