From c85dd0a79976589e6e84e3b76d12230ccaeef3ea Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 03:29:23 -0700 Subject: [PATCH] v1.91.316.0 ship: token cap with real meters and fresh-context delegation (GSTA-152 PR A) (#3593) * ship: token cap with real meters and fresh-context delegation (GSTA-152 PR A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (stopped or lost capped run no longer blocks uncapped review starts; short child return reconciles incomplete) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry estimate spend across a meter epoch change; calibration docs match their evidence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: load the committed calibration file by its repo path so pregate maps it to a lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: fix fence placeholders and register claude-usage fixture (GSTA-152 PR A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run ship-budget and review-log bins through their interpreters so Windows can launch them 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> --- CHANGELOG.md | 15 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-doctor-components.sh | 7 + bin/gstack-review-log | 43 +- bin/gstack-ship-budget | 50 +++ capy/skill/gstack/SKILL.md | 2 + capy/skill/gstack/captain.md | 5 + docs/ship-budget.md | 159 +++++++ docs/troubleshooting.md | 86 ++++ docs/unattended.md | 9 + lib/claude-usage.ts | 135 ++++++ lib/result-codes-budget.ts | 47 ++ lib/result-codes.ts | 2 + lib/ship-budget-calibration.json | 77 ++++ lib/ship-budget-cli.ts | 299 +++++++++++++ lib/ship-budget.ts | 410 +++++++++++++++++ lib/ship-handoff.ts | 245 +++++++++++ package.json | 2 +- scripts/pr-dependencies.ts | 2 +- ship/SKILL.md | 6 + ship/SKILL.md.tmpl | 5 + ship/sections/adversarial.md | 4 +- ship/sections/adversarial.md.tmpl | 4 +- ship/sections/budget-handoff.md | 56 +++ ship/sections/budget-handoff.md.tmpl | 54 +++ ship/sections/manifest.json | 6 + ship/sections/review-army.md | 4 +- ship/sections/review-army.md.tmpl | 4 +- test/claude-usage.test.ts | 127 ++++++ test/fixtures/claude-usage-2.1.292.json | 38 ++ test/fixtures/context-budget.json | 2 +- test/fixtures/golden/agy-ship-SKILL.md | 6 + test/fixtures/golden/claude-ship-SKILL.md | 6 + test/fixtures/golden/codex-ship-SKILL.md | 6 + test/fixtures/golden/factory-ship-SKILL.md | 6 + test/fixtures/module-size-ratchet.json | 5 + test/gstack-doctor.test.ts | 2 +- test/helpers/carve-guards.ts | 2 +- test/helpers/free-fixtures-data.ts | 1 + test/helpers/ship-budget-harness.ts | 88 ++++ test/plan-tune-cathedral.test.ts | 2 +- test/ship-budget-calibration.test.ts | 37 ++ test/ship-budget-review-log.test.ts | 134 ++++++ test/ship-budget-section.test.ts | 41 ++ test/ship-budget.test.ts | 413 ++++++++++++++++++ .../ship/capy-token-cap-with-real.json | 4 + .../size-ledger/ship/gsta152-ship-budget.json | 4 + test/timeline-stop-hook-slug.test.ts | 1 + 49 files changed, 2649 insertions(+), 18 deletions(-) create mode 100755 bin/gstack-ship-budget create mode 100644 docs/ship-budget.md create mode 100644 lib/claude-usage.ts create mode 100644 lib/result-codes-budget.ts create mode 100644 lib/ship-budget-calibration.json create mode 100644 lib/ship-budget-cli.ts create mode 100644 lib/ship-budget.ts create mode 100644 lib/ship-handoff.ts create mode 100644 ship/sections/budget-handoff.md create mode 100644 ship/sections/budget-handoff.md.tmpl create mode 100644 test/claude-usage.test.ts create mode 100644 test/fixtures/claude-usage-2.1.292.json create mode 100644 test/helpers/ship-budget-harness.ts create mode 100644 test/ship-budget-calibration.test.ts create mode 100644 test/ship-budget-review-log.test.ts create mode 100644 test/ship-budget-section.test.ts create mode 100644 test/ship-budget.test.ts create mode 100644 test/size-ledger/ship/capy-token-cap-with-real.json create mode 100644 test/size-ledger/ship/gsta152-ship-budget.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d6b90a2..a1862ec7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.91.316.0] - 2026-10-29 + +**/ship takes a token cap and stops cleanly at it, and long runs hand their heavy segments to fresh-context children.** On gstack-evals#31 a /ship spent 54M tokens against a 25M cap because the cap was only a number in a brief; #26, whose agent read the counter after every step, stopped at 39.40M of 40M. `/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP`) now admits each step and dispatch batch only when it fits, and a run started from a long-lived context, or one whose per-call charge passes 150k at Step 7 or Step 12, runs that segment in a fresh child. + +### Added +- **`gstack-ship-budget`** (`lib/ship-budget.ts`, `lib/ship-budget-cli.ts`, `lib/ship-handoff.ts`): an admission cap on a per-run token ledger under `/ship-runs//` (its own schema; only `withFileLock` is shared with `lib/spend-ledger.ts`). `start`, `check` (the `BUDGET:` line, `verdict=continue|delegate|stop`, a `RESERVATION:` id per admitted step or batch), `settle`/`release`, `handoff`/`launch`/`verify-handoff`/`return`/`reconcile`, `resume` (cap raises need `--approved-by` and the owner's words; `--rebuild-from-record`; restore from a private Drive checkpoint) and `close`. Exit 0 continue/delegate, 3 stop, 2 usage. Each meter is a versioned contract, and there are three: the Capy root counter (`--reading`), Claude Code journal usage (`lib/claude-usage.ts`, bound by `--session` or an echoed nonce) and `--meter estimate`. +- **`gstack-review-log --start ... --reservation `**: under a cap, a dispatch without an unspent, one-shot reservation refuses before launch (`BUDGET_CHECK_SKIPPED`), and `--finish` settles it. Without a cap nothing changes. +- **/ship**: the skeleton's Budget paragraph (`--token-cap`, `--resume `, `--continue `) and a new `budget-handoff` section with the meter recipes, reservations, stop and resume, and the delegate flow per host. +- **Result codes** `BUDGET_CAP_REACHED`, `BUDGET_READING_MISSING`, `BUDGET_READING_REJECTED`, `BUDGET_METER_UNAVAILABLE`, `BUDGET_LEDGER_INVALID`, `BUDGET_CHECK_SKIPPED`, `HANDOFF_PIN_MOVED` with docs/troubleshooting.md anchors; `lib/ship-budget-calibration.json` seeded from #19, #26 and #31. +- **Docs**: docs/ship-budget.md (linked from docs/unattended.md), and a paragraph each in the Capy skill's SKILL.md and captain.md. + +### Tests +- `test/ship-budget.test.ts`: the cap boundary, batch overshoot, the #31 replay, spend above a reservation, the no-cap path, meters and epochs, exit codes, start and segment delegation, the full handoff chain with an idempotent reconcile, moved pins and a tampered brief, refused and interrupted children, stop and RECOVER, checkpoint restore into an empty state root, ledger rebuild, concurrent ledgers, and an unchanged `gstack-wtree`. +- `test/ship-budget-review-log.test.ts`, `test/claude-usage.test.ts`, `test/ship-budget-calibration.test.ts` (leave-one-out), `test/ship-budget-section.test.ts`. + ## [1.91.315.0] - 2026-10-29 ### Changed diff --git a/VERSION b/VERSION index af3544127..786e62c30 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.315.0 +1.91.316.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 626a80836..5e410df79 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.315.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.316.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-doctor-components.sh b/bin/gstack-doctor-components.sh index dda793216..f3c867766 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -332,6 +332,13 @@ gstack_result_anchor() { PROJECT_FLOOR_UNMET) printf '%s' 'project-floor-unmet' ;; PROJECT_NODE_PIN_UNMET) printf '%s' 'project-node-pin-unmet' ;; UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; + BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; + BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; + BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;; + BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;; + BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;; + BUDGET_CHECK_SKIPPED) printf '%s' 'budget-check-skipped' ;; + HANDOFF_PIN_MOVED) printf '%s' 'handoff-pin-moved' ;; *) return 1 ;; esac } diff --git a/bin/gstack-review-log b/bin/gstack-review-log index b60832497..0a7037120 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -3,6 +3,10 @@ # Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}' # # Before reading a diff: gstack-review-log --start review +# Under a /ship token cap (docs/ship-budget.md): --start SKILL --reservation ID +# with the RESERVATION id gstack-ship-budget check printed for this dispatch +# batch; refuses (exit 1, BUDGET_CHECK_SKIPPED) without an unspent one, and +# --finish settles it. Without a cap no marker exists and nothing changes. # Fix-range repeat (#3339): gstack-review-log --start review --fix-range # prints the token, then FIX_BASE=; refuses (exit 1) unless the latest # pass completed with fixes on unchanged content and its commit is an @@ -32,15 +36,18 @@ export GSTACK_REVIEW_DIR="$GSTACK_HOME/projects/$SLUG" export GSTACK_REVIEW_REPO="$(git rev-parse --show-toplevel 2>/dev/null || true)" export GSTACK_REVIEW_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || true)" export GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" +export GSTACK_SHIP_BUDGET_LIB="$SCRIPT_DIR/../lib/ship-budget.ts" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) if command -v cygpath >/dev/null 2>&1; then GSTACK_REVIEW_LIB="$(cygpath -m "$GSTACK_REVIEW_LIB")" + GSTACK_SHIP_BUDGET_LIB="$(cygpath -m "$GSTACK_SHIP_BUDGET_LIB")" GSTACK_REVIEW_DIR="$(cygpath -m "$GSTACK_REVIEW_DIR")" fi ;; esac export GSTACK_REVIEW_LOG="$GSTACK_REVIEW_DIR/$BRANCH-reviews.jsonl" +export GSTACK_SHIP_BUDGET_MARKER="$GSTACK_REVIEW_DIR/$BRANCH-ship-budget.json" # Compute binding fields (best-effort; empty outside a git repo). COMMIT_FULL=$(git rev-parse HEAD 2>/dev/null || true) @@ -77,10 +84,28 @@ fi SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive) export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND" if [ "$INPUT" = --start ]; then - case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac - GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' + START_SKILL="${2:-}" START_FIX="" START_RESERVATION="" + if [ "$#" -ge 2 ]; then shift 2; else shift "$#"; fi + while [ "$#" -gt 0 ]; do + case "$1" in + --fix-range) START_FIX=--fix-range; shift ;; + --reservation) [ "$#" -ge 2 ] || { echo 'Usage: gstack-review-log --start SKILL [--fix-range] [--reservation ID]' >&2; exit 2; }; START_RESERVATION="$2"; shift 2 ;; + *) echo 'Usage: gstack-review-log --start SKILL [--fix-range] [--reservation ID]' >&2; exit 2 ;; + esac + done + if [ ! -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then + GSTACK_REVIEW_SKILL="$START_SKILL" GSTACK_REVIEW_FIX_RANGE="$START_FIX" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' + const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB); + try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); } + catch (e) { console.error(e.message); process.exit(1); } + ' + exit $? + fi + GSTACK_REVIEW_SKILL="$START_SKILL" GSTACK_REVIEW_FIX_RANGE="$START_FIX" GSTACK_REVIEW_RESERVATION="$START_RESERVATION" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB); - try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); } + const { admitReviewStart } = await import(process.env.GSTACK_SHIP_BUDGET_LIB); + const capture = () => captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range"); + try { console.log(admitReviewStart(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_RESERVATION || undefined, capture)); } catch (e) { console.error(e.message); process.exit(1); } ' exit $? @@ -93,7 +118,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then exit $? fi usage() { - echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2 + echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] [--reservation ID] | --check-shared-libs TOKEN < finding.json' >&2 echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2 exit 2 } @@ -126,6 +151,16 @@ try { echo "$STAMPED" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" +# A finished dispatch settles the budget reservation its start consumed (its +# completion receipt); stdout stays unchanged and a failure never undoes the row. +if [ -n "$TOKEN" ] && [ -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then + GSTACK_REVIEW_TOKEN="$TOKEN" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' + const { settleReviewFinish } = await import(process.env.GSTACK_SHIP_BUDGET_LIB); + try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservation " + id); } + catch (e) { console.error("review-log: budget reservation not settled: " + e.message); } + ' || true +fi + # gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). Plan B1: # an unattended session never enqueues, whatever the inherited sync mode. if [ "$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)" = "unattended" ]; then diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget new file mode 100755 index 000000000..ecb3277dc --- /dev/null +++ b/bin/gstack-ship-budget @@ -0,0 +1,50 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * 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. + * + * 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 ] + * settle --run|--record --reservation [--unknown] 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) + * verify-handoff --in [--reading ] [--session ] + * return --run --to [--changed ] [--status complete|refused|incomplete] [--reading ] + * reconcile --run|--record (--return |--handoff ) [--reading ] + * resume --record |--run [--reading ] [--meter ] [--session ] [--token-cap --approved-by --words ] + * [--rebuild-from-record] + * close --run|--record [--reading ] [--approved-by --words ] + * + * Exit: 0 continue/delegate · 3 stop (the next step does not start) · 2 usage + * or validation (stderr). Logic: lib/ship-budget.ts, lib/ship-budget-cli.ts, + * lib/ship-handoff.ts. + */ +import { BudgetError } from '../lib/ship-budget'; +import { cmdCheck, cmdClose, 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), + 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'); + (command && command !== '--help' && command !== '-h' ? console.error : console.log)(text); + process.exit(command && command !== '--help' && command !== '-h' ? 2 : 0); +} +try { + const out = commands[command]!(); + if (out.stdout.length) console.log(out.stdout.join('\n')); + process.exit(out.exit); +} catch (e) { + if (!(e instanceof BudgetError)) throw e; + if (e.exit === 3) console.log(e.message); else console.error(e.message); + process.exit(e.exit); +} diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index d0e793395..bb629e753 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -42,6 +42,8 @@ Run directories and scratch on Capy live under `~/.capy/work/` (`--out ~/. 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`). + ## Plan, approve, ship Every Capy thread that uses gstack follows these for new work, under a Captain or on its own. The rule in full is `docs/captain.md`, "Plan before build"; the lane side is section 3 of captain.md in this folder. diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md index a33dba03d..057573665 100644 --- a/capy/skill/gstack/captain.md +++ b/capy/skill/gstack/captain.md @@ -148,3 +148,8 @@ Every PR you send READY on, and the READY report itself, has: The READY report attaches the key image (Capy message attachment), so the Captain can show it inline. /ship does all of this when `GSTACK_CAPY=1` (its `capy-pr` section). Outside Capy, the PR body is unchanged. + +A token cap in a brief goes to /ship as `--token-cap `. When /ship stops on it (`STOP: +reason=budget_exhausted`, `status=incomplete`), the lane reports the BUDGET and RECOVER lines and asks the +owner; it never raises the cap itself. The owner's answer runs the RECOVER line with +`--approved-by --words ""`, and `gstack-approvals add` records the same words. diff --git a/docs/ship-budget.md b/docs/ship-budget.md new file mode 100644 index 000000000..ef7558364 --- /dev/null +++ b/docs/ship-budget.md @@ -0,0 +1,159 @@ +# /ship token budget and fresh-context handoffs + +`/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP=25M`) bounds what one /ship run spends, and +long runs hand their heavy segments to fresh-context children. The helper is +`bin/gstack-ship-budget`; the skill's procedure is `ship/sections/budget-handoff.md`. +There is no default cap: without one, /ship meters usage when it can and prints it, and +nothing else changes. + +## What the cap guarantees + +It is an **admission cap**, not a hard ceiling. Before every step and every dispatch batch, +`check` admits the work only when + + used + open reservations + unknown charges + projection + tail reserve + stop overhead <= cap + +Exactly at the cap is admitted; one token over is not. A parallel review batch is reserved as +one sum before any of it starts. Admitted work can still cost more than its projection, and +gstack cannot stop a host mid-turn from bash, so a run can finish above the cap by the +misprojection of work already admitted. The tail reserve (default 2M, `--reserve `) keeps +room for the receipt steps, and the stop overhead (calibrated) keeps room to stop cleanly. + +## Quickstart + +**Capy.** Read the root counter in one execute program that returns only that number: + +```js +return (await tools.thread.get({ thread_id: "this" })).cost.tokens; +``` + +then + +```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). + +**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 `. +The helper reads the session journal, its subagent journals and the journals of headless +children it launched; you never pass `--reading`. + +**Anywhere else.** `--meter estimate` projects with the calibration and prints `meter=estimate`. +With a cap set and no readable meter, start refuses +([BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable)). + +## The BUDGET line + + BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= next= projected= verdict=continue|delegate|stop + +| Field | Meaning | +|---|---| +| `used` | Metered usage since start (all epochs), or settled estimates under `meter=estimate` | +| `reserved` | Open reservations: admitted steps, started dispatches, and each open child's sub-cap counted as max(sub_cap, child_used), never both | +| `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 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 | +| `next`, `projected` | What the check was for and its calibrated projection | +| `verdict` | `continue` (exit 0, prints `RESERVATION:` 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, +an incomplete close and a child's return; /ship's real terminal line stays Step 22's. + +## Dispatches + +Under a cap, `gstack-review-log --start` refuses a dispatch without an unspent, one-shot +reservation ([BUDGET_CHECK_SKIPPED](troubleshooting.md#budget-check-skipped)): + +```bash +gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team --reading +gstack-review-log --start review --reservation +``` + +Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, +`fix-check`. `--finish ` settles the reservation at the dispatch's completion receipt. +`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. + +## Delegation + +At start, a per-call charge of 100k or more delegates the whole /ship. Before Step 7 and before +Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11.5, 12–22): + +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 + the child's sub-cap: min(cap − used − reserved − tail, the segment estimate), or `--sub-cap`. +2. The child is a Capy subagent on the shared machine, or on Claude Code + `gstack-ship-budget launch --in `: a background `claude -p` (resolved through lib/claude-bin.ts) + 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, is the only writer of the record, and never + delegates again: a delegate rule stops it. It ends with `return` (at most 40 lines). +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. + +`--delegate never` turns delegate into stop for hosts without children. + +## Stop and resume + +A stop prints + + STOP: reason=budget_exhausted spent= reserved= unknown= admission_cap= next= projected= + RECOVER: /ship --resume --token-cap + GSTACK_RESULT: skill=ship status=incomplete run= + +Raising the cap is the owner's call; the resume journals who raised it and their words: + +```bash +gstack-ship-budget resume --record --token-cap --approved-by Garry --words "raise it to finish" --reading +``` + +Every command takes `--run` or `--record`; runs are never found by branch or directory, and a +/ship without `--resume` or `--continue` always starts a new run. The ledger and record are +mirrored to a private Drive checkpoint (`GSTACK_SHIP_BUDGET_CHECKPOINT_DIR`, else the +project-user Drive folder on Capy) bound to the run id and their hashes, so after a machine +change `resume --run ` restores both. A corrupt ledger +([BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid)) is rebuilt from the +record's block with `--rebuild-from-record`; open reservations are then charged as unknown. + +## Calibration + +`lib/ship-budget-calibration.json` holds per-meter constants (per call, per dispatch, stop +overhead, calls and dispatches per step) fitted from metered runs: each constant is the +largest observation times 1.5, rounded up to 10,000 tokens. The leave-one-out test +(test/ship-budget-calibration.test.ts) covers the per-call constant only, the one observed +across three runs; per-dispatch has two observations and stop overhead one. Only the Capy +meter has observations; the Claude Code and estimate units are seeded from it until a +Claude-metered /ship completes. + +## Token cap and USD spend cap + +A lane's `--spend-cap` (USD) bounds paid gates and evals through lib/spend-ledger.ts; `--token-cap` +bounds the /ship run's model tokens through its own ledger. They are independent: set both +when a lane runs paid gates inside /ship, and neither converts into the other. + +## Claiming savings + +Savings count completed ships only. Preregister the comparison with `/eval-plan` (arms, bars, +stop rules) before claiming a saving from the cap or from delegation. + +## Result codes + +[BUDGET_CAP_REACHED](troubleshooting.md#budget-cap-reached) · +[BUDGET_READING_MISSING](troubleshooting.md#budget-reading-missing) · +[BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected) · +[BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable) · +[BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid) · +[BUDGET_CHECK_SKIPPED](troubleshooting.md#budget-check-skipped) · +[HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f7f904eab..e006d53d2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1823,6 +1823,92 @@ all the command prints `SHOT_PNG: unavailable (...)` and exits 0 instead. **Fix.** Screenshot `shot.html` with your own browser at 880 px wide, or use `text.md`. +## /ship token budget and fresh-context handoffs (gstack-ship-budget) + +The flow, the BUDGET line field by field and the meters are in [ship-budget.md](ship-budget.md). +Exit 2 lines go to stderr; exit 3 lines (a stop) go to stdout with the STOP, RECOVER and +GSTACK_RESULT lines. + + +### `gstack-ship-budget: ... (BUDGET_CAP_REACHED)` + +**Meaning.** The next step or dispatch batch would push used + open reservations + unknown +charges + its projection + the tail and stop reserve past the admission cap, so it did not +start. Exit 3. `gstack-review-log --start` prints the same code (exit 1) for a dispatch +after the run stopped. The cap admits work; it cannot stop a turn already running, so the +overshoot is bounded by the misprojection of work admitted before the stop. + +**Fix.** The run ended status=incomplete with a resumable record. Raising the cap is the +owner's call: run the printed `RECOVER:` line; its resume needs +`--approved-by --words ""`. + + +### `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 +`--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 `. + + +### `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 +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). +A different meter opens a new epoch that keeps prior usage: +`gstack-ship-budget resume --run --meter `. + + +### `gstack-ship-budget: ... (BUDGET_METER_UNAVAILABLE)` + +**Meaning.** A cap is set and no meter is readable: no `--reading`, no bound Claude Code +session, no `--meter estimate`; or a `--session`/`--nonce` matched zero or several journals. +The line names what it tried. Exit 2. + +**Fix.** Capy: `--reading `. Claude Code: `--session `, or echo the nonce the line prints +once and rerun with `--nonce `. Elsewhere, with the owner's consent, `--meter estimate`. + + +### `gstack-ship-budget: ... (BUDGET_LEDGER_INVALID)` + +**Meaning.** The ledger (`/ship-runs//budget.json`), the record's +gstack-budget block, a handoff brief's block or a checkpoint is missing, unparseable, from +another schema version, or not bound to the run (identity or sha256 mismatch). The line names +the path, the schema version and the parse location. Exit 2. + +**Fix.** After a machine change, `gstack-ship-budget resume --run ` restores the ledger and +record from the Drive checkpoint. Otherwise rebuild: +`resume --record --rebuild-from-record --reading `; open reservations are charged as unknown. + + +### `gstack-review-log: ... (BUDGET_CHECK_SKIPPED)` / `gstack-ship-budget: ... role= step= started= spend=unknown (BUDGET_CHECK_SKIPPED)` + +**Meaning.** Under a cap, `gstack-review-log --start` got no reservation id, an id from another run +or a step reservation (mismatched), or an id already used (one-shot); it refused before the +dispatch (exit 1, nothing captured). From `close`: a review-log row of this run had no +reservation before it started; one line per dispatch, and the run ends status=incomplete (exit 3). + +**Fix.** Run `gstack-ship-budget check --run --step --next dispatch:` before the +batch and pass the printed id once with `--reservation`. A reservation is never created after the +fact: the owner accepts the unreserved spend with `close --approved-by --words ""` +or the dispatches rerun under reservations. + + +### `gstack-ship-budget: ... (HANDOFF_PIN_MOVED)` + +**Meaning.** `verify-handoff` found the brief no longer matches its tree: HEAD, the merge base +with the base ref, the working-tree fingerprint (`gstack-wtree`) or the reviewed tree moved, or the +brief's sha256 is not the one the parent recorded. Each line names the pin with its expected +and actual value. Main moving alone keeps the merge base and never refuses. Exit 3; the child +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. + ## Browser diff --git a/docs/unattended.md b/docs/unattended.md index f4ed5bfdd..905de39cc 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -187,6 +187,15 @@ most 300 words; a subagent brief names the files to read instead of pasting them. Evidence goes in files the report points to. Waits go on operations, never on polling wakes. +### Token caps and fresh-context handoffs + +A parent bounds a /ship run with `--token-cap ` (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 +and 12–22 to fresh-context children. The flow, the BUDGET line and the result +codes are in [ship-budget.md](ship-budget.md). + ### The outside voice on Capy Step 11's outside adversarial review prefers the Codex CLI. On a Capy machine diff --git a/lib/claude-usage.ts b/lib/claude-usage.ts new file mode 100644 index 000000000..b7ed4318a --- /dev/null +++ b/lib/claude-usage.ts @@ -0,0 +1,135 @@ +/** + * claude-usage — the Claude Code meter for the /ship budget (GSTA-152 D1). A + * live-session reader beside lib/claude-journal-records.ts (record identity and + * ownership) and lib/claude-public-transcript.ts (question metadata): it only + * sums `message.usage`. A session is bound explicitly, by `--session ` or by + * a nonce the agent echoed once (zero or several matching journals refuse). + * Its journal, its subagent journals (`/subagents/*.jsonl`) and the + * journals of headless children the handoff recorded all count. Each API call + * is counted once per (message.id, requestId) at the maximum of each usage + * field, which folds streaming duplicates and the copies a compaction or fork + * writes into another journal; compaction summaries carry no charge. Reads are + * incremental from a stored offset, guarded by a hash of the file's first + * bytes: a rewritten journal is read again from the start. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const FIELDS = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens'] as const; +const PREFIX_BYTES = 4096; +const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export interface ClaudeMeterState { + schema_version: 1; + config_dir: string; + sessions: string[]; + files: Record; + keys: Record; + last_key: string | null; +} + +export const claudeConfigDir = (env: NodeJS.ProcessEnv = process.env) => env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude'); + +function projectDirs(configDir: string): string[] { + const projects = path.join(configDir, 'projects'); + try { return fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => path.join(projects, d.name)); } + catch { return []; } +} + +/** Journals named for a session id, across project directories. */ +export function sessionJournals(configDir: string, session: string): string[] { + if (!SESSION.test(session)) return []; + return projectDirs(configDir).map(d => path.join(d, `${session}.jsonl`)).filter(f => fs.existsSync(f)); +} + +/** Sessions whose journal contains the nonce; the caller refuses unless exactly one matches. */ +export function nonceSessions(configDir: string, nonce: string): string[] { + if (!/^[A-Za-z0-9_-]{8,}$/.test(nonce)) return []; + const found = new Set(); + for (const dir of projectDirs(configDir)) { + for (const name of fs.readdirSync(dir)) { + if (!name.endsWith('.jsonl') || !SESSION.test(name.slice(0, -6))) continue; + if (fs.readFileSync(path.join(dir, name), 'utf8').includes(nonce)) found.add(name.slice(0, -6)); + } + } + return [...found]; +} + +/** A session's own journal first, then its subagent journals. */ +export function sessionFiles(configDir: string, session: string): string[] { + return sessionJournals(configDir, session).flatMap(main => { + const sub = path.join(path.dirname(main), session, 'subagents'); + let extra: string[] = []; + try { extra = fs.readdirSync(sub).filter(n => n.endsWith('.jsonl')).sort().map(n => path.join(sub, n)); } catch {} + return [main, ...extra]; + }); +} + +export function newMeterState(configDir: string, sessions: string[]): ClaudeMeterState { + return { schema_version: 1, config_dir: configDir, sessions: [...sessions], files: {}, keys: {}, last_key: null }; +} + +function prefixHash(fd: number, length: number): string { + const buf = Buffer.alloc(length); + fs.readSync(fd, buf, 0, buf.length, 0); + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +function absorb(state: ClaudeMeterState, line: string, isRoot: boolean): void { + if (!line.includes('"usage"')) return; + let rec: any; + try { rec = JSON.parse(line); } catch { return; } + if (rec?.type !== 'assistant' || rec.isCompactSummary || typeof rec.message?.usage !== 'object' || rec.message.usage === null) return; + const key = `${rec.message.id ?? rec.uuid}|${rec.requestId ?? ''}`; + const prev = state.keys[key] ?? [0, 0, 0, 0]; + state.keys[key] = FIELDS.map((f, i) => Math.max(prev[i]!, Number.isFinite(rec.message.usage[f]) ? Number(rec.message.usage[f]) : 0)); + if (isRoot && !rec.isSidechain) state.last_key = key; +} + +/** Read new complete lines of one file; false when the file was rewritten under us. */ +function readIncrement(state: ClaudeMeterState, file: string, isRoot: boolean): boolean { + const fd = fs.openSync(file, 'r'); + try { + const size = fs.fstatSync(fd).size; + const seen = state.files[file]; + if (seen && (size < seen.offset || prefixHash(fd, seen.prefix_len) !== seen.prefix)) return false; + const start = seen?.offset ?? 0; + const buf = Buffer.alloc(size - start); + if (buf.length) fs.readSync(fd, buf, 0, buf.length, start); + const end = buf.lastIndexOf(0x0a) + 1; + for (const line of buf.subarray(0, end).toString('utf8').split('\n')) if (line) absorb(state, line, isRoot); + const prefixLen = Math.min(PREFIX_BYTES, size); + state.files[file] = { offset: start + end, prefix_len: prefixLen, prefix: prefixHash(fd, prefixLen) }; + return true; + } finally { fs.closeSync(fd); } +} + +const total = (v: number[] | undefined) => (v ?? []).reduce((s, n) => s + n, 0); + +/** The meter reading: every charge of the bound sessions, and the last root call's charge. */ +export function readClaudeUsage(state: ClaudeMeterState): { total: number; lastCall: number | null; files: number } { + const files = state.sessions.flatMap((s, i) => sessionFiles(state.config_dir, s).map((f, j) => ({ f, root: i === 0 && j === 0 }))); + for (const { f, root } of files) { + if (readIncrement(state, f, root)) continue; + state.files = {}; + state.keys = {}; + state.last_key = null; + for (const again of files) readIncrement(state, again.f, again.root); + break; + } + const sum = Object.values(state.keys).reduce((s, v) => s + total(v), 0); + return { total: sum, lastCall: state.last_key ? total(state.keys[state.last_key]) : null, files: files.length }; +} + +export function loadMeterState(file: string): ClaudeMeterState | undefined { + try { return JSON.parse(fs.readFileSync(file, 'utf8')) as ClaudeMeterState; } catch { return undefined; } +} + +export function saveMeterState(file: string, state: ClaudeMeterState): void { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 }); + fs.renameSync(tmp, file); +} diff --git a/lib/result-codes-budget.ts b/lib/result-codes-budget.ts new file mode 100644 index 000000000..28ef0abc2 --- /dev/null +++ b/lib/result-codes-budget.ts @@ -0,0 +1,47 @@ +/** + * Result codes for the /ship token budget (bin/gstack-ship-budget, the + * reservation check in `gstack-review-log --start`) and the fresh-context + * handoff (lib/ship-handoff.ts). Kept beside lib/result-codes.ts, which spreads + * this table into RESULT_CODES; the same rules apply: one stable + * docs/troubleshooting.md anchor, a summary and a fix clause each. The flow is + * documented in docs/ship-budget.md. + */ +import type { ResultCode } from './result-codes'; + +export const BUDGET_RESULT_CODES = { + BUDGET_CAP_REACHED: { + anchor: 'budget-cap-reached', + summary: 'the next step or dispatch would push used + open reservations + its projection + the tail and stop reserve past the admission cap, so it did not start', + fix: 'the run stopped status=incomplete with a resumable record; raising the cap is the owner\'s call: run the printed RECOVER line, whose resume needs --approved-by --words ', + }, + 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)', + }, + BUDGET_READING_REJECTED: { + anchor: 'budget-reading-rejected', + summary: 'the reading does not fit the run\'s meter contract: it is lower than the last reading of the same epoch, it was passed to a meter that reads its own source, or --prior-reading is above --reading', + fix: 'pass the counter the run started with (the BUDGET line names meter, unit and epoch); a different meter starts a new epoch through gstack-ship-budget resume --meter ', + }, + BUDGET_METER_UNAVAILABLE: { + anchor: 'budget-meter-unavailable', + summary: 'a cap is set and no meter is readable: no --reading, no bound Claude Code session and no --meter estimate', + fix: 'pass --reading on Capy, --session or --nonce on Claude Code, or --meter estimate to accept the conservative estimate (printed meter=estimate)', + }, + BUDGET_LEDGER_INVALID: { + anchor: 'budget-ledger-invalid', + summary: 'the budget ledger or the record\'s gstack-budget block is missing, unparseable, or from another schema version', + fix: 'restore it from the Drive checkpoint (gstack-ship-budget resume --run ) or rebuild it from the record with resume --record --rebuild-from-record --reading ; open reservations are then charged as unknown', + }, + BUDGET_CHECK_SKIPPED: { + anchor: 'budget-check-skipped', + summary: 'a dispatch under a cap started without an unspent reservation id from gstack-ship-budget check, or reused or mismatched one', + fix: 'run gstack-ship-budget check --run --step --next dispatch: before the dispatch and pass its RESERVATION id once; a reservation is never created after the fact, so an unreserved dispatch ends the run status=incomplete for the owner to accept', + }, + HANDOFF_PIN_MOVED: { + anchor: 'handoff-pin-moved', + summary: 'the handoff brief no longer matches the tree it was written for: HEAD, the merge base, the working tree or the brief\'s own hash moved', + fix: 'the child returns status=refused; the parent regenerates the handoff at the current tree (gstack-ship-budget handoff ...) or stops; main moving alone never refuses', + }, +} as const satisfies Record; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 46ca94bd7..80e6f16f1 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -12,6 +12,7 @@ */ import { RELEASE_RESULT_CODES } from './result-codes-release'; import { CAPY_RESULT_CODES } from './result-codes-capy'; +import { BUDGET_RESULT_CODES } from './result-codes-budget'; export interface ResultCode { /** Stable `` in docs/troubleshooting.md. */ @@ -770,6 +771,7 @@ export const RESULT_CODES = { }, ...RELEASE_RESULT_CODES, ...CAPY_RESULT_CODES, + ...BUDGET_RESULT_CODES, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/lib/ship-budget-calibration.json b/lib/ship-budget-calibration.json new file mode 100644 index 000000000..225540983 --- /dev/null +++ b/lib/ship-budget-calibration.json @@ -0,0 +1,77 @@ +{ + "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.", + "headroom": 1.5, + "round_to": 10000, + "observations": { + "per_call": [ + { + "run": "gstack-evals#19", + "value": 240000, + "source": "GSTA-152 plan v3 evidence: late parent calls charged 0.22M then 0.24M (solved from the 29.3M and 16.3M counter windows; derived)" + }, + { + "run": "gstack-evals#26", + "value": 400000, + "source": "GSTA-152 plan v3 evidence: late turns cost about 0.4M each (root counter read after every step)" + }, + { + "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" + } + ], + "per_dispatch": [ + { + "run": "gstack-evals#19", + "value": 2400000, + "source": "GSTA-152 plan v3 evidence: about 2.4M per child, solved from the two counter windows (derived)" + }, + { + "run": "gstack-evals#31", + "value": 4950000, + "source": "GSTA-152 plan v3 evidence: the /ship child and its 10 review dispatches account for about 49.5M (4-5M per dispatch)" + } + ], + "stop_overhead": [ + { + "run": "gstack-evals#26", + "value": 1650000, + "source": "GSTA-152 plan v3 evidence: the stop message and the parent's checks took the counter from 39.40M to 41.05M" + } + ] + }, + "constants": { + "per_call": 750000, + "per_dispatch": 7430000, + "stop_overhead": 2480000 + }, + "step_calls": { + "7": 12, + "8": 12, + "9": 12, + "default": 3 + }, + "step_dispatches": { + "7": 1, + "8": 1, + "14.5": 1 + }, + "segment_dispatches": { + "9": 4, + "11": 2 + }, + "units": { + "capy-tokens": { + "fit": "observations above (Capy root counter, children included)" + }, + "claude-usage-tokens": { + "seeded_from": "capy-tokens", + "why": "no Claude Code-metered /ship yet; refit once one completes" + }, + "estimate-tokens": { + "seeded_from": "capy-tokens", + "why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs" + } + } +} diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts new file mode 100644 index 000000000..d761c2e74 --- /dev/null +++ b/lib/ship-budget-cli.ts @@ -0,0 +1,299 @@ +/** + * ship-budget-cli — the commands of bin/gstack-ship-budget (docs/ship-budget.md): + * start, check, settle, release, close and resume here; handoff, verify-handoff, + * return and reconcile in lib/ship-handoff.ts. Every command returns its lines + * and exit code: 0 continue or delegate, 3 stop (the next step does not start), + * 2 usage or validation (stderr, with the code, the value and the repair). + * GSTACK_RESULT is printed only on a stop, an incomplete close and a child's + * return; the run's real terminal line stays /ship Step 22's. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resultLine } from './result-codes'; +import { + BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, + admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, newEpoch, + parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, readLedger, reserveIn, restoreRun, reviewPaths, + runDirFor, segmentEstimate, settleIn, stepProjection, projection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, + type Ledger, type MeterKind, type Next, +} from './ship-budget'; +import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage'; + +export type Opts = Record; +export interface Outcome { stdout: string[]; exit: number } +export interface Ctx { env: NodeJS.ProcessEnv; cwd: string } + +export function parseArgs(argv: string[], values: string[], flags: string[] = []): Opts { + const out: Opts = {}; + for (let i = 0; i < argv.length; i++) { + const name = argv[i]!.replace(/^--/, ''); + if (!argv[i]!.startsWith('--')) throw usageError(`unexpected argument '${argv[i]}'`); + if (flags.includes(name)) { out[name] = true; continue; } + if (!values.includes(name)) throw usageError(`unknown flag --${name}; valid: ${[...values, ...flags].map(v => '--' + v).join(' ')}`); + const value = argv[++i]; + if (value === undefined) throw usageError(`--${name} needs a value`); + out[name] = value; + } + return out; +} + +export const str = (o: Opts, k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined); +export const tokensOpt = (o: Opts, k: string) => (str(o, k) === undefined ? undefined : parseTokens(str(o, k), `--${k}`)); +const meterFile = (l: Ledger) => path.join(l.dir, 'claude-meter.json'); + +/** 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 { + const reading = tokensOpt(o, 'reading'); + const prior = tokensOpt(o, 'prior-reading'); + const meter = l.meter.contract.meter; + if (meter !== 'capy') { + if (reading !== undefined) applyReading(l, reading, 'flag'); + if (meter === 'estimate') return null; + const state = loadMeterState(meterFile(l)) ?? newMeterState(l.meter.config_dir ?? claudeConfigDir(), l.meter.sessions ?? []); + const usage = readClaudeUsage(state); + saveMeterState(meterFile(l), state); + applyReading(l, usage.total, 'host'); + 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}`); + return null; + } + if (prior !== undefined && prior > reading) throw budgetError('BUDGET_READING_REJECTED', `--prior-reading ${prior} is above --reading ${reading}`); + applyReading(l, reading, 'flag'); + return prior === undefined ? null : reading - prior; +} + +/** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */ +export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] { + l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; + const t = tally(l); + const cal = loadCalibration(l.calibration); + const from = f.next.startsWith('dispatch:') ? f.step : f.next; + const tailCost = segmentEstimate(cal, l.meter.contract.unit, from, '22') + l.tail + l.stop_overhead; + const lines = [budgetLine(l, { ...f, verdict: 'stop' })]; + if (f.reason === 'budget_exhausted') { + lines.push(`STOP: reason=budget_exhausted spent=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap} next=${f.next} projected=${f.projected}`, + resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${l.tail + l.stop_overhead}`), + `RECOVER: /ship --resume ${l.record ?? ''} --token-cap ${t.used + t.reserved + t.unknown + tailCost}`); + } else { + lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} threshold=${DELEGATE_SEGMENT_CALL} next=${f.next} (${l.role === 'child' ? 'a child never delegates' : '--delegate never'})`, + `RECOVER: /ship --resume ${l.record ?? ''} in a fresh session`); + } + lines.push(`GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); + if (l.role === 'child' && l.parent) writeReturnFile(l.parent.return_file, [`RETURN: handoff=${l.parent.handoff_sha} status=incomplete steps=${l.parent.from}-${l.parent.to} next=${f.next}`, ...lines]); + return lines; +} + +export function writeReturnFile(file: string, lines: string[]): void { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 }); +} + +/** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */ +export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { + const cal = loadCalibration(l.calibration); + const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`; + const projected = projection(cal, l.meter.contract.unit, next); + if (!admits(tally(l), projected, l.tail + l.stop_overhead, l.cap)) { + return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 }; + } + const boundary = next.kind === 'step' && (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); + const wants = boundary && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL); + if (wants && (l.role === 'child' || l.delegate === 'never')) { + return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold' }), exit: 3 }; + } + if (wants && next.kind === 'step') { + return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }), + `DELEGATE: Steps ${next.step}-${SEGMENTS[next.step]} go to a fresh child: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${SEGMENTS[next.step]} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; + } + const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; + if (l.cap !== null) { + const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); + lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); + } + return { stdout: lines, exit: 0 }; +} + +/** Bind the Claude Code session: --session , or a nonce echoed once; zero or several matches refuse. */ +function bindClaude(o: Opts, env: NodeJS.ProcessEnv): string[] { + const dir = claudeConfigDir(env); + const session = str(o, 'session'); + if (session) { + if (sessionJournals(dir, session).length !== 1) throw budgetError('BUDGET_METER_UNAVAILABLE', `--session ${session}: ${sessionJournals(dir, session).length} journals under ${dir}/projects (need exactly one)`); + return [session]; + } + const nonce = str(o, 'nonce'); + if (!nonce) throw budgetError('BUDGET_METER_UNAVAILABLE', `meter=claude needs --session , or echo GSTACK_BUDGET_NONCE=${crypto.randomBytes(8).toString('hex')} once and rerun with --nonce `); + const found = nonceSessions(dir, nonce); + if (found.length !== 1) throw budgetError('BUDGET_METER_UNAVAILABLE', `--nonce ${nonce}: ${found.length} journals under ${dir}/projects contain it (need exactly one)`); + return found; +} + +function pickMeter(o: Opts): MeterKind | undefined { + const m = str(o, 'meter'); + if (m !== undefined && !['capy', 'claude', 'estimate'].includes(m)) throw usageError(`--meter '${m}' is not capy, claude or estimate`); + if (m) return m as MeterKind; + if (str(o, 'reading') !== undefined) return 'capy'; + if (str(o, 'session') || str(o, 'nonce')) return 'claude'; + return 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']); + const record = str(o, 'record'); + if (!record) throw usageError('start needs --record '); + const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP; + const cap = rawCap === undefined || rawCap === 'none' ? null : parseTokens(rawCap, '--token-cap'); + const delegate = str(o, 'delegate') ?? 'auto'; + if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`); + const existing = parseRecordBlock(record); + if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) { + const l = readLedger(existing.ledger); + return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 }; + } + const meter = pickMeter(o); + if (!meter) { + if (cap !== null) throw budgetError('BUDGET_METER_UNAVAILABLE', 'tried --reading (absent), a Claude Code session (--session/--nonce absent) and --meter estimate (not passed)'); + return { stdout: ['BUDGET: unmetered admission_cap=none (no readable meter: --reading on Capy, --session on Claude Code)'], exit: 0 }; + } + const run = mintRunId(); + const dir = runDirFor(ctx.env, run); + const cal = loadCalibration(str(o, 'calibration')); + const contract = METER_CONTRACTS[meter]; + const paths = reviewPaths(ctx.cwd, ctx.env); + const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined; + const l: Ledger = { + schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: str(o, 'ledger') ?? path.join(dir, 'budget.json'), record: path.resolve(record), + created_at: new Date().toISOString(), cap, tail: tokensOpt(o, 'reserve') ?? DEFAULT_TAIL, stop_overhead: unitConstants(cal, contract.unit).stop_overhead, + 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')!) } : {}), + }; + if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy'); + if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!; + const call = readMeter(l, o, true); + if (meter === 'claude') l.meter.baseline = l.meter.last ?? 0; + const out = decide(l, '0.9', { kind: 'step', step: '1' }, call, true); + writeLedger(l, ctx.env); + pointMarker(l.marker, l); + return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }; +} + +export function cmdCheck(argv: string[], ctx: Ctx): Outcome { + const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading']); + const step = parseStep(str(o, 'step')); + const next = parseNext(str(o, 'next')); + 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 }; + for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); + const call = readMeter(l, o, true); + return decide(l, step, next, call); + }); +} + +export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome { + const o = parseArgs(argv, ['run', 'record', 'reservation'], release ? [] : ['unknown']); + const id = str(o, 'reservation'); + if (!id) throw usageError(`${release ? 'release' : 'settle'} needs --reservation `); + 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'; } + else settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) }); + return { stdout: [`${release ? 'RELEASED' : 'SETTLED'}: id=${id} state=${r.state}`], exit: 0 }; + }); +} + +export function cmdClose(argv: string[], ctx: Ctx): Outcome { + const o = parseArgs(argv, ['run', 'record', 'reading', 'approved-by', 'words']); + return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { + for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); + readMeter(l, o, false); + 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 accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []); + const skipped = l.cap === null ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started)); + if (skipped.length && str(o, 'approved-by') && str(o, 'words')) { + (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started)]; + lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); + } else if (skipped.length) { + for (const s of skipped) lines.push(resultLine('gstack-ship-budget', 'BUDGET_CHECK_SKIPPED', `role=${s.skill} step=${s.step} started=${s.started} spend=unknown`)); + l.stopped = { at: new Date().toISOString(), reason: 'budget_check_skipped', step: '22', next: '22' }; + lines.push(`STOP: reason=budget_check_skipped dispatches=${skipped.length}; owner decision: accept the unreserved spend (close --record ${l.record} --approved-by --words ) or rerun them under reservations`, + `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); + return { stdout: lines, exit: 3 }; + } + l.closed_at = new Date().toISOString(); + if (l.marker) { try { fs.unlinkSync(l.marker); } catch {} } + return { stdout: lines, exit: 0 }; + }); +} + +/** 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 cap = str(o, 'token-cap'); + if (cap !== undefined && (!str(o, 'approved-by') || !str(o, 'words'))) throw usageError('resume --token-cap needs --approved-by --words ; raising the cap is the owner\'s call'); + const lines: string[] = []; + let file: string; + const record = str(o, 'record'); + const block = record ? parseRecordBlock(record) : undefined; + const run = str(o, 'run') ?? block?.run; + file = block?.ledger ?? ledgerFileFor({ run }, ctx.env); + if (!fs.existsSync(file) && run) { + const restored = restoreRun(run, ctx.env); + if (restored) { file = restored.ledger; lines.push(`RESTORED: ledger=${restored.ledger} record=${restored.record ?? 'none'} from=${restored.from}`); } + } + if (o['rebuild-from-record'] === true) { + try { readLedger(file); } catch { rebuildLedger(file, record, ctx, o); lines.push(`REBUILT: ledger=${file} from=${record}`); } + } + const out = mutateLedger(file, ctx.env, l => { + const meter = pickMeter(o) ?? l.meter.contract.meter; + if (meter !== l.meter.contract.meter) { + const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined; + newEpoch(l, METER_CONTRACTS[meter], meter === 'capy' ? tokensOpt(o, 'reading') ?? 0 : 0); + if (sessions) { l.meter.sessions = sessions; l.meter.config_dir = claudeConfigDir(ctx.env); try { fs.unlinkSync(path.join(l.dir, 'claude-meter.json')); } catch {} } + const call = readMeter(l, { ...o, ...(meter === 'capy' ? {} : { reading: undefined as any }) }, false); + if (meter === 'claude') { l.meter.baseline = l.meter.last ?? 0; } + void call; + lines.push(`EPOCH: ${l.meter.epoch} meter=${meter} unit=${l.meter.contract.unit} carried_used=${l.carried_used}`); + } else readMeter(l, o, false); + if (cap !== undefined) { + const to = cap === 'none' ? null : parseTokens(cap, '--token-cap'); + l.cap_raises.push({ from: l.cap, to, by: str(o, 'approved-by')!, words: str(o, 'words')!, at: new Date().toISOString() }); + l.cap = to; + } + const was = l.stopped; + delete l.stopped; + pointMarker(l.marker, l); + return [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, `RESUMED: from=${was ? `Step ${was.step} (${was.reason})` : 'running'}`, budgetLine(l, { call: null, next: was?.next ?? '-', projected: 0, verdict: 'continue' })]; + }); + return { stdout: [...lines, ...out], exit: 0 }; +} + +/** Rebuild a ledger from the record's gstack-budget block; open reservations are charged as unknown. */ +function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Opts): void { + const b = record ? parseRecordBlock(record) : undefined; + if (!b?.run || !b.meter || !(b.meter in METER_CONTRACTS)) throw budgetError('BUDGET_LEDGER_INVALID', `${record ?? '--record'}: no usable gstack-budget block to rebuild from`); + const meter = b.meter as MeterKind; + const reading = tokensOpt(o, 'reading'); + if (meter === 'capy' && reading === undefined) throw budgetError('BUDGET_READING_MISSING', 'resume --rebuild-from-record on meter=capy'); + const num = (k: string) => Number(b[k] ?? 0) || 0; + const l: Ledger = { + schema_version: 1, kind: 'gstack-ship-budget', run: b.run, dir: path.dirname(file), file, record: path.resolve(record!), + created_at: new Date().toISOString(), cap: b.cap === 'none' ? null : num('cap'), tail: num('tail') || DEFAULT_TAIL, + stop_overhead: unitConstants(loadCalibration(), METER_CONTRACTS[meter].unit).stop_overhead, delegate: 'auto', role: b.role === 'child' ? 'child' : 'parent', + 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 {}; } })(), + }; + if (meter === 'estimate') l.estimate_used = 0; + fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 }); + writeLedger(l, ctx.env); +} + +export { SHIP_STEPS, stepProjection, BudgetError }; diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts new file mode 100644 index 000000000..a3c3d18e1 --- /dev/null +++ b/lib/ship-budget.ts @@ -0,0 +1,410 @@ +/** + * ship-budget — the /ship token budget (GSTA-152, docs/ship-budget.md): an + * ADMISSION cap on a token ledger, never a hard ceiling. A step or dispatch + * starts only when used + open reservations + unknown charges + its projection + * + the parent's tail reserve + the stop overhead fits under the cap; the + * overshoot is bounded by the misprojection of work already admitted. + * + * The ledger is its own schema (build-spec r2 item 7, the "reuse only + * withFileLock" choice): one JSON file per run under + * /ship-runs//budget.json (mode 0600), mutated only under + * lib/spend-ledger.ts's withFileLock, so lib/autoplan-budget.ts's USD ledger is + * untouched. Meters are versioned contracts (unit, host field, scope, + * descendants, epoch); a reading lower than the last one of its epoch is + * rejected and a meter change opens a new epoch that keeps prior usage. Under a + * cap, start writes a per-branch marker next to the review log so + * gstack-review-log --start refuses a dispatch without an unspent reservation; + * without a cap no marker exists and review-log behaves as before. Commands: + * lib/ship-budget-cli.ts; handoffs: lib/ship-handoff.ts. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { withFileLock } from './spend-ledger'; +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'; + +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[]; +type MissingRole = Exclude; +const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true; +void ROLES_COMPLETE; +/** D3: per-call charge that runs the whole /ship in a fresh child, and the one that delegates a heavy segment. */ +export const DELEGATE_START_CALL = 100_000; +export const DELEGATE_SEGMENT_CALL = 150_000; +/** Segment boundaries (before Step 7, before Step 12) and the last step each segment runs. */ +export const SEGMENTS: Record = { '1': '22', '7': '11.5', '12': '22' }; +export const DEFAULT_TAIL = 2_000_000; +const DIFF_REVIEW_STEP: Record = { 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 const METER_CONTRACTS: Record = { + capy: { version: 1, meter: 'capy', unit: 'capy-tokens', host_field: 'tools.thread.get({ thread_id: "this" }).cost.tokens', scope: 'root', 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 }, +}; + +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; +} +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; +} +export interface Ledger { + schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string; + cap: number | null; tail: number; stop_overhead: number; delegate: 'auto' | 'never'; role: 'parent' | 'child'; + parent?: { run: string; ledger: string; handoff_sha: string; from: string; to: string; return_file: string }; + meter: { contract: MeterContract; epoch: number; baseline: number; last: number | null; last_at?: string; sessions?: string[]; config_dir?: string }; + epochs: Array<{ epoch: number; contract: MeterContract; baseline: number; last: number | null; used: number }>; + carried_used: number; estimate_used: number; unknown: number; next_id: number; + reservations: Reservation[]; handoffs: Handoff[]; + cap_raises: Array<{ from: number | null; to: number | null; by: string; words: string; at: string }>; + marker?: string; review_log?: string; calibration?: string; + stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string; +} + +/** A refusal: exit 2 for usage and validation (stderr), exit 3 for a stop. */ +export class BudgetError extends Error { + constructor(public code: ResultCodeName | 'usage', message: string, public exit = 2) { super(message); } +} +export const budgetError = (code: ResultCodeName, detail: string, exit = 2) => new BudgetError(code, resultLine('gstack-ship-budget', code, detail), exit); +export const usageError = (detail: string) => new BudgetError('usage', `gstack-ship-budget: ${detail} (usage)`); + +export function parseTokens(raw: string | undefined, flag: string): number { + const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec((raw ?? '').trim()); + if (!m) throw usageError(`${flag} '${raw ?? ''}' is not a token count; fix: pass an integer or a k/M suffix, e.g. 25M or 400k`); + const scale = m[2]!.toLowerCase() === 'k' ? 1e3 : m[2]!.toLowerCase() === 'm' ? 1e6 : 1; + return Math.round(Number(m[1]) * scale); +} + +export function parseStep(raw: string | undefined, flag = '--step'): string { + if (raw && (SHIP_STEPS as readonly string[]).includes(raw)) return raw; + throw usageError(`${flag} '${raw ?? ''}' is not a ship step id; valid: ${SHIP_STEPS.join(', ')}; fix: use a step number from ship/SKILL.md.tmpl`); +} + +export type Next = { kind: 'step'; step: string } | { kind: 'dispatch'; roles: string[] }; +export function parseNext(raw: string | undefined): Next { + if (raw?.startsWith('dispatch:')) { + const roles = raw.slice('dispatch:'.length).split(',').filter(Boolean); + const bad = roles.filter(r => !(DISPATCH_ROLES as readonly string[]).includes(r)); + if (roles.length === 0 || bad.length) throw usageError(`--next '${raw}' names unknown roles (${bad.join(', ') || 'none given'}); valid: ${DISPATCH_ROLES.join(', ')}; fix: list the plan's roles, e.g. dispatch:specialist,red-team`); + return { kind: 'dispatch', roles }; + } + return { kind: 'step', step: parseStep(raw, '--next') }; +} + +export interface Calibration { + schema_version: 1; headroom: number; round_to: number; + observations: Record>; + constants: Record<'per_call' | 'per_dispatch' | 'stop_overhead', number>; + step_calls: Record; step_dispatches: Record; segment_dispatches: Record; + units: Record; +} +export const CALIBRATION_FILE = path.join(import.meta.dir, 'ship-budget-calibration.json'); + +/** Conservative fit (plan item 8): the largest observation times the headroom, rounded up. */ +export function fitConstant(values: number[], headroom: number, roundTo: number): number { + return Math.ceil((Math.max(...values) * headroom) / roundTo) * roundTo; +} + +export function loadCalibration(file = CALIBRATION_FILE): Calibration { + try { return JSON.parse(fs.readFileSync(file, 'utf8')) as Calibration; } + catch (e: any) { throw usageError(`--calibration ${file} is unreadable: ${e.message}`); } +} + +/** Constants for a unit: its own fit, or the unit it was seeded from (calibration is per meter). */ +export function unitConstants(cal: Calibration, unit: string): Calibration['constants'] { + const entry = cal.units[unit]; + if (!entry) throw usageError(`calibration has no entry for unit ${unit}`); + 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 function projection(cal: Calibration, unit: string, next: Next): number { + return next.kind === 'dispatch' ? next.roles.length * unitConstants(cal, unit).per_dispatch : stepProjection(cal, unit, next.step); +} + +/** Steps from..to inclusive plus their usual reviewer batches: a child's segment estimate and the RECOVER tail. */ +export function segmentEstimate(cal: Calibration, unit: string, from: string, to: string): number { + const i = SHIP_STEPS.indexOf(from as any), j = SHIP_STEPS.indexOf(to as any); + const k = unitConstants(cal, unit); + return SHIP_STEPS.slice(i, j + 1).reduce((sum, s) => sum + stepProjection(cal, unit, s) + (cal.segment_dispatches[s] ?? 0) * k.per_dispatch, 0); +} + +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')}`; +} + +export function readLedger(file: string): Ledger { + let text: string; + try { text = fs.readFileSync(file, 'utf8'); } + catch (e: any) { throw budgetError('BUDGET_LEDGER_INVALID', `${file}: ${e.code === 'ENOENT' ? 'missing' : e.message}`); } + let parsed: any; + try { parsed = JSON.parse(text); } + catch (e: any) { throw budgetError('BUDGET_LEDGER_INVALID', `${file}: schema_version unknown, parse error: ${e.message}`); } + if (parsed?.kind !== 'gstack-ship-budget' || parsed.schema_version !== 1) { + throw budgetError('BUDGET_LEDGER_INVALID', `${file}: schema_version ${JSON.stringify(parsed?.schema_version)} kind ${JSON.stringify(parsed?.kind)}, expected 1 gstack-ship-budget`); + } + return parsed as Ledger; +} + +function writePrivate(file: string, text: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, text, { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +export function writeLedger(ledger: Ledger, env: NodeJS.ProcessEnv = process.env): void { + writePrivate(ledger.file, JSON.stringify(ledger, null, 2) + '\n'); + if (ledger.record) writeRecordBlock(ledger.record, ledger); + mirrorRun(ledger, env); +} + +/** One locked read-modify-write of a ledger. */ +export function mutateLedger(file: string, env: NodeJS.ProcessEnv, fn: (l: Ledger) => T): T { + return withFileLock(file, () => { + const ledger = readLedger(file); + const out = fn(ledger); + writeLedger(ledger, env); + return out; + }); +} + +function childUsed(l: Ledger, r: Reservation): number { + const h = l.handoffs.find(x => x.reservation === r.id); + if (!h || h.reading_at === null || h.epoch !== l.meter.epoch || l.meter.last === null) return 0; + return Math.max(0, l.meter.last - h.reading_at); +} + +/** used, open reservations (an open child counts max(sub_cap, child_used), never both) and unknown charges. */ +export function tally(l: Ledger): { used: number; reserved: number; unknown: number } { + const metered = l.meter.contract.meter === 'estimate' ? l.estimate_used : Math.max(0, (l.meter.last ?? l.meter.baseline) - l.meter.baseline); + let reserved = 0; + for (const r of l.reservations) { + if (r.state !== 'open' && r.state !== 'consumed') continue; + reserved += r.kind === 'child' ? Math.max(0, r.tokens - childUsed(l, r)) : r.tokens; + } + return { used: l.carried_used + metered, reserved, unknown: l.unknown }; +} + +/** The admission rule; exactly at the cap is admitted. */ +export function admits(t: { used: number; reserved: number; unknown: number }, projected: number, reserve: number, cap: number | null): boolean { + return cap === null || t.used + t.reserved + t.unknown + projected + reserve <= cap; +} + +export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string { + const t = tally(l); + return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${l.tail + l.stop_overhead} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} verdict=${f.verdict}`; +} + +export function reserveIn(l: Ledger, r: Omit): Reservation { + const row: Reservation = { ...r, id: `${l.run}-r${l.next_id++}`, at: new Date().toISOString(), state: 'open' }; + l.reservations.push(row); + 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 { + if (r.state === 'settled' || r.state === 'released') return; + r.state = 'settled'; + r.settled_at = new Date().toISOString(); + if (opts.unknown) { l.unknown += r.tokens; 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; + if (unseen && r.roles?.length) l.unknown += Math.round((r.tokens * unseen) / r.roles.length); +} + +/** Validate and store one meter reading against the run's contract. */ +export function applyReading(l: Ledger, value: number | null, source: 'flag' | 'host'): void { + const meter = l.meter.contract.meter; + if (value === null) return; + if (source === 'flag' && meter !== 'capy') throw budgetError('BUDGET_READING_REJECTED', `--reading ${value} passed to meter=${meter} (unit ${l.meter.contract.unit}), which reads its own source`); + const floor = l.meter.last ?? l.meter.baseline; + if (value < floor) throw budgetError('BUDGET_READING_REJECTED', `reading ${value} is below the last reading ${floor} of epoch ${l.meter.epoch} (meter=${meter} unit=${l.meter.contract.unit})`); + l.meter.last = value; + l.meter.last_at = new Date().toISOString(); +} + +/** A meter change: close the epoch (its usage carries) and start the next at the new baseline. */ +export function newEpoch(l: Ledger, contract: MeterContract, baseline: number): void { + const metered = l.meter.contract.meter === 'estimate' ? l.estimate_used : Math.max(0, (l.meter.last ?? l.meter.baseline) - l.meter.baseline); + l.epochs.push({ epoch: l.meter.epoch, contract: l.meter.contract, baseline: l.meter.baseline, last: l.meter.last, used: metered }); + l.carried_used += metered; + l.estimate_used = 0; + l.meter = { contract, epoch: l.meter.epoch + 1, baseline, last: baseline }; +} + +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}`; +} + +/** The record's fenced gstack-budget block: written by the bin, never by the agent. */ +export function writeRecordBlock(record: string, l: Ledger): void { + const fenced = '```gstack-budget\n' + recordBlock(l) + '\n```\n'; + let text = ''; + try { text = fs.readFileSync(record, 'utf8'); } catch {} + const next = BLOCK.test(text) ? text.replace(BLOCK, fenced) : `${text}${text && !text.endsWith('\n') ? '\n' : ''}${text ? '\n' : ''}${fenced}`; + writePrivate(record, next); +} + +export function parseRecordBlock(record: string): Record | undefined { + let text: string; + try { text = fs.readFileSync(record, 'utf8'); } catch { return undefined; } + const m = BLOCK.exec(text); + if (!m) return undefined; + const body = m[0].split('\n')[1] ?? ''; + 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. */ +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; + } + if (o.run) { + if (!/^[A-Za-z0-9._-]+$/.test(o.run)) throw usageError(`--run '${o.run}' is not a run id`); + return path.join(runDirFor(env, o.run), 'budget.json'); + } + throw usageError('pass --run or --record ; runs are never found by branch or directory'); +} + +/** SLUG and BRANCH exactly as gstack-review-log sees them (bin/gstack-slug). */ +export function reviewPaths(cwd: string, env: NodeJS.ProcessEnv): { marker: string; reviewLog: string } { + const r = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, env, encoding: 'utf8', timeout: 30_000 }); + const get = (k: string) => new RegExp(`^${k}=(.*)$`, 'm').exec(r.stdout ?? '')?.[1]?.trim(); + const slug = get('SLUG'), branch = get('BRANCH'); + if (r.status !== 0 || !slug || !branch) throw usageError(`cannot resolve the project slug and branch in ${cwd} (bin/gstack-slug exit ${r.status})`); + const dir = path.join(resolveStateRoot(env), 'projects', slug); + return { marker: path.join(dir, `${branch}-ship-budget.json`), reviewLog: path.join(dir, `${branch}-reviews.jsonl`) }; +} + +export function pointMarker(marker: string | undefined, l: Ledger): void { + if (!marker) return; + if (l.cap === null) { try { fs.unlinkSync(marker); } catch {} return; } + writePrivate(marker, JSON.stringify({ run: l.run, ledger: l.file }) + '\n'); +} + +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; +} + +/** + * 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 + * started without --reservation (a later uncapped review on the branch) runs + * capture() exactly as before. + */ +export function admitReviewStart(marker: string, reservation: string | undefined, capture: () => string, env: NodeJS.ProcessEnv = process.env): string { + const active = activeLedger(marker); + if (!active || (active.stopped && !reservation)) return capture(); + return mutateLedger(active.file, env, l => { + const fix = `run gstack-ship-budget check --run ${l.run} --step --next dispatch: and pass --reservation `; + if (l.stopped) throw new BudgetError('BUDGET_CAP_REACHED', resultLine('gstack-review-log', 'BUDGET_CAP_REACHED', `run ${l.run} stopped at Step ${l.stopped.step}: ${l.stopped.reason}`), 1); + 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 out = capture(); + r.state = 'consumed'; + r.token = out.split('\n')[0]!.trim(); + r.started_at = new Date().toISOString(); + return out; + }); +} + +/** gstack-review-log --finish TOKEN: settle the reservation that start consumed (its completion receipt). */ +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; + return mutateLedger(active.file, env, l => { + const r = l.reservations.find(x => x.token === token && x.state === 'consumed'); + if (!r) return undefined; + settleIn(l, r); + return r.id; + }); +} + +/** close's audit: review-log rows of this run with no reservation 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 out: Array<{ skill: string; step: string; started: string }> = []; + for (const line of lines) { + let row: any; + try { row = JSON.parse(line); } catch { continue; } + 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 }); + } + return out; +} + +/** Where run checkpoints go: GSTACK_SHIP_BUDGET_CHECKPOINT_DIR, else the private project-user Drive folder on Capy. */ +export function checkpointDir(env: NodeJS.ProcessEnv): string | undefined { + if (env.GSTACK_SHIP_BUDGET_CHECKPOINT_DIR) return env.GSTACK_SHIP_BUDGET_CHECKPOINT_DIR; + const folder = capyDriveFolder(env); + return folder ? path.join(path.dirname(folder), 'ship-budget') : undefined; +} + +const sha256 = (text: string | Buffer) => crypto.createHash('sha256').update(text).digest('hex'); + +/** Mirror the ledger and record, bound to the run id and their hashes. */ +export function mirrorRun(l: Ledger, env: NodeJS.ProcessEnv): void { + const dir = checkpointDir(env); + if (!dir) return; + const into = path.join(dir, l.run); + const ledgerText = fs.readFileSync(l.file); + writePrivate(path.join(into, 'budget.json'), ledgerText.toString()); + let recordSha: string | null = null; + if (l.record && fs.existsSync(l.record)) { + const recordText = fs.readFileSync(l.record); + writePrivate(path.join(into, 'record.md'), recordText.toString()); + recordSha = sha256(recordText); + } + writePrivate(path.join(into, 'binding.json'), JSON.stringify({ run: l.run, ledger: l.file, record: l.record ?? null, ledger_sha256: sha256(ledgerText), record_sha256: recordSha }, null, 2) + '\n'); +} + +/** Restore a run's ledger and record from its checkpoint when the original paths are gone. */ +export function restoreRun(run: string, env: NodeJS.ProcessEnv): { ledger: string; record: string | null; from: string } | undefined { + const dir = checkpointDir(env); + if (!dir) return undefined; + const from = path.join(dir, run); + let binding: any; + try { binding = JSON.parse(fs.readFileSync(path.join(from, 'binding.json'), 'utf8')); } catch { return undefined; } + const ledgerText = fs.readFileSync(path.join(from, 'budget.json')); + if (binding.run !== run || sha256(ledgerText) !== binding.ledger_sha256 || JSON.parse(ledgerText.toString()).run !== run) { + throw budgetError('BUDGET_LEDGER_INVALID', `checkpoint ${from} is not bound to run ${run} (identity or sha256 mismatch)`); + } + writePrivate(binding.ledger, ledgerText.toString()); + if (binding.record && binding.record_sha256) { + const recordText = fs.readFileSync(path.join(from, 'record.md')); + if (sha256(recordText) !== binding.record_sha256) throw budgetError('BUDGET_LEDGER_INVALID', `checkpoint record ${from}/record.md sha256 mismatch`); + if (!fs.existsSync(binding.record)) writePrivate(binding.record, recordText.toString()); + } + return { ledger: binding.ledger, record: binding.record, from }; +} diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts new file mode 100644 index 000000000..48e2ac7a5 --- /dev/null +++ b/lib/ship-handoff.ts @@ -0,0 +1,245 @@ +/** + * ship-handoff — fresh-context delegation for /ship (GSTA-152 D3, build-spec r2 + * item 3). The parent writes a brief (mode 0600, under its run directory) with + * the pins computed from git at handoff time, the invocation record and its + * start tokens, the decisions in scope with the owner's words, the decision + * 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; it never delegates again. It + * returns at most 40 lines through a return file; the parent reconciles once + * per handoff hash (a duplicate return charges nothing twice) and reads the + * record, never the child's transcript. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { resolveClaudeCommand } from './claude-bin'; +import { wtreeFingerprint } from './script-invocation'; +import { resultLine } from './result-codes'; +import { + SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, + readLedger, reserveIn, runDirFor, segmentEstimate, 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'; + +export const RECORD_HEADINGS = ['Release', 'Decisions', 'Reviews', 'Checks', 'Documentation', 'Next steps'] as const; +export const RETURN_MAX_LINES = 40; +const sha256 = (text: string | Buffer) => crypto.createHash('sha256').update(text).digest('hex'); + +function git(cwd: string, args: string[]): string | null { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); + return r.status === 0 ? r.stdout.trim() : null; +} + +export function pinsNow(cwd: string, baseRef: string, env: NodeJS.ProcessEnv): { head: string | null; merge_base: string | null; wtree: string | null } { + const w = wtreeFingerprint({ cwd, env, timeoutMs: 60_000 }); + return { head: git(cwd, ['rev-parse', 'HEAD']), merge_base: git(cwd, ['merge-base', 'HEAD', baseRef]), wtree: 'tree' in w ? w.tree : null }; +} + +/** One record section's lines, from its heading to the next of the six. */ +export function recordSection(text: string, name: string): string[] | undefined { + const heading = (line: string) => RECORD_HEADINGS.find(h => new RegExp(`^\\s*(?:[-*]\\s*)?(?:#+\\s*)?(?:\\*\\*)?${h}(?:\\*\\*)?:?(?:\\*\\*)?(?:\\s|$)`).test(line)); + const lines = text.split('\n'); + const at = lines.findIndex(line => heading(line) === name); + if (at < 0) return undefined; + const end = lines.findIndex((line, i) => i > at && heading(line) !== undefined); + return lines.slice(at, end < 0 ? lines.length : end); +} + +function brief(l: Ledger, h: Omit, pins: Record, recordText: string, o: Record): 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) ?? [])]; + const block = { version: 1, parent_run: l.run, parent_ledger: l.file, from: h.from, to: h.to, sub_cap: h.sub_cap, parent_tail: l.tail, + unit: l.meter.contract.unit, meter: l.meter.contract.meter, epoch: h.epoch, reading_at: h.reading_at, ...pins, record: l.record, + return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens }; + return [ + `# /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 \` (on Capy add \`--reading \`) before anything else. On a refusal, return its lines and stop.`, + block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} \` before any trail write.` : '2. No decision trail was named.', + `3. Run /ship --continue for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. You never delegate; a delegate rule stops you.`, + 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.', '', + '## 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}.`, '', + '## Return contract', '', + `Finish with \`gstack-ship-budget return --run --to --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.`, '', + ].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 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'); + if (!base) throw usageError('handoff needs --base origin/ (the merge base is pinned against it)'); + return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { + if (l.role === 'child') throw usageError('a child never delegates again; stop instead'); + if (!l.record || !fs.existsSync(l.record)) throw usageError(`the run has no invocation record at ${l.record}`); + const recordText = fs.readFileSync(l.record, 'utf8'); + const missing = RECORD_HEADINGS.filter(h => !recordSection(recordText, h)); + if (missing.length) throw usageError(`record ${l.record} lacks headings: ${missing.join(', ')}; fix: keep the six headings the ship skill names`); + const child = str(o, 'child-session'); + if (child && l.meter.contract.meter === 'claude') { + l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), child])]; + const state = loadMeterState(path.join(l.dir, 'claude-meter.json')); + if (state) { state.sessions = l.meter.sessions; saveMeterState(path.join(l.dir, 'claude-meter.json'), state); } + } + const call = readMeter(l, o, true); + const cal = loadCalibration(l.calibration); + const t = tally(l); + const room = l.cap === null ? Infinity : l.cap - t.used - t.reserved - t.unknown - l.tail - l.stop_overhead; + const subCap = tokensOpt(o, 'sub-cap') ?? Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to)); + if (subCap <= 0 || subCap > room) return { stdout: stopLines(l, { call, next: from, step: from, projected: subCap, reason: 'budget_exhausted' }), exit: 3 }; + const pins = pinsNow(ctx.cwd, base, ctx.env); + const reviewed = str(o, 'reviewed-tree') ?? null; + const r = reserveIn(l, { kind: 'child', step: from, next: `child:${from}-${to}`, tokens: subCap }); + const n = l.handoffs.length + 1; + const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) }; + const text = brief(l, draft, { ...pins, base_ref: base, reviewed_tree: reviewed }, recordText, { trail: str(o, 'trail'), trailRun: str(o, 'trail-run'), launch: ctx.env.GSTACK_LAUNCH_FILE }); + fs.writeFileSync(draft.file, text, { mode: 0o600 }); + const sha = sha256(text); + l.handoffs.push({ ...draft, sha }); + return { stdout: [`HANDOFF: ${draft.file} sha256=${sha} sub_cap=${subCap}`, `RETURN_FILE: ${draft.return_file}`, budgetLine(l, { call, next: from, projected: subCap, verdict: 'delegate' })], exit: 0 }; + }); +} + +function handoffBlock(text: string): any { + const m = /```gstack-handoff\n([\s\S]*?)\n```/.exec(text); + try { return m ? JSON.parse(m[1]!) : undefined; } catch { return undefined; } +} + +export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { + const o = parseArgs(argv, ['in', 'reading', 'session']); + const file = str(o, 'in'); + if (!file) throw usageError('verify-handoff needs --in '); + const text = fs.readFileSync(file, 'utf8'); + const block = handoffBlock(text); + if (!block?.parent_ledger) throw budgetError('BUDGET_LEDGER_INVALID', `${file}: no gstack-handoff block`); + const parent = readLedger(block.parent_ledger); + const sha = sha256(text); + const h = parent.handoffs.find(x => x.sha === sha); + const moved: string[] = []; + if (!h) moved.push(`brief sha256 expected ${parent.handoffs.find(x => x.file === path.resolve(file))?.sha ?? 'a recorded handoff'} actual ${sha}`); + else if (h.reconciled_at) moved.push(`brief already reconciled at ${h.reconciled_at}`); + const now = pinsNow(ctx.cwd, block.base_ref, ctx.env); + for (const k of ['head', 'merge_base', 'wtree'] as const) if (block[k] !== now[k]) moved.push(`${k} expected ${block[k]} actual ${now[k]}`); + if (block.reviewed_tree && git(ctx.cwd, ['cat-file', '-t', block.reviewed_tree]) !== 'tree') moved.push(`reviewed_tree expected ${block.reviewed_tree} actual missing`); + if (moved.length) { + const lines = [`RETURN: handoff=${sha} status=refused steps=${block.from}-${block.to} head=${now.head} tree=${now.wtree}`, + ...moved.map(m => resultLine('gstack-ship-budget', 'HANDOFF_PIN_MOVED', m)), `GSTACK_RESULT: skill=ship status=refused run=${parent.dir}`]; + writeReturnFile(block.return_file, lines); + return { stdout: lines, exit: 3 }; + } + const run = mintRunId(); + const dir = runDirFor(ctx.env, run); + const child: Ledger = { + schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: path.join(dir, 'budget.json'), record: block.record, created_at: new Date().toISOString(), + cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: 'never', role: 'child', + 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, + ...(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, + }; + if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session '); + readMeter(child, o, true); + 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 }; +} + +export function cmdReturn(argv: string[], ctx: Ctx): Outcome { + const o = parseArgs(argv, ['run', 'to', 'changed', 'status', 'reading']); + 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); + const head = git(ctx.cwd, ['rev-parse', 'HEAD']); + const tree = pinsNow(ctx.cwd, 'HEAD', ctx.env).wtree; + const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${status} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, + `RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }), + `GSTACK_RESULT: skill=ship status=${status} run=${l.dir}`]; + writeReturnFile(l.parent.return_file, lines); + l.closed_at = new Date().toISOString(); + return { stdout: lines, exit: 0 }; + }); +} + +/** 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); + if (lines.length > RETURN_MAX_LINES) return undefined; + 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 }; +} + +export function cmdReconcile(argv: string[], ctx: Ctx): Outcome { + const o = parseArgs(argv, ['run', 'record', 'return', 'handoff', 'reading', '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; + const parsed = text ? parseReturn(text) : undefined; + const sha = parsed?.handoff ?? str(o, 'handoff') ?? l.handoffs.find(x => x.return_file === (retFile && path.resolve(retFile)))?.sha; + const h = l.handoffs.find(x => x.sha === sha); + if (!h) throw usageError(`no handoff ${sha ?? '(none named)'} in run ${l.run}; pass --return or --handoff `); + const exitFor = (s: string | undefined) => (s === 'complete' ? 0 : 3); + if (h.reconciled_at) { + return { stdout: [`RECONCILED: handoff=${h.sha} child=${h.status} child_used=${h.child_used} (already reconciled at ${h.reconciled_at}; nothing charged twice)`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' })], exit: exitFor(h.status) }; + } + const own = !parsed && fs.existsSync(h.return_file) ? parseReturn(fs.readFileSync(h.return_file, 'utf8')) : undefined; + 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 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(); + 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/) or stop`); + return { stdout: lines, exit: exitFor(h.status) }; + }); +} + +/** Explicit permissions for a headless child (build-spec r2 item 7, M1): edits and the shell, nothing broader. */ +export const CHILD_PERMISSION_ARGS = ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash,Read,Edit,Write,Grep,Glob']; + +/** Claude Code: launch the child as a background headless session that answers through the return file. */ +export function cmdLaunch(argv: string[], ctx: Ctx): Outcome { + const o = parseArgs(argv, ['in']); + const file = str(o, 'in'); + if (!file) throw usageError('launch needs --in '); + const block = handoffBlock(fs.readFileSync(file, 'utf8')); + if (!block?.parent_ledger) throw budgetError('BUDGET_LEDGER_INVALID', `${file}: no gstack-handoff block`); + const claude = resolveClaudeCommand(ctx.env); + if (!claude) throw usageError('no claude binary resolves (PATH, GSTACK_CLAUDE_BIN); launch the child by hand or rerun with --delegate never'); + const session = crypto.randomUUID(); + const prompt = `Load gstack. Your Claude Code session id is ${session}. Run /ship --continue ${path.resolve(file)}: first gstack-ship-budget verify-handoff --in ${path.resolve(file)} --session ${session}, then only the steps the brief names, then gstack-ship-budget return as its return contract says.`; + const log = path.join(path.dirname(block.return_file), `child-${session}.log`); + return mutateLedger(block.parent_ledger, ctx.env, l => { + if (l.meter.contract.meter === 'claude') { + l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), session])]; + const meter = path.join(l.dir, 'claude-meter.json'); + const state = loadMeterState(meter); + if (state) { state.sessions = l.meter.sessions; saveMeterState(meter, state); } + } + const out = fs.openSync(log, 'a', 0o600); + const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] }); + child.unref(); + fs.closeSync(out); + return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 }; + }); +} diff --git a/package.json b/package.json index 6ee5d4ca5..da7c8c3ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.315", + "version": "1.91.316", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts index 2dbd26004..b0a107407 100644 --- a/scripts/pr-dependencies.ts +++ b/scripts/pr-dependencies.ts @@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [ // The build entry list: every compiled binary, which skills reach through their `--outfile` path instead. 'scripts/build.sh', // The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes. - 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', + 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', // The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case). 'test/helpers/exercised-commands.json', ]; diff --git a/ship/SKILL.md b/ship/SKILL.md index 5846b96d3..2c3b87597 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -194,6 +194,11 @@ its absolute path, with these headings so a paused run can resume: - **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception. - **Next steps:** one ordered work list, with the current step marked. +**Budget** (docs/ship-budget.md): `--token-cap ` (or GSTACK_SHIP_TOKEN_CAP); `--resume ` +or `--continue ` re-attach. Step 1 runs `gstack-ship-budget start --record `. With a cap, +read `~/.claude/skills/gstack/ship/sections/budget-handoff.md`, `check` at each step boundary and dispatch +batch, obey `verdict=`, `close` before Step 22. + A **receipt** is saved evidence of a check's command, result and consumed content. A review's **start token** is the opaque value returned by `gstack-review-log --start` before it reads the diff. Keep `REVIEW_START` for Step 9, a separate `PASS_START` for @@ -257,6 +262,7 @@ sections. Read a section in full before doing its step; do not work from memory. | preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` | | Step 19 printed CAPY_PR: on (a Capy session, GSTACK_CAPY=1) — read before composing the PR body | `sections/capy-pr.md` | | handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` | +| a token cap is set or verdict=delegate printed (Step 1 on) | `sections/budget-handoff.md` | --- diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 810bbd990..824178441 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -60,6 +60,11 @@ its absolute path, with these headings so a paused run can resume: - **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception. - **Next steps:** one ordered work list, with the current step marked. +**Budget** (docs/ship-budget.md): `--token-cap ` (or GSTACK_SHIP_TOKEN_CAP); `--resume ` +or `--continue ` re-attach. Step 1 runs `gstack-ship-budget start --record `. With a cap, +read `~/.claude/skills/gstack/ship/sections/budget-handoff.md`, `check` at each step boundary and dispatch +batch, obey `verdict=`, `close` before Step 22. + A **receipt** is saved evidence of a check's command, result and consumed content. A review's **start token** is the opaque value returned by `gstack-review-log --start` before it reads the diff. Keep `REVIEW_START` for Step 9, a separate `PASS_START` for diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 620f17248..250d9f5a7 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -4,7 +4,9 @@ `DISPATCH: adversarial-native` and the outside voice only on `DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; -with neither line, take decision 3 of Finish the adversarial phase. +with neither line, take decision 3 of Finish the adversarial phase. Under a token cap, +reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) +and add `--reservation ` to the `--start adversarial-review` call below. ## Step 11: Adversarial review diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl index df982e205..417be026e 100644 --- a/ship/sections/adversarial.md.tmpl +++ b/ship/sections/adversarial.md.tmpl @@ -2,7 +2,9 @@ `DISPATCH: adversarial-native` and the outside voice only on `DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; -with neither line, take decision 3 of Finish the adversarial phase. +with neither line, take decision 3 of Finish the adversarial phase. Under a token cap, +reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) +and add `--reservation ` to the `--start adversarial-review` call below. {{ADVERSARIAL_STEP}} diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md new file mode 100644 index 000000000..1b036c551 --- /dev/null +++ b/ship/sections/budget-handoff.md @@ -0,0 +1,56 @@ + + +## Budget and context handoff (read when a cap is set or `verdict=delegate` prints) + +The cap is an **admission cap**: `gstack-ship-budget check` admits the next step or +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. + +**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 `. For the per-call charge, read it again in your next + call and pass the earlier value as `--prior-reading `. +- Claude Code: `start --session `, or echo the nonce the refusal prints once and + rerun `start --nonce `; 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 --step --next --reading `. +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): + +```bash +~/.claude/skills/gstack/bin/gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team --reading "$READING" +~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" +``` + +`--finish` settles it. A dispatch with no `review-log --start` settles with +`gstack-ship-budget settle --run --reservation ` when it returns +(`--unknown` when the meter cannot see it), or `release` if it never ran. + +**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 +--token-cap ` runs `gstack-ship-budget resume --record --token-cap +--approved-by --words "" --reading `. After a machine change, +`resume --run ` 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 --from 7 --to 11.5 --base origin/ --reading ` + (add `--reviewed-tree `, `--trail --trail-run `). It prints `HANDOFF:` and `RETURN_FILE:`. +2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source + GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code + `gstack-ship-budget launch --in ` (background, explicit permissions). +3. The child runs `verify-handoff --in ` first (`--reading ` on Capy); a + `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap, + never delegates, and ends with `gstack-ship-budget return --run --to + --changed ` (at most 40 lines). +4. `gstack-ship-budget reconcile --run --return --reading `. Read the + record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): + regenerate the handoff or stop. + +**--resume ** and **--continue ** (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 +`--continue`, run verify-handoff before any trail write. diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl new file mode 100644 index 000000000..fa41ceb04 --- /dev/null +++ b/ship/sections/budget-handoff.md.tmpl @@ -0,0 +1,54 @@ +## Budget and context handoff (read when a cap is set or `verdict=delegate` prints) + +The cap is an **admission cap**: `gstack-ship-budget check` admits the next step or +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. + +**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 `. For the per-call charge, read it again in your next + call and pass the earlier value as `--prior-reading `. +- Claude Code: `start --session `, or echo the nonce the refusal prints once and + rerun `start --nonce `; 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 --step --next --reading `. +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): + +```bash +~/.claude/skills/gstack/bin/gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team --reading "$READING" +~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" +``` + +`--finish` settles it. A dispatch with no `review-log --start` settles with +`gstack-ship-budget settle --run --reservation ` when it returns +(`--unknown` when the meter cannot see it), or `release` if it never ran. + +**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 +--token-cap ` runs `gstack-ship-budget resume --record --token-cap +--approved-by --words "" --reading `. After a machine change, +`resume --run ` 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 --from 7 --to 11.5 --base origin/ --reading ` + (add `--reviewed-tree `, `--trail --trail-run `). It prints `HANDOFF:` and `RETURN_FILE:`. +2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source + GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code + `gstack-ship-budget launch --in ` (background, explicit permissions). +3. The child runs `verify-handoff --in ` first (`--reading ` on Capy); a + `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap, + never delegates, and ends with `gstack-ship-budget return --run --to + --changed ` (at most 40 lines). +4. `gstack-ship-budget reconcile --run --return --reading `. Read the + record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): + regenerate the handoff or stop. + +**--resume ** and **--continue ** (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 +`--continue`, run verify-handoff before any trail write. diff --git a/ship/sections/manifest.json b/ship/sections/manifest.json index 32af4c724..c140b56d7 100644 --- a/ship/sections/manifest.json +++ b/ship/sections/manifest.json @@ -93,6 +93,12 @@ "file": "handoff.md", "title": "Handoff without a PR (automation lanes)", "trigger": "handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18)" + }, + { + "id": "budget-handoff", + "file": "budget-handoff.md", + "title": "Token budget and fresh-context handoff", + "trigger": "a token cap is set or verdict=delegate printed (Step 1 on)" } ] } diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index 06419d047..c251a9e46 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -99,7 +99,7 @@ This pass is static; defer product probes to Step 9.2.1. 1. Read `~/.claude/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error. -2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/` (`git diff $RANGE_BASE` from pass 2 on). Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them. +2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START (under a token cap, first reserve this pass's dispatch batch with `gstack-ship-budget check` and add `--reservation `). Then run `git diff origin/` (`git diff $RANGE_BASE` from pass 2 on). Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them. Every pass captures a full token (no `--fix-range`), so the record binds the whole tree even when reviewers read only the range. @@ -826,7 +826,7 @@ Other scopes apply decisions 2–4 in order. past what the request needs (a helper, a guard, a case nothing takes), cut that growth before anything else; the repeat reviews the cut as part of the range. Tests must pass or retain approval for the same verified pre-existing failures and scope. Keep CYCLES and scoped approvals across this repeat; PASS increments on entry. -3.5. **One fix-check.** Take a `gstack-review-log --start review-fix-check` token; write the +3.5. **One fix-check.** Take a `gstack-review-log --start review-fix-check` token (`--reservation ` under a cap); write the fix list, touched hunks (`git diff $PASS_HEAD`), callers of changed symbols and test results to `.gstack/tmp/fix-check-*.txt`; dispatch one full-effort reviewer on `gstack-review-plan --fix-check-prompt --fix-list --hunks --tests diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl index 704bfb6b1..c1b6476c0 100644 --- a/ship/sections/review-army.md.tmpl +++ b/ship/sections/review-army.md.tmpl @@ -44,7 +44,7 @@ This pass is static; defer product probes to Step 9.2.1. 1. Read `~/.claude/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error. -2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/` (`git diff $RANGE_BASE` from pass 2 on). Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them. +2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START (under a token cap, first reserve this pass's dispatch batch with `gstack-ship-budget check` and add `--reservation `). Then run `git diff origin/` (`git diff $RANGE_BASE` from pass 2 on). Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them. Every pass captures a full token (no `--fix-range`), so the record binds the whole tree even when reviewers read only the range. @@ -240,7 +240,7 @@ Other scopes apply decisions 2–4 in order. past what the request needs (a helper, a guard, a case nothing takes), cut that growth before anything else; the repeat reviews the cut as part of the range. Tests must pass or retain approval for the same verified pre-existing failures and scope. Keep CYCLES and scoped approvals across this repeat; PASS increments on entry. -3.5. **One fix-check.** Take a `gstack-review-log --start review-fix-check` token; write the +3.5. **One fix-check.** Take a `gstack-review-log --start review-fix-check` token (`--reservation ` under a cap); write the fix list, touched hunks (`git diff $PASS_HEAD`), callers of changed symbols and test results to `.gstack/tmp/fix-check-*.txt`; dispatch one full-effort reviewer on `gstack-review-plan --fix-check-prompt --fix-list --hunks --tests diff --git a/test/claude-usage.test.ts b/test/claude-usage.test.ts new file mode 100644 index 000000000..acc65c5e5 --- /dev/null +++ b/test/claude-usage.test.ts @@ -0,0 +1,127 @@ +/** + * The Claude Code meter (GSTA-152 plan item 3): per (message.id, requestId) + * maxima, compaction copies, subagent and child journals, explicit session + * binding, and the incremental reader's offset, rewrite and timing guards. + */ +import { afterEach, 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 { newMeterState, nonceSessions, readClaudeUsage, sessionJournals } from '../lib/claude-usage'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const FIXTURE = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'claude-usage-2.1.292.json'), 'utf8')); +const S = FIXTURE.sessions as Record; +const roots: string[] = []; +const harnesses: Harness[] = []; +afterEach(() => { for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function configDir(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-usage-')); + roots.push(root); + const project = path.join(root, 'projects', '-tmp-repo'); + fs.mkdirSync(path.join(project, S.parent!, 'subagents'), { recursive: true }); + const write = (file: string, lines: string[]) => fs.writeFileSync(file, lines.map(l => l + '\n').join('')); + write(path.join(project, `${S.parent}.jsonl`), FIXTURE.journals.parent); + write(path.join(project, `${S.fork}.jsonl`), FIXTURE.journals.fork); + write(path.join(project, S.parent!, 'subagents', 'agent-a1b2c3.jsonl'), FIXTURE.journals.subagent); + write(path.join(project, `${S.child}.jsonl`), FIXTURE.journals.child); + write(path.join(project, `${S.decoy}.jsonl`), FIXTURE.journals.decoy); + return root; +} + +describe('claude-usage meter', () => { + test('streaming duplicates count once at the max of each field; sidechain and subagent charges count; the last root call is known', () => { + const state = newMeterState(configDir(), [S.parent!]); + const r = readClaudeUsage(state); + expect(r.total).toBe(20_071 + 550); + expect(r.lastCall).toBe(7_362); + expect(r.files).toBe(2); + }); + + test('a compaction fork\'s copies of earlier calls are not counted twice; a headless child adds its own calls', () => { + const dir = configDir(); + expect(readClaudeUsage(newMeterState(dir, [S.parent!, S.fork!])).total).toBe(20_621 + 36); + expect(readClaudeUsage(newMeterState(dir, [S.parent!, S.child!])).total).toBe(20_621 + 100); + }); + + test('binding: a session id names one journal; a nonce must match exactly one journal', () => { + const dir = configDir(); + expect(sessionJournals(dir, S.parent!)).toHaveLength(1); + expect(sessionJournals(dir, 'not-a-session')).toEqual([]); + expect(nonceSessions(dir, 'nonce0abc123')).toEqual([S.parent]); + expect(nonceSessions(dir, 'nonce-nowhere-1')).toEqual([]); + fs.appendFileSync(path.join(dir, 'projects', '-tmp-repo', `${S.decoy}.jsonl`), JSON.stringify({ type: 'user', message: { content: 'GSTACK_BUDGET_NONCE=nonce0abc123' } }) + '\n'); + expect(nonceSessions(dir, 'nonce0abc123').sort()).toEqual([S.parent, S.decoy].sort()); + }); + + test('incremental: an append reads only new complete lines; a partial line waits; a rewrite reads from the start', () => { + const dir = configDir(); + const file = path.join(dir, 'projects', '-tmp-repo', `${S.parent}.jsonl`); + const state = newMeterState(dir, [S.parent!]); + expect(readClaudeUsage(state).total).toBe(20_621); + const extra = JSON.parse(FIXTURE.journals.child[1]); + extra.sessionId = S.parent; + const line = JSON.stringify(extra); + fs.appendFileSync(file, line.slice(0, 40)); + expect(readClaudeUsage(state).total).toBe(20_621); + fs.appendFileSync(file, line.slice(40) + '\n'); + expect(readClaudeUsage(state).total).toBe(20_721); + expect(readClaudeUsage(state).lastCall).toBe(100); + fs.writeFileSync(file, FIXTURE.journals.parent.slice(0, 2).map((l: string) => l + '\n').join('')); + expect(readClaudeUsage(state).total).toBe(6_253 + 550 - 240); + }); + + test('timing guard: after a 30 MB journal is read once, a small append is read incrementally', () => { + const dir = configDir(); + const file = path.join(dir, 'projects', '-tmp-repo', `${S.parent}.jsonl`); + const template = JSON.parse(FIXTURE.journals.parent[3]); + template.message.content = [{ type: 'text', text: 'x'.repeat(900) }]; + const chunks: string[] = []; + let bytes = fs.statSync(file).size; + for (let i = 0; bytes < 30 * 1024 * 1024; i++) { + template.message.id = `msg_big_${i}`; + template.requestId = `req_big_${i}`; + const line = JSON.stringify(template) + '\n'; + chunks.push(line); + bytes += line.length; + } + fs.appendFileSync(file, chunks.join('')); + const state = newMeterState(dir, [S.parent!]); + let t0 = performance.now(); + const first = readClaudeUsage(state); + const full = performance.now() - t0; + expect(first.total).toBeGreaterThan(20_621); + fs.appendFileSync(file, chunks[0]!.replace('msg_big_0', 'msg_tail').replace('req_big_0', 'req_tail')); + t0 = performance.now(); + const second = readClaudeUsage(state); + const incremental = performance.now() - t0; + expect(second.total).toBe(first.total + 6_245); + expect(full).toBeLessThan(15_000); + expect(incremental).toBeLessThan(Math.max(250, full / 4)); + }, 60_000); + + test('gstack-ship-budget start --meter claude binds a session and meters it', () => { + const h = budgetHarness(); + harnesses.push(h); + const project = path.join(h.root, 'claude', 'projects', '-tmp-repo'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, `${S.parent}.jsonl`), FIXTURE.journals.parent.map((l: string) => l + '\n').join('')); + const record = h.record(); + const none = h.sb(['start', '--record', record, '--token-cap', '25M', '--meter', 'claude']); + expect(none.code).toBe(2); + expect(none.err).toMatch(/echo GSTACK_BUDGET_NONCE=[0-9a-f]{16} once.*\(BUDGET_METER_UNAVAILABLE\)/); + const r = h.sb(['start', '--record', record, '--token-cap', '25M', '--nonce', 'nonce0abc123']); + expect(r.code).toBe(0); + expect(h.field(r.out, 'BUDGET', 'meter')).toBe('claude'); + expect(h.field(r.out, 'BUDGET', 'unit')).toBe('claude-usage-tokens'); + expect(h.field(r.out, 'BUDGET', 'call')).toBe('7362'); + expect(h.field(r.out, 'BUDGET', 'used')).toBe('0'); + const run = h.field(r.out, 'BUDGET_RUN', 'id'); + fs.appendFileSync(path.join(project, `${S.parent}.jsonl`), FIXTURE.journals.child[1].replaceAll(S.child!, S.parent!) + '\n'); + const c = h.sb(['check', '--run', run, '--step', '1', '--next', '2']); + expect(c.code).toBe(0); + expect(h.field(c.out, 'BUDGET', 'used')).toBe('100'); + expect(h.sb(['check', '--run', run, '--step', '2', '--next', '3', '--reading', '5']).err).toContain('(BUDGET_READING_REJECTED)'); + }); +}); diff --git a/test/fixtures/claude-usage-2.1.292.json b/test/fixtures/claude-usage-2.1.292.json new file mode 100644 index 000000000..854667278 --- /dev/null +++ b/test/fixtures/claude-usage-2.1.292.json @@ -0,0 +1,38 @@ +{ + "claudeCodeVersion": "2.1.292", + "note": "Record shapes follow Claude Code 2.1.292 journals (the version .github/docker/Dockerfile.ci pins; field set as in test/fixtures/context-restore-fragment-match-captures.json). Usage values are synthetic so totals are exact: parent 6253+6245+7362+211=20071 (msg_01A streamed twice, max per field), last root call msg_01C=7362; fork adds only msg_01D=36 (its msg_01A and msg_01B are compaction copies); subagent msg_01E=550; child msg_01F=100.", + "sessions": { + "parent": "3dea0a29-560c-4987-8665-38025df2a023", + "fork": "6a87cfeb-1a6f-4747-9883-a295c57fad68", + "child": "4afc15c8-ba09-4a60-9d57-28d59bd43639", + "decoy": "9b1c2d3e-4f50-4617-8899-aabbccddeeff" + }, + "journals": { + "parent": [ + "{\"type\": \"user\", \"uuid\": \"00000000-0000-4000-8000-000000000006\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:06Z\", \"version\": \"2.1.292\", \"cwd\": \"__CWD__\", \"message\": {\"role\": \"user\", \"content\": \"echo GSTACK_BUDGET_NONCE=nonce0abc123\"}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000001\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:01Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01A\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01A\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"thinking\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 3, \"cache_creation_input_tokens\": 1000, \"cache_read_input_tokens\": 5000, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 1000, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 10, \"service_tier\": \"standard\"}}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000002\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:02Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01A\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01A\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"tool_use\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 3, \"cache_creation_input_tokens\": 1000, \"cache_read_input_tokens\": 5000, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 1000, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 250, \"service_tier\": \"standard\"}}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000003\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:03Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01B\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01B\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 5, \"cache_creation_input_tokens\": 200, \"cache_read_input_tokens\": 6000, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 200, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 40, \"service_tier\": \"standard\"}}}", + "{\"type\": \"system\", \"subtype\": \"compact_boundary\", \"uuid\": \"00000000-0000-4000-8000-000000000007\", \"parentUuid\": null, \"logicalParentUuid\": \"00000000-0000-4000-8000-000000000003\", \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:01:00Z\", \"version\": \"2.1.292\", \"cwd\": \"__CWD__\"}", + "{\"type\": \"user\", \"uuid\": \"00000000-0000-4000-8000-000000000008\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:08Z\", \"version\": \"2.1.292\", \"cwd\": \"__CWD__\", \"message\": {\"role\": \"user\", \"content\": \"[redacted]\"}, \"isCompactSummary\": true}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000004\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:04Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01C\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01C\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 2, \"cache_creation_input_tokens\": 300, \"cache_read_input_tokens\": 7000, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 300, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 60, \"service_tier\": \"standard\"}}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000005\", \"parentUuid\": null, \"isSidechain\": true, \"sessionId\": \"3dea0a29-560c-4987-8665-38025df2a023\", \"timestamp\": \"2026-10-29T10:00:05Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01S\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01S\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 1, \"cache_creation_input_tokens\": 100, \"cache_read_input_tokens\": 100, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 100, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 10, \"service_tier\": \"standard\"}}}" + ], + "fork": [ + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000002\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"6a87cfeb-1a6f-4747-9883-a295c57fad68\", \"timestamp\": \"2026-10-29T10:00:02Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01A\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01A\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"tool_use\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 3, \"cache_creation_input_tokens\": 1000, \"cache_read_input_tokens\": 5000, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 1000, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 250, \"service_tier\": \"standard\"}}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000003\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"6a87cfeb-1a6f-4747-9883-a295c57fad68\", \"timestamp\": \"2026-10-29T10:00:03Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01B\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01B\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 5, \"cache_creation_input_tokens\": 200, \"cache_read_input_tokens\": 6000, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 200, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 40, \"service_tier\": \"standard\"}}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000009\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"6a87cfeb-1a6f-4747-9883-a295c57fad68\", \"timestamp\": \"2026-10-29T10:00:09Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01D\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01D\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 1, \"cache_creation_input_tokens\": 10, \"cache_read_input_tokens\": 20, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 10, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 5, \"service_tier\": \"standard\"}}}" + ], + "subagent": [ + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000010\", \"parentUuid\": null, \"isSidechain\": true, \"sessionId\": \"agent-a1b2c3\", \"timestamp\": \"2026-10-29T10:00:10Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01E\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01E\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 0, \"cache_creation_input_tokens\": 0, \"cache_read_input_tokens\": 500, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 0, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 50, \"service_tier\": \"standard\"}}}" + ], + "child": [ + "{\"type\": \"user\", \"uuid\": \"00000000-0000-4000-8000-000000000011\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"4afc15c8-ba09-4a60-9d57-28d59bd43639\", \"timestamp\": \"2026-10-29T10:00:11Z\", \"version\": \"2.1.292\", \"cwd\": \"__CWD__\", \"message\": {\"role\": \"user\", \"content\": \"continue\"}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000012\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"4afc15c8-ba09-4a60-9d57-28d59bd43639\", \"timestamp\": \"2026-10-29T10:00:12Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01F\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01F\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 10, \"cache_creation_input_tokens\": 0, \"cache_read_input_tokens\": 0, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 0, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 90, \"service_tier\": \"standard\"}}}" + ], + "decoy": [ + "{\"type\": \"user\", \"uuid\": \"00000000-0000-4000-8000-000000000013\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"9b1c2d3e-4f50-4617-8899-aabbccddeeff\", \"timestamp\": \"2026-10-29T10:00:13Z\", \"version\": \"2.1.292\", \"cwd\": \"__CWD__\", \"message\": {\"role\": \"user\", \"content\": \"unrelated session\"}}", + "{\"type\": \"assistant\", \"uuid\": \"00000000-0000-4000-8000-000000000014\", \"parentUuid\": null, \"isSidechain\": false, \"sessionId\": \"9b1c2d3e-4f50-4617-8899-aabbccddeeff\", \"timestamp\": \"2026-10-29T10:00:14Z\", \"version\": \"2.1.292\", \"requestId\": \"req_01G\", \"cwd\": \"__CWD__\", \"message\": {\"model\": \"claude-opus-5-5\", \"id\": \"msg_01G\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [{\"type\": \"text\"}], \"stop_reason\": null, \"stop_sequence\": null, \"usage\": {\"input_tokens\": 9, \"cache_creation_input_tokens\": 9, \"cache_read_input_tokens\": 9, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 9, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 9, \"service_tier\": \"standard\"}}}" + ] + } +} \ No newline at end of file diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index 866f1cfb5..a7b5997c9 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -9,7 +9,7 @@ "browser-skills/hackernews-frontpage": 371, "canary": 7738, "captain": 6551, - "capy/skill/gstack": 4120, + "capy/skill/gstack": 4588, "careful": 1239, "codex": 9890, "context-restore": 6287, diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 59c5baac2..5e5da8491 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -194,6 +194,11 @@ its absolute path, with these headings so a paused run can resume: - **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception. - **Next steps:** one ordered work list, with the current step marked. +**Budget** (docs/ship-budget.md): `--token-cap ` (or GSTACK_SHIP_TOKEN_CAP); `--resume ` +or `--continue ` re-attach. Step 1 runs `gstack-ship-budget start --record `. With a cap, +read `sections/budget-handoff.md` relative to the installed `gstack-ship` SKILL.md directory, `check` at each step boundary and dispatch +batch, obey `verdict=`, `close` before Step 22. + A **receipt** is saved evidence of a check's command, result and consumed content. A review's **start token** is the opaque value returned by `gstack-review-log --start` before it reads the diff. Keep `REVIEW_START` for Step 9, a separate `PASS_START` for @@ -257,6 +262,7 @@ sections. Read a section in full before doing its step; do not work from memory. | preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` | | Step 19 printed CAPY_PR: on (a Capy session, GSTACK_CAPY=1) — read before composing the PR body | `sections/capy-pr.md` | | handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` | +| a token cap is set or verdict=delegate printed (Step 1 on) | `sections/budget-handoff.md` | --- diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 5846b96d3..2c3b87597 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -194,6 +194,11 @@ its absolute path, with these headings so a paused run can resume: - **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception. - **Next steps:** one ordered work list, with the current step marked. +**Budget** (docs/ship-budget.md): `--token-cap ` (or GSTACK_SHIP_TOKEN_CAP); `--resume ` +or `--continue ` re-attach. Step 1 runs `gstack-ship-budget start --record `. With a cap, +read `~/.claude/skills/gstack/ship/sections/budget-handoff.md`, `check` at each step boundary and dispatch +batch, obey `verdict=`, `close` before Step 22. + A **receipt** is saved evidence of a check's command, result and consumed content. A review's **start token** is the opaque value returned by `gstack-review-log --start` before it reads the diff. Keep `REVIEW_START` for Step 9, a separate `PASS_START` for @@ -257,6 +262,7 @@ sections. Read a section in full before doing its step; do not work from memory. | preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` | | Step 19 printed CAPY_PR: on (a Capy session, GSTACK_CAPY=1) — read before composing the PR body | `sections/capy-pr.md` | | handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` | +| a token cap is set or verdict=delegate printed (Step 1 on) | `sections/budget-handoff.md` | --- diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index afd639848..dba06ab6e 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -192,6 +192,11 @@ its absolute path, with these headings so a paused run can resume: - **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception. - **Next steps:** one ordered work list, with the current step marked. +**Budget** (docs/ship-budget.md): `--token-cap ` (or GSTACK_SHIP_TOKEN_CAP); `--resume ` +or `--continue ` re-attach. Step 1 runs `gstack-ship-budget start --record `. With a cap, +read `sections/budget-handoff.md` relative to the installed `gstack-ship` SKILL.md directory, `check` at each step boundary and dispatch +batch, obey `verdict=`, `close` before Step 22. + A **receipt** is saved evidence of a check's command, result and consumed content. A review's **start token** is the opaque value returned by `gstack-review-log --start` before it reads the diff. Keep `REVIEW_START` for Step 9, a separate `PASS_START` for @@ -255,6 +260,7 @@ sections. Read a section in full before doing its step; do not work from memory. | preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` | | Step 19 printed CAPY_PR: on (a Capy session, GSTACK_CAPY=1) — read before composing the PR body | `sections/capy-pr.md` | | handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` | +| a token cap is set or verdict=delegate printed (Step 1 on) | `sections/budget-handoff.md` | --- diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index e2c3e30bc..b41b69b3a 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -194,6 +194,11 @@ its absolute path, with these headings so a paused run can resume: - **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception. - **Next steps:** one ordered work list, with the current step marked. +**Budget** (docs/ship-budget.md): `--token-cap ` (or GSTACK_SHIP_TOKEN_CAP); `--resume ` +or `--continue ` re-attach. Step 1 runs `gstack-ship-budget start --record `. With a cap, +read `sections/budget-handoff.md` relative to the installed `gstack-ship` SKILL.md directory, `check` at each step boundary and dispatch +batch, obey `verdict=`, `close` before Step 22. + A **receipt** is saved evidence of a check's command, result and consumed content. A review's **start token** is the opaque value returned by `gstack-review-log --start` before it reads the diff. Keep `REVIEW_START` for Step 9, a separate `PASS_START` for @@ -257,6 +262,7 @@ sections. Read a section in full before doing its step; do not work from memory. | preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` | | Step 19 printed CAPY_PR: on (a Capy session, GSTACK_CAPY=1) — read before composing the PR body | `sections/capy-pr.md` | | handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` | +| a token cap is set or verdict=delegate printed (Step 1 on) | `sections/budget-handoff.md` | --- diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index c01582fed..5c4000f37 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -99,6 +99,11 @@ { "file": "lib/result-codes.ts", "movedFrom": "(new: result codes and troubleshooting anchors for unattended runs, multi-agent wave B0)" }, { "file": "lib/result-codes-release.ts", "movedFrom": "lib/result-codes.ts (the release-path rows: ship queue, pregate timing, approval classes, state matrix; field list 2 wave B)" }, { "file": "lib/result-codes-capy.ts", "movedFrom": "lib/result-codes.ts (the Capy rows: approval record, Captain brief, PR screenshots; GSTA-61)" }, + { "file": "lib/result-codes-budget.ts", "movedFrom": "lib/result-codes.ts (the /ship token budget and handoff rows; GSTA-152)" }, + { "file": "lib/ship-budget.ts", "movedFrom": "(new: /ship token budget ledger, meters and admission, GSTA-152)" }, + { "file": "lib/ship-budget-cli.ts", "movedFrom": "(new: bin/gstack-ship-budget start/check/settle/close/resume, GSTA-152)" }, + { "file": "lib/ship-handoff.ts", "movedFrom": "(new: /ship fresh-context handoff, verify, return and reconcile, GSTA-152)" }, + { "file": "lib/claude-usage.ts", "movedFrom": "(new: the Claude Code usage meter for the /ship budget, GSTA-152)" }, { "file": "lib/headless-artifacts.ts", "movedFrom": "(new: the unattended artifact contract: schemas, validator, result grammar, B0)" }, { "file": "lib/gate-list.ts", "movedFrom": "(new: the final gate list renderer and reply grammar, B9)" }, { "file": "lib/autoplan-timing.ts", "movedFrom": "(new: autoplan phase timing, estimate and cycle summary, B10)" }, diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index dc81a8c06..d39ab1440 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -27,7 +27,7 @@ const COPIED = [ 'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks 'bin/gstack-launch-probe.sh', 'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts', - 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', // the windows bash row + 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', // the windows bash row ]; const bases: string[] = []; afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index 2197ad31d..459c7d0d5 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -161,7 +161,7 @@ export const CARVE_GUARDS: Record = { }, behavioral: 'external', externalTest: 'test/skill-e2e-ship-section-loading.test.ts', - maxSkeletonBytes: 60_960, // GSTA-131 token efficiency (2026-10-27, Garry's D1): Step 1's delta carry (dry run, pregate at HEAD, then carry --pregate) and Step 11.5's fix-check and planned-skip binding, +60 bytes after tightening Step 1 and the Review policy paragraph; measured 60,960 (before: 60,900 on 10b77de4). Before that: GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): the hoisted skeleton (59,706) plus this PR's growth, keeping the hoist's 65-byte headroom; measured 60,848. Before: + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535. + harness lane E (2026-10-26, issue row 3: four Captains re-ran /ship on identical product code): Step 1's carry check (gstack-ship-receipt carry binds an earlier receipt to HEAD when the product patch-id is unchanged); measured 82,165. + the Review policy paragraph (the blocking floor, pass structure and cap, NO FINDINGS, the review_loop receipt counters, the review_scope knob), stated in the skeleton because gstack is the canonical holder of its review policy (docs/review-policy.md); measured 82,645. + maxSkeletonBytes: 61_420, // GSTA-152 token cap (2026-10-29, Garry's "Do it" on plan v3): the skeleton's Budget paragraph (the --token-cap, --resume and --continue flags, start/check/close, the budget-handoff pointer) and the section index's budget-handoff line; measured 61,420 (before: 60,958 on edf4c2e). Before that: GSTA-131 token efficiency (2026-10-27, Garry's D1): Step 1's delta carry (dry run, pregate at HEAD, then carry --pregate) and Step 11.5's fix-check and planned-skip binding, +60 bytes after tightening Step 1 and the Review policy paragraph; measured 60,960 (before: 60,900 on 10b77de4). Before that: GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): the hoisted skeleton (59,706) plus this PR's growth, keeping the hoist's 65-byte headroom; measured 60,848. Before: + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535. + harness lane E (2026-10-26, issue row 3: four Captains re-ran /ship on identical product code): Step 1's carry check (gstack-ship-receipt carry binds an earlier receipt to HEAD when the product patch-id is unchanged); measured 82,165. + the Review policy paragraph (the blocking floor, pass structure and cap, NO FINDINGS, the review_loop receipt counters, the review_scope knob), stated in the skeleton because gstack is the canonical holder of its review policy (docs/review-policy.md); measured 82,645. minUnionBytes: 181_000, // token-reduction Phases 1-2 (v1.69.x branch); measured union 201,464 mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'], // v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble. diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 997b1aea9..df59c287d 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -32,6 +32,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/autoplan-with-result-au.json': ['test/autoplan-phase-observer.test.ts'], 'test/fixtures/browse-judge/**': ['test/workflow-judge-cache.test.ts'], 'test/fixtures/ceo-completion-handoff-m-call.json': ['test/plan-count-completion.test.ts'], + 'test/fixtures/claude-usage-2.1.292.json': ['test/claude-usage.test.ts'], 'test/fixtures/context-restore-transcript.jsonl': ['test/context-restore-transcripts.test.ts'], 'test/fixtures/ceo-completion-handoff-o-call.json': ['test/plan-count-completion.test.ts'], 'test/fixtures/ceo-completion-handoff-q-call.json': ['test/plan-count-completion.test.ts'], diff --git a/test/helpers/ship-budget-harness.ts b/test/helpers/ship-budget-harness.ts new file mode 100644 index 000000000..be1a79b6d --- /dev/null +++ b/test/helpers/ship-budget-harness.ts @@ -0,0 +1,88 @@ +/** + * Scratch harness for the /ship budget tests (GSTA-152): a git repository with + * a feature branch and an origin/main ref, a private state root, a checkpoint + * directory standing in for the Drive mirror, an invocation record with the six + * headings, and runners for bin/gstack-ship-budget and bin/gstack-review-log. + */ +import { spawnSync } from 'node:child_process'; +import { bunIsolationArgs } from '../../lib/bun-isolation'; +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 BUDGET_BIN = path.join(ROOT, 'bin', 'gstack-ship-budget'); +export const REVIEW_LOG_BIN = path.join(ROOT, 'bin', 'gstack-review-log'); + +export const RECORD_TEXT = [ + '# /ship invocation record', '', + '## Release', 'reviewed tree: pending', '', + '## Decisions', '- Garry, 2026-10-29: "do it" covers the CHANGELOG wording in CHANGELOG.md', '', + '## Reviews', '- Step 9 pass 1 start token 1b4e28ba-2fa1-11d2-883f-0016d3cca427', '', + '## Checks', '- bun test: 12 pass', '', + '## Documentation', '- none yet', '', + '## Next steps', '- [ ] Step 7', '', +].join('\n'); + +export interface Run { code: number; out: string; err: string } +export interface Harness { + root: string; repo: string; state: string; checkpoints: string; env: NodeJS.ProcessEnv; + record(name?: string): string; + sb(args: string[], extraEnv?: NodeJS.ProcessEnv): Run; + reviewLog(args: string[]): Run; + git(...args: string[]): string; + field(out: string, line: string, key: string): string; + ledger(file: string): any; + cleanup(): void; +} + +export function budgetHarness(): Harness { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ship-budget-'))); + const repo = path.join(root, 'repo'); + const state = path.join(root, 'state'); + const checkpoints = path.join(root, 'checkpoints'); + fs.mkdirSync(repo, { recursive: true }); + const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GSTACK_CAPY: '', GSTACK_SHIP_BUDGET_CHECKPOINT_DIR: checkpoints, + CLAUDE_CONFIG_DIR: path.join(root, 'claude'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + delete env.GSTACK_SHIP_TOKEN_CAP; + delete env.GSTACK_LAUNCH_FILE; + delete env.GSTACK_STATE_DIR; + const git = (...args: string[]) => { + const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + git('init', '-q', '-b', 'main'); + fs.writeFileSync(path.join(repo, 'app.ts'), 'export const x = 1;\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('checkout', '-q', '-b', 'feature'); + fs.writeFileSync(path.join(repo, 'app.ts'), 'export const x = 2;\n'); + git('commit', '-qam', 'change'); + const run = (bin: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}): Run => { + const argv = bin === REVIEW_LOG_BIN ? ['bash', bin] : [process.execPath, ...bunIsolationArgs(), bin]; + const r = spawnSync(argv[0], [...argv.slice(1), ...args], { cwd: repo, env: { ...env, ...extraEnv }, encoding: 'utf8', timeout: 60_000 }); + return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' }; + }; + let records = 0; + return { + root, repo, state, checkpoints, env, git, + record(name = `record-${++records}.md`) { + const file = path.join(root, 'records', name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, RECORD_TEXT); + return file; + }, + sb: (args, extraEnv) => run(BUDGET_BIN, args, extraEnv), + reviewLog: args => run(REVIEW_LOG_BIN, args), + field(out, line, key) { + const row = out.split('\n').find(l => l.startsWith(line + ':')); + const m = row ? new RegExp(`(?:^|\\s)${key}=(\\S+)`).exec(row) : null; + if (!m) throw new Error(`no ${key}= on a ${line}: line in:\n${out}`); + return m[1]!; + }, + ledger: file => JSON.parse(fs.readFileSync(file, 'utf8')), + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index 910089329..e0c73fcf3 100644 --- a/test/plan-tune-cathedral.test.ts +++ b/test/plan-tune-cathedral.test.ts @@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; // These imports are resolved relative to the copied install, not the source checkout. const libDir = path.join(workDir, 'lib'); fs.mkdirSync(libDir, { recursive: true }); - for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts']) { + for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts']) { fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file)); } diff --git a/test/ship-budget-calibration.test.ts b/test/ship-budget-calibration.test.ts new file mode 100644 index 000000000..579c666af --- /dev/null +++ b/test/ship-budget-calibration.test.ts @@ -0,0 +1,37 @@ +/** + * Calibration (GSTA-152 plan item 8): the committed constants are the + * conservative fit of the recorded observations, and a fit on two metered runs + * is at or above the meter on the third (leave-one-out) for every constant + * three runs observed. + */ +import { describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; +import { fitConstant, loadCalibration, unitConstants } from '../lib/ship-budget'; + +const cal = loadCalibration(path.join(import.meta.dir, '..', 'lib/ship-budget-calibration.json')); + +describe('ship-budget calibration', () => { + test('constants equal the conservative fit of their observations', () => { + for (const [name, rows] of Object.entries(cal.observations)) { + expect(cal.constants[name as keyof typeof cal.constants]).toBe(fitConstant(rows.map(r => r.value), cal.headroom, cal.round_to)); + for (const row of rows) expect(row.source.length).toBeGreaterThan(20); + } + }); + + test('leave-one-out: fitted on two runs, the constant is at or above the meter on the third', () => { + const covered = Object.entries(cal.observations).filter(([, rows]) => new Set(rows.map(r => r.run)).size >= 3); + expect(covered.map(([name]) => name)).toContain('per_call'); + for (const [name, rows] of covered) { + for (const held of rows) { + const fit = fitConstant(rows.filter(r => r.run !== held.run).map(r => r.value), cal.headroom, cal.round_to); + expect(fit, `${name} fitted without ${held.run}`).toBeGreaterThanOrEqual(held.value); + } + } + }); + + test('every meter unit resolves to constants (calibration is per meter)', () => { + for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) { + expect(unitConstants(cal, unit).per_dispatch).toBeGreaterThan(0); + } + }); +}); diff --git a/test/ship-budget-review-log.test.ts b/test/ship-budget-review-log.test.ts new file mode 100644 index 000000000..dcd9e5763 --- /dev/null +++ b/test/ship-budget-review-log.test.ts @@ -0,0 +1,134 @@ +/** + * Pre-dispatch enforcement (GSTA-152 build-spec r2 item 1): under a cap, + * gstack-review-log --start refuses a dispatch without an unspent, one-shot + * reservation id from gstack-ship-budget check; --finish settles it (its + * completion receipt, item 7 M2); close audits unreserved dispatches after the + * fact. Without a cap nothing changes. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { RESULT_CODES } from '../lib/result-codes'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +function capped(h: Harness, cap = '80M') { + const record = h.record(); + const r = h.sb(['start', '--record', record, '--token-cap', cap, '--reading', '0']); + expect(r.code).toBe(0); + return { record, run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} +const reserve = (h: Harness, run: string, next = 'dispatch:specialist,red-team') => + h.field(h.sb(['check', '--run', run, '--step', '9', '--next', next, '--reading', '0']).out, 'RESERVATION', 'id'); +const startFiles = (h: Harness) => { + const dirs = fs.existsSync(path.join(h.state, 'projects')) ? fs.readdirSync(path.join(h.state, 'projects')) : []; + return dirs.flatMap(d => { try { return fs.readdirSync(path.join(h.state, 'projects', d, '.review-starts')); } catch { return []; } }); +}; + +describe('gstack-review-log --start under a token cap', () => { + test('without a cap, --start behaves exactly as before: one token line, no reservation asked', () => { + const h = fresh(); + const plain = h.reviewLog(['--start', 'review']); + expect(plain.code).toBe(0); + expect(plain.out.trim()).toMatch(UUID); + expect(plain.err).toBe(''); + const record = h.record(); + expect(h.sb(['start', '--record', record, '--token-cap', 'none', '--reading', '0']).code).toBe(0); + const still = h.reviewLog(['--start', 'review']); + expect(still.code).toBe(0); + expect(still.out.trim()).toMatch(UUID); + expect(still.err).toBe(''); + }); + + test('a skipped admission refuses before launch and captures nothing', () => { + const h = fresh(); + capped(h); + const before = startFiles(h).length; + const r = h.reviewLog(['--start', 'review']); + expect(r.code).toBe(1); + expect(r.out).toBe(''); + expect(r.err).toContain('(BUDGET_CHECK_SKIPPED)'); + expect(r.err).toContain('no --reservation'); + expect(r.err.trim()).toEndWith(`; fix: ` + RESULT_CODES.BUDGET_CHECK_SKIPPED.fix + ' (BUDGET_CHECK_SKIPPED)'); + expect(startFiles(h).length).toBe(before); + }); + + test('a mismatched reservation (another run, or a step reservation) refuses', () => { + const h = fresh(); + const other = capped(h); + const foreign = reserve(h, other.run); + const mine = capped(h); + expect(h.reviewLog(['--start', 'review', '--reservation', foreign]).err).toContain('(mismatched)'); + const stepRes = h.field(h.sb(['check', '--run', mine.run, '--step', '1', '--next', '2', '--reading', '0']).out, 'RESERVATION', 'id'); + const r = h.reviewLog(['--start', 'review', '--reservation', stepRes]); + expect(r.code).toBe(1); + expect(r.err).toContain('a step reservation'); + expect(r.err).toContain('(BUDGET_CHECK_SKIPPED)'); + }); + + 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 ok = h.reviewLog(['--start', 'review', '--reservation', id]); + expect(ok.code).toBe(0); + const token = ok.out.trim(); + expect(token).toMatch(UUID); + const again = h.reviewLog(['--start', 'review', '--reservation', id]); + expect(again.code).toBe(1); + expect(again.err).toContain('already consumed'); + let row = h.ledger(run.ledger).reservations.find((r: any) => r.id === id); + expect(row).toMatchObject({ state: 'consumed', token }); + const fin = h.reviewLog([JSON.stringify({ skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 }), '--finish', token]); + expect(fin.code).toBe(0); + row = h.ledger(run.ledger).reservations.find((r: any) => r.id === id); + expect(row.state).toBe('settled'); + expect(h.sb(['close', '--run', run.run, '--reading', '10']).code).toBe(0); + }); + + test('close prints one BUDGET_CHECK_SKIPPED line per unreserved dispatch and ends incomplete', () => { + const h = fresh(); + const run = capped(h); + const row = { skill: 'review', timestamp: new Date(Date.now() + 1000).toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 }; + expect(h.reviewLog([JSON.stringify(row)]).code).toBe(0); + const close = h.sb(['close', '--run', run.run, '--reading', '10']); + expect(close.code).toBe(3); + const skipped = close.out.split('\n').filter(l => l.endsWith('(BUDGET_CHECK_SKIPPED)')); + expect(skipped).toHaveLength(1); + expect(skipped[0]).toContain('role=review step=9'); + expect(skipped[0]).toContain('spend=unknown'); + expect(close.out).toContain('owner decision'); + expect(close.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/); + const accepted = h.sb(['close', '--run', run.run, '--reading', '20', '--approved-by', 'Garry', '--words', 'accept that spend']); + expect(accepted.code).toBe(0); + expect(accepted.out).toContain('ACCEPTED: 1 unreserved dispatches by Garry'); + }); + + test('a stopped run refuses every later dispatch until resume', () => { + const h = fresh(); + const run = capped(h, '20M'); + expect(h.sb(['check', '--run', run.run, '--step', '9', '--next', 'dispatch:specialist,red-team', '--reading', '9000000']).code).toBe(3); + const r = h.reviewLog(['--start', 'review', '--reservation', 'anything']); + expect(r.code).toBe(1); + expect(r.err).toContain('(BUDGET_CAP_REACHED)'); + }); + + test('a stopped run or a lost ledger does not block a later uncapped review on the branch', () => { + const h = fresh(); + const run = capped(h, '20M'); + expect(h.sb(['check', '--run', run.run, '--step', '9', '--next', 'dispatch:specialist,red-team', '--reading', '9000000']).code).toBe(3); + const afterStop = h.reviewLog(['--start', 'review']); + expect(afterStop.code).toBe(0); + expect(afterStop.out.trim()).toMatch(UUID); + const h2 = fresh(); + const lost = capped(h2); + fs.unlinkSync(lost.ledger); + const afterLoss = h2.reviewLog(['--start', 'review']); + expect(afterLoss.code).toBe(0); + expect(afterLoss.out.trim()).toMatch(UUID); + }); +}); diff --git a/test/ship-budget-section.test.ts b/test/ship-budget-section.test.ts new file mode 100644 index 000000000..da5cc2b7a --- /dev/null +++ b/test/ship-budget-section.test.ts @@ -0,0 +1,41 @@ +/** + * Skill structure for the /ship budget (GSTA-152 plan items 5 and 9): the + * budget-handoff section is registered, the skeleton points at it and names the + * start/check/close calls, every step id gstack-ship-budget accepts is a ship + * step, and the dispatch sections pass a reservation into gstack-review-log. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { DISPATCH_ROLES, SHIP_STEPS } from '../lib/ship-budget'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const templates = ['ship/SKILL.md.tmpl', ...fs.readdirSync(path.join(ROOT, 'ship/sections')).filter(f => f.endsWith('.md.tmpl')).map(f => `ship/sections/${f}`)]; + +describe('ship budget skill structure', () => { + test('budget-handoff is registered in the manifest and read from the skeleton', () => { + const manifest = JSON.parse(read('ship/sections/manifest.json')); + const entry = manifest.sections.find((s: any) => s.id === 'budget-handoff'); + expect(entry).toMatchObject({ file: 'budget-handoff.md' }); + expect(fs.existsSync(path.join(ROOT, 'ship/sections/budget-handoff.md.tmpl'))).toBe(true); + const skeleton = read('ship/SKILL.md.tmpl'); + expect(skeleton).toContain('sections/budget-handoff.md'); + for (const call of ['gstack-ship-budget start', 'check', 'close', '--token-cap', '--resume', '--continue', 'verdict=']) expect(skeleton).toContain(call); + }); + + test('every step id the budget accepts is a step the ship templates define', () => { + const text = templates.map(read).join('\n'); + for (const step of SHIP_STEPS) expect(text, `Step ${step}`).toMatch(new RegExp(`Step ${step.replace('.', '\\.')}(?![\\d.])`)); + }); + + test('the budget section names only valid steps and roles in its commands', () => { + const text = read('ship/sections/budget-handoff.md.tmpl'); + for (const m of text.matchAll(/--(?:step|from|to) ([\d.]+)/g)) expect(SHIP_STEPS as readonly string[]).toContain(m[1]!); + 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); + }); + + 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'); + }); +}); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts new file mode 100644 index 000000000..fcbd635b6 --- /dev/null +++ b/test/ship-budget.test.ts @@ -0,0 +1,413 @@ +/** + * /ship token budget (GSTA-152 PR A, plan v3 items 1-2, 4, 9; build-spec r2 + * items 2-7): free, deterministic, with a fake Capy meter passed as --reading. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { admits, loadCalibration, METER_CONTRACTS, newEpoch, parseNext, parseTokens, segmentEstimate, stepProjection, tally, unitConstants, type Ledger } from '../lib/ship-budget'; +import { BUDGET_BIN, budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const K = unitConstants(cal, 'capy-tokens'); +const STEP2 = stepProjection(cal, 'capy-tokens', '2'); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function started(h: Harness, args: string[]) { + const record = h.record(); + const r = h.sb(['start', '--record', record, ...args]); + return { record, r, run: r.code === 0 || r.code === 3 ? h.field(r.out, 'BUDGET_RUN', 'id') : '', ledger: r.code === 0 || r.code === 3 ? h.field(r.out, 'BUDGET_RUN', 'ledger') : '' }; +} + +describe('parsing and the admission rule', () => { + test('token counts, step ids and roles', () => { + expect(parseTokens('25M', '--token-cap')).toBe(25_000_000); + expect(parseTokens('400k', '--reading')).toBe(400_000); + expect(parseTokens('1.5M', '--reserve')).toBe(1_500_000); + expect(() => parseTokens('lots', '--token-cap')).toThrow(/not a token count/); + expect(parseNext('dispatch:specialist,red-team')).toEqual({ kind: 'dispatch', roles: ['specialist', 'red-team'] }); + expect(() => parseNext('7.7')).toThrow(/valid: 0.9, 1, 2/); + expect(() => parseNext('dispatch:security')).toThrow(/unknown roles \(security\)/); + }); + + test('exactly at the cap is admitted; one token over is not', () => { + const t = { used: 10, reserved: 5, unknown: 1 }; + expect(admits(t, 4, 0, 20)).toBe(true); + expect(admits(t, 5, 0, 20)).toBe(false); + expect(admits(t, 1_000, 0, null)).toBe(true); + }); + + test('CLI boundary: a check exactly at the cap continues, one token over stops (exit 3)', () => { + const h = fresh(); + const cap = 40_000_000; + for (const [extra, code, verdict] of [[0, 0, 'continue'], [1, 3, 'stop']] as const) { + const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + expect(s.r.code).toBe(0); + const reading = cap - STEP2 - K.stop_overhead + extra; + const c = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', String(reading)]); + expect(c.code).toBe(code); + expect(h.field(c.out, 'BUDGET', 'verdict')).toBe(verdict); + expect(h.field(c.out, 'BUDGET', 'admission_cap')).toBe(String(cap)); + } + }); +}); + +describe('reservations', () => { + test('a parallel batch is reserved as one sum and refused whole when it would overshoot', () => { + const h = fresh(); + const cap = 2 * K.per_dispatch + K.stop_overhead + 1_000_000; + const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + const over = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist,red-team,fix-check', '--reading', '0']); + expect(over.code).toBe(3); + expect(over.out).toContain('(BUDGET_CAP_REACHED)'); + expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch')).toHaveLength(0); + }); + + test('#31 replay: a 25M cap stops before the dispatch that would cross it', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '25M', '--reading', '0']); + let reading = 0; + let admitted = 0; + let stop = ''; + for (let i = 0; i < 10; i++) { + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', String(reading)]); + if (c.code === 3) { stop = c.out; break; } + const id = h.field(c.out, 'RESERVATION', 'id'); + reading += 4_950_000; + admitted++; + expect(h.sb(['settle', '--run', s.run, '--reservation', id]).code).toBe(0); + } + expect(stop).toContain('STOP: reason=budget_exhausted'); + expect(reading).toBeLessThanOrEqual(25_000_000); + expect(admitted).toBeLessThan(10); + expect(stop).toMatch(/^RECOVER: \/ship --resume \S+ --token-cap \d+$/m); + expect(stop.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/); + }); + + test('actual spend above its reservation is counted from the meter, and the next admission sees it', () => { + const h = fresh(); + const cap = 30_000_000; + const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + 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; + 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); + expect(next.code).toBe(spent + K.per_dispatch + 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); + }); + + test('without a cap: no marker, no reservation, no GSTACK_RESULT, and usage is still metered', () => { + const h = fresh(); + const s = started(h, ['--token-cap', 'none', '--reading', '1000']); + expect(s.r.code).toBe(0); + expect(s.r.out).not.toContain('RESERVATION'); + expect(s.r.out).not.toContain('GSTACK_RESULT'); + const c = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '5000']); + expect(c.code).toBe(0); + expect(h.field(c.out, 'BUDGET', 'used')).toBe('4000'); + expect(h.field(c.out, 'BUDGET', 'admission_cap')).toBe('none'); + expect(c.out).not.toContain('GSTACK_RESULT'); + const projects = path.join(h.state, 'projects'); + const markers = fs.existsSync(projects) ? fs.readdirSync(projects).flatMap(d => fs.readdirSync(path.join(projects, d))).filter(f => f.endsWith('-ship-budget.json')) : []; + expect(markers).toEqual([]); + }); +}); + +describe('meters and exit codes', () => { + test('a cap with no readable meter refuses; --meter estimate prints meter=estimate', () => { + const h = fresh(); + const r = h.sb(['start', '--record', h.record(), '--token-cap', '25M']); + expect(r.code).toBe(2); + expect(r.err).toContain('(BUDGET_METER_UNAVAILABLE)'); + expect(r.err).toContain('--meter estimate (not passed)'); + const e = h.sb(['start', '--record', h.record(), '--token-cap', '25M', '--meter', 'estimate']); + expect(e.code).toBe(0); + expect(h.field(e.out, 'BUDGET', 'meter')).toBe('estimate'); + expect(h.field(e.out, 'BUDGET', 'unit')).toBe('estimate-tokens'); + }); + + test('the Capy meter needs a reading under a cap; lower readings and readings to other meters are rejected', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '50M', '--reading', '1000']); + const missing = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2']); + expect(missing.code).toBe(2); + expect(missing.err).toContain('(BUDGET_READING_MISSING)'); + const lower = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '999']); + expect(lower.code).toBe(2); + expect(lower.err).toContain('(BUDGET_READING_REJECTED)'); + const est = started(h, ['--token-cap', '50M', '--meter', 'estimate']); + const wrong = h.sb(['check', '--run', est.run, '--step', '1', '--next', '2', '--reading', '5']); + expect(wrong.err).toContain('(BUDGET_READING_REJECTED)'); + }); + + test('a meter change opens a new epoch and keeps prior usage and reservations', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '60M', '--reading', '0']); + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '3000000']); + expect(c.code).toBe(0); + const r = h.sb(['resume', '--run', s.run, '--meter', 'estimate']); + expect(r.code).toBe(0); + expect(r.out).toContain('EPOCH: 2 meter=estimate'); + expect(h.field(r.out, 'BUDGET', 'used')).toBe('3000000'); + expect(Number(h.field(r.out, 'BUDGET', 'reserved'))).toBeGreaterThanOrEqual(K.per_dispatch); + expect(h.ledger(s.ledger).epochs[0].contract.unit).toBe('capy-tokens'); + }); + + test('usage errors exit 2 on stderr with the value and the repair; unknown ids list the valid ones', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '50M', '--reading', '0']); + const step = h.sb(['check', '--run', s.run, '--step', '7.7', '--next', '8', '--reading', '0']); + expect(step.code).toBe(2); + expect(step.out).toBe(''); + expect(step.err).toContain("--step '7.7' is not a ship step id; valid: 0.9, 1, 2, 3"); + const role = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:security', '--reading', '0']); + expect(role.err).toContain('valid: specialist, red-team, adversarial-native, outside-voice, fix-check'); + expect(h.sb(['check', '--step', '1', '--next', '2']).err).toContain('never found by branch or directory'); + expect(h.sb(['frobnicate']).code).toBe(2); + expect(h.sb(['start', '--record', h.record(), '--token-cap', 'lots']).err).toContain("--token-cap 'lots' is not a token count"); + }); +}); + +describe('delegation', () => { + test('start from a long-lived context (call >= 100k) delegates the whole /ship', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8880000']); + expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('120000'); + expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate'); + expect(s.r.out).toContain('--from 1 --to 22'); + }); + + 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']); + 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']); + 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']); + expect(stop.code).toBe(3); + expect(stop.out).toContain('STOP: reason=context_threshold'); + }); + + 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', '1000000']); + 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']); + expect(ho.code).toBe(0); + const brief = ho.out.match(/^HANDOFF: (\S+) sha256=([0-9a-f]{64}) sub_cap=(\d+)$/m)!; + expect(brief).toBeTruthy(); + expect(Number(brief[3])).toBeLessThanOrEqual(segmentEstimate(cal, 'capy-tokens', '7', '11.5')); + expect((fs.statSync(brief[1]!).mode & 0o777).toString(8)).toBe('600'); + const text = fs.readFileSync(brief[1]!, 'utf8'); + expect(text).toContain('1b4e28ba-2fa1-11d2-883f-0016d3cca427'); + expect(text).toContain('"do it" covers the CHANGELOG wording'); + expect(text).toContain('## Return contract'); + const v = h.sb(['verify-handoff', '--in', brief[1]!, '--reading', '1400000']); + 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'); + const cc = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '5000000']); + expect(cc.code).toBe(0); + expect(h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']).out).toContain('a child never delegates'); + 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); + expect(ret.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=complete run=/); + 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(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'); + expect(h.field(again.out, 'BUDGET', 'reserved')).toBe('0'); + const close = h.sb(['close', '--run', s.run, '--reading', '9300000']); + expect(close.code).toBe(0); + expect(close.out).toContain('BUDGET_FINAL: used=8300000'); + expect(close.out).not.toContain('GSTACK_RESULT'); + }); + + test('pins: HEAD moving refuses with HANDOFF_PIN_MOVED and a tampered brief refuses', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '80M', '--reading', '0']); + const brief = () => h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '10']).out.match(/^HANDOFF: (\S+)/m)![1]!; + const second = brief(); + fs.writeFileSync(path.join(h.repo, 'app.ts'), 'export const x = 3;\n'); + h.git('commit', '-qam', 'moved'); + const moved = h.sb(['verify-handoff', '--in', second, '--reading', '30']); + expect(moved.code).toBe(3); + expect(moved.out).toMatch(/head expected [0-9a-f]{40} actual [0-9a-f]{40}.*\(HANDOFF_PIN_MOVED\)/); + expect(moved.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=refused run=/); + const third = brief(); + fs.appendFileSync(third, '\nextra instruction\n'); + const tampered = h.sb(['verify-handoff', '--in', third, '--reading', '40']); + expect(tampered.code).toBe(3); + expect(tampered.out).toMatch(/brief sha256 expected [0-9a-f]{64} actual [0-9a-f]{64}.*\(HANDOFF_PIN_MOVED\)/); + }); + + test('main moving alone keeps the merge base, so the brief still verifies', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '80M', '--reading', '0']); + const ho = h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '10']); + const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const feature = h.git('rev-parse', 'HEAD'); + h.git('checkout', '-q', 'main'); + fs.writeFileSync(path.join(h.repo, 'other.ts'), 'export {};\n'); + h.git('add', '-A'); + h.git('commit', '-qm', 'main moves'); + h.git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + h.git('checkout', '-q', 'feature'); + expect(h.git('rev-parse', 'HEAD')).toBe(feature); + expect(h.sb(['verify-handoff', '--in', file, '--reading', '20']).code).toBe(0); + }); + + test('a refused child and an interrupted child both stop the parent; their reservations settle once', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '80M', '--reading', '0']); + const ho = h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '10']); + const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; + fs.writeFileSync(path.join(h.repo, 'app.ts'), 'export const x = 9;\n'); + expect(h.sb(['verify-handoff', '--in', file, '--reading', '20']).code).toBe(3); + const refused = h.sb(['reconcile', '--run', s.run, '--return', returnFile, '--reading', '30']); + expect(refused.code).toBe(3); + expect(refused.out).toContain('child=refused'); + expect(refused.out).toContain('NEXT: the child returned refused; regenerate the handoff'); + const ho2 = h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '40']); + const sha = ho2.out.match(/sha256=([0-9a-f]{64})/)![1]!; + const interrupted = h.sb(['reconcile', '--run', s.run, '--handoff', sha, '--reading', '5000040']); + expect(interrupted.code).toBe(3); + expect(interrupted.out).toContain('child=interrupted child_used=5000000'); + expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'child').map((r: any) => r.state)).toEqual(['settled', 'settled']); + expect(Number(h.field(interrupted.out, 'BUDGET', 'reserved'))).toBe(stepProjection(cal, 'capy-tokens', '1')); + }); +}); + +describe('Claude Code child launch', () => { + test('launch runs the child in the background with explicit permission flags and the return file named', async () => { + const h = fresh(); + const s = started(h, ['--token-cap', '80M', '--meter', 'estimate']); + const ho = h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main']); + const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const argsFile = path.join(h.root, 'claude-args.txt'); + const fake = path.join(h.root, 'fake-claude'); + fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}"\n`, { mode: 0o755 }); + const r = h.sb(['launch', '--in', file], { GSTACK_CLAUDE_BIN: fake }); + expect(r.code).toBe(0); + const session = h.field(r.out, 'CHILD', 'session'); + expect(h.field(r.out, 'CHILD', 'return')).toBe(ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!); + for (let i = 0; i < 50 && !fs.existsSync(argsFile); i++) await Bun.sleep(100); + const args = fs.readFileSync(argsFile, 'utf8').split('\n'); + expect(args.slice(0, 7)).toEqual(['-p', '--session-id', session, '--permission-mode', 'acceptEdits', '--allowedTools', 'Bash,Read,Edit,Write,Grep,Glob']); + expect(args[7]).toContain(`verify-handoff --in ${file} --session ${session}`); + }); +}); + +describe('stop, resume and recovery', () => { + test('stop prints a RECOVER line; executing it needs the owner\'s words and then the run continues', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '20M', '--reading', '0']); + const stop = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist,red-team', '--reading', '9000000']); + expect(stop.code).toBe(3); + const recover = stop.out.match(/^RECOVER: \/ship --resume (\S+) --token-cap (\d+)$/m)!; + expect(recover[1]).toBe(s.record); + expect(h.sb(['check', '--run', s.run, '--step', '9', '--next', '10', '--reading', '9000000']).code).toBe(3); + const noWords = h.sb(['resume', '--record', recover[1]!, '--token-cap', recover[2]!, '--reading', '9100000']); + expect(noWords.code).toBe(2); + expect(noWords.err).toContain('--approved-by'); + const ok = h.sb(['resume', '--record', recover[1]!, '--token-cap', recover[2]!, '--approved-by', 'Garry', '--words', 'raise it to finish', '--reading', '9100000']); + expect(ok.code).toBe(0); + expect(ok.out).toContain('RESUMED: from=Step 9 (budget_exhausted)'); + const go = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist,red-team', '--reading', '9200000']); + expect(go.code).toBe(0); + expect(h.ledger(s.ledger).cap_raises[0]).toMatchObject({ from: 20_000_000, to: Number(recover[2]), by: 'Garry', words: 'raise it to finish' }); + }); + + test('a machine change: the ledger and record come back from the checkpoint into an empty state root', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '60M', '--reading', '0']); + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '2000000']); + expect(c.code).toBe(0); + fs.rmSync(h.state, { recursive: true, force: true }); + fs.rmSync(path.dirname(s.record), { recursive: true, force: true }); + const r = h.sb(['resume', '--run', s.run, '--reading', '2500000']); + expect(r.code).toBe(0); + expect(r.out).toContain(`RESTORED: ledger=${s.ledger} record=${s.record}`); + expect(h.field(r.out, 'BUDGET', 'used')).toBe('2500000'); + expect(Number(h.field(r.out, 'BUDGET', 'reserved'))).toBe(K.per_dispatch); + expect(fs.readFileSync(s.record, 'utf8')).toContain('```gstack-budget'); + }); + + test('a corrupt ledger refuses with path, schema and parse location; --rebuild-from-record charges open reservations as unknown', () => { + const h = fresh(); + const s = started(h, ['--token-cap', '60M', '--reading', '0']); + h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '1000000']); + fs.writeFileSync(s.ledger, '{"kind": "gstack-ship-budget", oops'); + const bad = h.sb(['resume', '--record', s.record, '--reading', '1500000']); + expect(bad.code).toBe(2); + expect(bad.err).toContain(s.ledger); + expect(bad.err).toMatch(/schema_version unknown, parse error: .*\(BUDGET_LEDGER_INVALID\)/); + const rebuilt = h.sb(['resume', '--record', s.record, '--rebuild-from-record', '--reading', '1500000']); + expect(rebuilt.code).toBe(0); + expect(rebuilt.out).toContain('REBUILT:'); + expect(h.field(rebuilt.out, 'BUDGET', 'used')).toBe('1000000'); + expect(Number(h.field(rebuilt.out, 'BUDGET', 'unknown'))).toBeGreaterThanOrEqual(K.per_dispatch); + }); +}); + +describe('isolation', () => { + test('two ledgers run concurrently, and contention on one ledger never over-admits', async () => { + const h = fresh(); + const a = started(h, ['--token-cap', '60M', '--reading', '0', '--reserve', '0']); + const b = started(h, ['--token-cap', '60M', '--reading', '0', '--reserve', '0']); + expect(a.ledger).not.toBe(b.ledger); + const cap = 2 * K.per_dispatch + K.stop_overhead + stepProjection(cal, 'capy-tokens', '1'); + const tight = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + const spawn = (run: string) => Bun.spawn([BUDGET_BIN, 'check', '--run', run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0'], { cwd: h.repo, env: h.env, stdout: 'pipe', stderr: 'pipe' }); + const procs = [...Array.from({ length: 6 }, () => spawn(tight.run)), spawn(a.run), spawn(b.run)]; + const codes = await Promise.all(procs.map(p => p.exited)); + expect(codes.slice(0, 6).filter(c => c === 0)).toHaveLength(2); + expect(codes.slice(6)).toEqual([0, 0]); + for (const file of [a.ledger, b.ledger]) expect(h.ledger(file).reservations.filter((r: any) => r.kind === 'dispatch')).toHaveLength(1); + }); + + test('budget state lives outside the checkout: gstack-wtree and git status are unchanged across checks', () => { + const h = fresh(); + const wtree = () => spawnSync('bash', [path.join(import.meta.dir, '..', 'bin', 'gstack-wtree')], { cwd: h.repo, env: h.env, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + const before = wtree(); + const s = started(h, ['--token-cap', '60M', '--reading', '0']); + h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '10']); + h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '20']); + expect(wtree()).toBe(before); + expect(h.git('status', '--porcelain')).toBe(''); + expect(s.ledger.startsWith(path.join(h.state, 'ship-runs'))).toBe(true); + expect((fs.statSync(s.ledger).mode & 0o777).toString(8)).toBe('600'); + }); +}); + +describe('meter epochs', () => { + test('estimate spend survives a change of meter (estimate -> capy -> estimate)', () => { + const l = { meter: { contract: METER_CONTRACTS.estimate, epoch: 0, baseline: 0, last: 0 }, epochs: [], carried_used: 0, estimate_used: 20_000_000, unknown: 0, reservations: [] } as unknown as Ledger; + expect(tally(l).used).toBe(20_000_000); + newEpoch(l, METER_CONTRACTS.capy, 100_000_000); + expect(tally(l).used).toBe(20_000_000); + l.meter.last = 103_000_000; + newEpoch(l, METER_CONTRACTS.estimate, 0); + expect(tally(l).used).toBe(23_000_000); + l.estimate_used = 1_000_000; + expect(tally(l).used).toBe(24_000_000); + }); +}); diff --git a/test/size-ledger/ship/capy-token-cap-with-real.json b/test/size-ledger/ship/capy-token-cap-with-real.json new file mode 100644 index 000000000..0ba857060 --- /dev/null +++ b/test/size-ledger/ship/capy-token-cap-with-real.json @@ -0,0 +1,4 @@ +{ + "bytes": 96, + "why": "fence placeholders become shell variables (GSTA-152 PR A pregate fix)" +} diff --git a/test/size-ledger/ship/gsta152-ship-budget.json b/test/size-ledger/ship/gsta152-ship-budget.json new file mode 100644 index 000000000..4bdbef8aa --- /dev/null +++ b/test/size-ledger/ship/gsta152-ship-budget.json @@ -0,0 +1,4 @@ +{ + "bytes": 4443, + "why": "GSTA-152 token cap and fresh-context handoff: the skeleton's Budget paragraph and section-index line, the new budget-handoff section (meters, reservations, stop/resume, delegate/handoff/reconcile), and the --reservation clauses in review-army (Step 9 item 2, fix-check 3.5) and adversarial (Step 11)" +} diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts index 868f8f260..0c9975788 100644 --- a/test/timeline-stop-hook-slug.test.ts +++ b/test/timeline-stop-hook-slug.test.ts @@ -32,6 +32,7 @@ const HOOK_FILES = [ 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', + 'lib/result-codes-budget.ts', ]; const SLUG = 'stop-hook-slug-test'; const BUDGET_MS = 2000;