From 517adb0b26994518494ce65e1942236e0bd5e287 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 30 Oct 2026 11:02:32 +0000 Subject: [PATCH 01/19] 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> --- CHANGELOG.md | 24 ++ bin/gstack-capy-install | 6 +- bin/gstack-ship-budget | 4 +- bin/gstack-ship-preflight | 10 + bin/gstack-ship-release | 10 + capy/skill/gstack/SKILL.md | 8 +- docs/capy-install.md | 3 + docs/ship-budget.md | 88 ++++-- docs/troubleshooting.md | 10 +- lib/result-codes-budget.ts | 2 +- lib/ship-bin-common.ts | 109 +++++++ lib/ship-budget-calibration.json | 66 ++++- lib/ship-budget-cli.ts | 90 ++++-- lib/ship-budget.ts | 241 ++++++++++++---- lib/ship-handoff.ts | 63 ++++- lib/ship-preflight.ts | 132 +++++++++ lib/ship-release.ts | 238 ++++++++++++++++ scripts/gen-skill-docs.ts | 12 + scripts/resolvers/index.ts | 3 + scripts/resolvers/ship-driver.ts | 43 +++ ship/DRIVER.md | 112 ++++++++ ship/DRIVER.md.tmpl | 86 ++++++ ship/SKILL.md | 92 +++--- ship/SKILL.md.tmpl | 92 +++--- ship/sections/budget-handoff.md | 39 ++- ship/sections/budget-handoff.md.tmpl | 39 ++- ship/sections/tests.md | 43 +-- ship/sections/tests.md.tmpl | 43 +-- test/binding-template-drift.test.ts | 5 +- test/fixtures/golden/agy-ship-SKILL.md | 92 +++--- test/fixtures/golden/claude-ship-SKILL.md | 92 +++--- test/fixtures/golden/codex-ship-SKILL.md | 94 +++--- test/fixtures/golden/factory-ship-SKILL.md | 94 +++--- test/fixtures/module-size-ratchet.json | 1 + test/gen-skill-docs-checks.test.ts | 1 + test/gstack-capy-install.test.ts | 18 +- test/helpers/ship-bin-fixture.ts | 84 ++++++ test/launch-env-echo-ratchet.test.ts | 4 +- test/review-packets-removed.test.ts | 44 +++ test/ship-budget-batch.test.ts | 8 +- test/ship-budget-calibration.test.ts | 13 + test/ship-budget-chain.test.ts | 82 +++++- test/ship-budget-meter-contract.test.ts | 85 ++++++ test/ship-budget-projection.test.ts | 27 +- test/ship-budget-reader-drift.test.ts | 283 +++++++++++++++++++ test/ship-budget-review-log.test.ts | 145 +++++++++- test/ship-budget-section.test.ts | 27 ++ test/ship-budget-small-run.test.ts | 58 ++++ test/ship-budget-step-prior.test.ts | 50 ++++ test/ship-budget.test.ts | 9 +- test/ship-driver.test.ts | 168 +++++++++++ test/ship-handoff-brief.test.ts | 98 +++++++ test/ship-plan-completion-invariants.test.ts | 33 ++- test/ship-preflight.test.ts | 159 +++++++++++ test/ship-release.test.ts | 171 +++++++++++ test/size-ledger/ship/gsta-167-turns.json | 4 + test/test-of-test-ratchet.test.ts | 1 + 57 files changed, 3052 insertions(+), 606 deletions(-) create mode 100755 bin/gstack-ship-preflight create mode 100755 bin/gstack-ship-release create mode 100644 lib/ship-bin-common.ts create mode 100644 lib/ship-preflight.ts create mode 100644 lib/ship-release.ts create mode 100644 scripts/resolvers/ship-driver.ts create mode 100644 ship/DRIVER.md create mode 100644 ship/DRIVER.md.tmpl create mode 100644 test/helpers/ship-bin-fixture.ts create mode 100644 test/ship-budget-meter-contract.test.ts create mode 100644 test/ship-budget-reader-drift.test.ts create mode 100644 test/ship-budget-small-run.test.ts create mode 100644 test/ship-budget-step-prior.test.ts create mode 100644 test/ship-driver.test.ts create mode 100644 test/ship-handoff-brief.test.ts create mode 100644 test/ship-preflight.test.ts create mode 100644 test/ship-release.test.ts create mode 100644 test/size-ledger/ship/gsta-167-turns.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e02216e5..ab6212e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [Unreleased] + +**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` the version step prints `VERSION: skipped (policy: ...)` and writes nothing. +- **`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-ship-budget start --delegate measured`** delegates a segment only when a measured call is 150k or more above the host floor; an unknown charge continues. +- **Small runs keep their segment.** `check --runtime-lines ` continues at the Step 7 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. + +### 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.331.0] - 2026-10-30 **`/ship` and `/review` go back to the reviewers they used in 1.91.325.0, because the newer reviewers missed real blockers.** In 1.91.326.0 (#3621) each reviewer started reading one prepared packet of the diff instead of reading the diff and the code around it, and the parent read a short findings record. 1.91.330.0 (#3636) let packet reviewers read a little beyond their hunks. A replay of 14 merged PRs (GSTA-167 no-loss round 3) then showed that the old reviewers find 19 of the 30 known blockers, and the packet reviewers, even with the looser reading rule, found only 10 of those 19 (the replay's counting rule favored the old review, and the packet reviewers also found 2 blockers it missed, but the preregistered bar allowed 1 miss and they had 9). So reviews run with gstack 1.91.326.0 through 1.91.330.0 are not as thorough as the review before them, and a PR reviewed on one of those versions may still hide a blocker the old review would have caught. diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 93b51b75c..6ccfc52c8 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-ship-budget b/bin/gstack-ship-budget index 3f1361c0e..f2b8a464e 100755 --- a/bin/gstack-ship-budget +++ b/bin/gstack-ship-budget @@ -6,8 +6,8 @@ * reserve fit under the cap. * * 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 [,...]] + * [--session |--nonce ] [--reserve ] [--delegate auto|never|measured] [--calibration ] [--ledger ] + * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] [--returned [,...]] [--runtime-lines ] * settle --run|--record --reservation [--unknown] [--reading ] release --run|--record --reservation * handoff --run|--record --from --to --base origin/ [--reviewed-tree ] [--trail --trail-run ] * [--sub-cap ] [--reading ] [--child-session ] 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 bb629e753..ff8c1a47e 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 `--delegate measured`, 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` 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 it as `--reading `, never in a call of its own, and obeys `verdict=` (`docs/ship-budget.md`). + +/ship reviewer batches on Capy under a cap: one execute program per batch, in this order: (1) `gstack-ship-budget check --run --step --next dispatch: --reading ` (one role per `DISPATCH:` line; `BEFORE` is this program's counter reading), then `gstack-review-log --start --reservation ` (`` from check's `RESERVATION_ID:` line) once per row the step logs (a later batch of the same pass takes none); (2) `tools.subagent.create`, one reviewer each, its brief copied verbatim from the section that dispatches it; (3) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s until every reviewer settles; (4) the reading `AFTER`, then `gstack-ship-budget settle --run --reservation --dispatched-at --reading --readers `, returning each reviewer's final report. The parent makes no model call while the program runs, so only that pair measures the readers; a batch settled from a later program omits `--dispatched-at` and stays `UNMEASURED`. Keep one program under ~8 minutes and rerun the poll until every reviewer has settled. ## Plan, approve, ship diff --git a/docs/capy-install.md b/docs/capy-install.md index 7de24710e..ed00f9ed8 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). The file is part of the ok verdict: a passing run that cannot write it exits 1 with `gstack: fail launch-file — could not write the launch file ; fix: make writable, or set GSTACK_STATE_ROOT to a writable directory diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 2180eb82b..09204b8ce 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -23,21 +23,28 @@ you set stays as given); the stop overhead never scales. ## 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: "" })).cost.tokens; ``` -then +then pass it to ```bash gstack-ship-budget start --record --token-cap 25M --reading gstack-ship-budget check --run --step 6 --next 7 --reading [--prior-reading ] ``` -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 handoff child reads its own counter too. **Claude Code.** `gstack-ship-budget start --record --token-cap 25M --session `. Without the session id, `start --meter claude` prints a nonce: echo it once, then rerun with `--nonce `. @@ -59,12 +66,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: ` 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 +87,25 @@ gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team gstack-review-log --start review --reservation ``` -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=)`: 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 ` 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 [,...]`), or, when other work comes first, `settle --reservation ---reading ` 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 --dispatched-at --reading --readers ` (or names the batch in +a check with `--returned --dispatched-at `). 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 --words ""`); a reservation is never created after the fact. @@ -110,7 +124,12 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check 1. `gstack-ship-budget handoff --run --from 12 --to 22 --base origin/ --reading ` 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 +138,10 @@ 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` 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,13 +157,25 @@ 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 --return --reading `; 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_`): 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. `--delegate never` turns delegate into stop for a parent on a host without children; a child -continues with a `CONTEXT_HIGH:` note instead. +continues with a `CONTEXT_HIGH:` note instead. `--delegate measured` (what `ship/DRIVER.md` starts +with) delegates only when a measured call is 150k or more above the floor at a segment boundary; an +unknown charge continues there. A parent driving /ship from the driver carries no skill text a fresh +child would shed, so a child pays its fixed cost only when the parent's own context grew. + +**Small runs keep their segment.** `check --runtime-lines ` (runtime_lines from +`gstack-review-plan --json`, which `gstack-ship-preflight` prints) continues at the Step 7 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 @@ -177,16 +211,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= kind=step|dispatch next= projected= actual= ratio= +and each reservation that cannot be measured prints +`UNMEASURED: id= kind=step|dispatch next= 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:`); 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2d0e80edd..a09517ba2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1846,17 +1846,17 @@ owner's call: run the printed `RECOVER:` line; its resume needs ### `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 `. +**Fix.** In the execute program that runs the command, read +`(await tools.thread.get({ thread_id: "" })).cost.tokens` and pass it as `--reading `. ### `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 diff --git a/lib/result-codes-budget.ts b/lib/result-codes-budget.ts index 28ef0abc2..7ddcf5bd2 100644 --- a/lib/result-codes-budget.ts +++ b/lib/result-codes-budget.ts @@ -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 (ship/sections/budget-handoff.md has the recipe)', + fix: 'read cost.tokens from tools.thread.get({ thread_id: "" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading (ship/sections/budget-handoff.md has the recipe)', }, BUDGET_READING_REJECTED: { anchor: 'budget-reading-rejected', diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts new file mode 100644 index 000000000..c9a13ff4a --- /dev/null +++ b/lib/ship-bin-common.ts @@ -0,0 +1,109 @@ +/** + * 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 path from 'node:path'; +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 { + 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