v1.92.40.0 ship-budget: a subagent's /ship cost says it is the root counter, an upper bound (GST-467) (#3696)

* gstack-ship-budget start --meter-scope self|tree: label tree-scoped cost lines, refuse METER_SCOPE_PARENT (GST-467)

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

* ship-budget: a ledger rebuilt from its record keeps meter_scope=tree; document the cap on a tree run

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

* ship-budget: a re-start asking for tree upgrades an open run; tree labels reach the return file, drift and reconcile lines; reading-missing fix names the subagent route

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

* docs/ship-budget.md: GSTACK_SHIP_SUBAGENT=1 stands in for --subagent only

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-01 20:45:39 +00:00
1 parent 77fe28c397
commit 6ffbfb8ef1
15 files changed
+248 -25

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [1.92.40.0] - 2026-11-01
**A subagent's /ship cost now says it is an upper bound.** A Capy subagent cannot read its own jam counter, so its /ship read `"this"`, the root thread, whose counter also holds every sibling lane. BUDGET_FINAL and the notes printed that total as if it were the run's own cost.
### Added
- **`gstack-ship-budget start --meter-scope self|tree`.** A run started with `tree`, or with `--subagent` (or `GSTACK_SHIP_SUBAGENT=1`) and `--reading-from this`, is tree-scoped: every BUDGET, NOTE, BUDGET_FINAL and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`.
- **`METER_SCOPE_PARENT`.** A subagent reading `"this"` that asks for `--meter-scope self` is refused with exit 2 and the fix `--meter-scope tree` (docs/troubleshooting.md#meter-scope-parent).
### Changed
- The Capy skill and docs/ship-budget.md tell a subagent to start with `--subagent --reading-from this --meter-scope tree`, and say exact per-run cost comes from a root replay thread. The driver's Step 22 carries the `METER: tree` line into the report.
### Tests
- `test/ship-budget-meter-scope.test.ts`: tree runs label every cost line, self runs print none, the refusal and its troubleshooting anchor, and the docs pointers.
## [1.92.39.0] - 2026-11-01
**A push to main no longer runs Free Tests and Windows Free Tests again on a tree its pull request already passed.** 22 of 22 merges in 30 hours (Oct 27–29) reran both suites on the identical tree, about 66 Linux shard-minutes and 44 Windows minutes each. Now a `reuse` job, on main pushes only, finds the merged pull request, requires its head's tree to equal the pushed tree, and verifies that head's newest green `pull_request` or `workflow_dispatch` run with the same checks `gstack-pregate clear --run` uses. When it verifies, every suite job is skipped and the required check links the reused run. Anything else runs every job as before. The same verifier backs a new `--from-run` flag for the pregate tests stage.
+1 -1
View File
@@ -1 +1 @@
1.92.39.0
1.92.40.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.39.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.92.40.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,
+1
View File
@@ -361,6 +361,7 @@ gstack_result_anchor() {
UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;;
BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;;
BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;;
METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;;
BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;;
BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;;
BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;;
+1 -1
View File
@@ -44,7 +44,7 @@ Run directories and scratch on Capy live under `~/.capy/work/<name>` (`--out ~/.
Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render <dir>/gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent:<id> --owner-reply "<their words>"`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out <new dir> --from <that dir>` restores the newest valid checkpoint.
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: "<RANGE_BASE>"`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship began. A small waiting child saves nothing.
+9 -2
View File
@@ -76,8 +76,14 @@ one program measure nothing: Capy books a call after its program ends.
A jam's own counter includes its own subagents, outside voices too (measured 2026-10-30: +10.92M
across a six-reader dispatch with two ~0.35M parent calls between). `"this"` is the root thread,
whose counter also holds every sibling tree, so a subagent running /ship passes its own jam id;
a root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it
whose counter also holds every sibling tree. A subagent running /ship cannot read its own jam id, so it
reads `"this"` and starts with `--subagent --reading-from this --meter-scope tree` (`GSTACK_SHIP_SUBAGENT=1` in its
brief stands in for `--subagent` only; `--reading-from this` is still required): the run is tree-scoped, and every BUDGET, NOTE, BUDGET_FINAL
and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. A
subagent that asks for `--meter-scope self` while reading `"this"` is refused
(`METER_SCOPE_PARENT`). A `--token-cap` on a tree-scoped run stops on that upper bound, so sibling
lanes count against it. Exact per-run cost comes from a root replay thread: a root thread that runs
only that /ship, whose `"this"` is its own counter. A root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it
verifies with no reading and prints `METER: parent`; the parent's handoff and reconcile readings,
taken on its own counter (which holds the child), measure it.
@@ -286,6 +292,7 @@ stop rules) before claiming a saving from the cap or from delegation.
[BUDGET_CAP_REACHED](troubleshooting.md#budget-cap-reached) ·
[BUDGET_READING_MISSING](troubleshooting.md#budget-reading-missing) ·
[METER_SCOPE_PARENT](troubleshooting.md#meter-scope-parent) ·
[BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected) ·
[BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable) ·
[BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid) ·
+12
View File
@@ -1942,6 +1942,18 @@ owner's call: run the printed `RECOVER:` line; its resume needs
**Fix.** In the execute program that runs the command, read
`(await tools.thread.get({ thread_id: "<your own jam id>" })).cost.tokens` and pass it as `--reading <n>`.
<a id="meter-scope-parent"></a>
### `gstack-ship-budget: ... (METER_SCOPE_PARENT)`
**Meaning.** `start` was told the run is a subagent (`--subagent` or `GSTACK_SHIP_SUBAGENT=1`) reading
`"this"` (`--reading-from this`), the root thread's counter, which also holds every sibling lane, and
asked for `--meter-scope self`. A self scope would print that counter as the run's own cost. Exit 2;
no run started.
**Fix.** Start with `--meter-scope tree`. Every cost line then prints
`METER: tree (root counter, upper bound)`. Exact per-run cost comes from a root replay thread
(`docs/ship-budget.md`).
<a id="budget-reading-rejected"></a>
### `gstack-ship-budget: ... (BUDGET_READING_REJECTED)`
+6 -1
View File
@@ -17,7 +17,12 @@ export const BUDGET_RESULT_CODES = {
BUDGET_READING_MISSING: {
anchor: 'budget-reading-missing',
summary: 'the Capy meter needs a fresh counter reading and none was passed',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included; a subagent that can read only the root thread starts with --subagent --reading-from this --meter-scope tree) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
},
METER_SCOPE_PARENT: {
anchor: 'meter-scope-parent',
summary: 'a subagent asked for a self-scoped run while its reading comes from "this", the root thread\'s counter, which also holds every sibling lane',
fix: 'start with --meter-scope tree (every cost line then prints METER: tree (root counter, upper bound)); exact per-run cost comes from a root replay thread',
},
BUDGET_READING_REJECTED: {
anchor: 'budget-reading-rejected',
+15 -6
View File
@@ -14,7 +14,7 @@ import * as path from 'node:path';
import { resultLine } from './result-codes';
import {
BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS,
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, labelScope, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths,
runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
type Ledger, type MeterKind, type Next, type Note,
@@ -237,9 +237,15 @@ function pickMeter(o: Opts): MeterKind | undefined {
}
export function cmdStart(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode']);
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from'], ['subagent']);
const mode = str(o, 'mode') ?? 'admit';
if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`);
const scope = str(o, 'meter-scope');
if (scope !== undefined && scope !== 'self' && scope !== 'tree') throw usageError(`--meter-scope '${scope}' is not self or tree`);
const from = str(o, 'reading-from');
if (from !== undefined && from !== 'this' && from !== 'own') throw usageError(`--reading-from '${from}' is not this or own`);
const parentCounter = (o.subagent === true || ctx.env.GSTACK_SHIP_SUBAGENT === '1') && from === 'this';
if (parentCounter && scope === 'self') throw budgetError('METER_SCOPE_PARENT', 'start --subagent --reading-from this --meter-scope self');
const record = str(o, 'record');
if (!record) throw usageError('start needs --record <invocation record path>');
const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP;
@@ -249,8 +255,10 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
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 };
return mutateLedger(existing.ledger, ctx.env, l => {
if (scope === 'tree' || parentCounter) l.meter_scope = 'tree';
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) {
@@ -271,6 +279,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}),
...(callFloor !== undefined ? { call_floor: callFloor } : {}), ...(mode === 'observe' ? { mode } : {}),
...(scope === 'tree' || parentCounter ? { meter_scope: 'tree' as const } : {}),
};
if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy');
if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!;
@@ -279,7 +288,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
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 };
return labelScope(l, { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit });
}
/**
@@ -472,7 +481,7 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op
meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null },
epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [],
...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(),
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}),
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), ...(b.meter_scope === 'tree' ? { meter_scope: 'tree' as const } : {}),
};
if (meter === 'estimate') l.estimate_used = 0;
fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 });
+16 -2
View File
@@ -91,6 +91,8 @@ export interface Ledger {
/** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */
credits?: { baseline: number; last: number };
notes?: Note[];
/** tree: the reading is the root thread's counter (a subagent cannot read its own jam id), so every cost line carries TREE_METER. */
meter_scope?: 'tree';
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
}
@@ -359,10 +361,22 @@ export function mutateLedger<T>(file: string, env: NodeJS.ProcessEnv, fn: (l: Le
const ledger = readLedger(file);
const out = fn(ledger);
writeLedger(ledger, env);
return out;
return labelScope(ledger, out);
});
}
export const TREE_METER = 'METER: tree (root counter, upper bound)';
/** A tree-scoped run never prints a cost line without TREE_METER on the line after it. */
export function labelScope<T>(l: Ledger, out: T): T {
if (l.meter_scope !== 'tree') return out;
const label = (lines: string[]) => lines.flatMap((line, i) => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/.test(line) && lines[i + 1] !== TREE_METER ? [line, TREE_METER] : [line]);
if (Array.isArray(out)) return label(out) as T;
const o = out as { stdout?: unknown };
if (Array.isArray(o?.stdout)) o.stdout = label(o.stdout);
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;
@@ -463,7 +477,7 @@ 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';
const credits = creditsUsed(l);
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`;
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}${l.meter_scope === 'tree' ? ' meter_scope=tree' : ''}`;
}
/** The record's fenced gstack-budget block: written by the bin, never by the agent. */
+6 -6
View File
@@ -25,7 +25,7 @@ import { resolveClaudeCommand } from './claude-bin';
import { wtreeFingerprint } from './script-invocation';
import { resultLine } from './result-codes';
import {
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, labelScope, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
projector, readLedger, reserveIn, runDirFor, segmentEstimate, settleIn, tally, usageError, writeLedger, type Handoff, type Ledger,
} from './ship-budget';
import { loadMeterState, saveMeterState } from './claude-usage';
@@ -195,7 +195,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) },
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration,
...(parentMetered ? { parent_metered: true } : {}),
...(parentMetered ? { parent_metered: true } : {}), ...(parent.meter_scope ? { meter_scope: parent.meter_scope } : {}),
};
if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session <this child session id>');
readMeter(child, o, !parentMetered);
@@ -205,9 +205,9 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
});
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}`,
return labelScope(child, { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`,
...(parentMetered ? [`${PARENT_METERED} (no counter of its own: the parent's handoff and reconcile readings measure Steps ${block.from}-${block.to})`] : []),
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 };
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 });
}
export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
@@ -229,11 +229,11 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
const unreconciled = l.handoffs.filter(h => !h.reconciled_at);
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status;
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
const lines = labelScope(l, [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`),
...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`),
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`];
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]);
writeReturnFile(l.parent.return_file, lines);
l.closed_at = new Date().toISOString();
return { stdout: lines, exit: 0 };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.92.39",
"version": "1.92.40",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+2 -2
View File
@@ -120,8 +120,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
- Step 20: the metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
## Where each STOP sends you (paths under `<root>/ship/`)
+2 -2
View File
@@ -118,8 +118,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
- Step 20: the metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
## Where each STOP sends you (paths under `<root>/ship/`)
+161
View File
@@ -0,0 +1,161 @@
/**
* GST-467: a subagent running /ship cannot read its own jam id, so its Capy reading is the root
* thread's counter, which also holds every sibling lane. Such a run is tree-scoped and every cost
* line it prints carries METER: tree (root counter, upper bound); a subagent that asks for a self
* scope while metering from "this" is refused (METER_SCOPE_PARENT).
*/
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 { labelScope, type Ledger } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const ROOT = path.resolve(import.meta.dir, '..');
const TREE = 'METER: tree (root counter, upper bound)';
const COST = /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/;
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
function costLinesLabeled(out: string): boolean[] {
const lines = out.split('\n');
return lines.flatMap((l, i) => COST.test(l) ? [lines[i + 1] === TREE] : []);
}
function observedRun(h: Harness, start: string[], env: NodeJS.ProcessEnv = {}) {
const s = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--credits', '100', ...start], env);
if (s.code !== 0) return { start: s, note: s, close: s };
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const note = h.sb(['note', '--run', run, '--step', '6', '--reading', '1500000', '--credits', '150']);
const close = h.sb(['close', '--run', run, '--reading', '2000000', '--credits', '200']);
return { start: s, note, close };
}
describe('meter scope (GST-467)', () => {
// Value: protects=a run metered off the root counter never prints a cost without saying it is the root counter, an upper bound; fails_when=a tree-scoped run prints a BUDGET, NOTE, BUDGET_FINAL or BUDGET_TIMELINE line with no METER: tree line under it, or a self run is labeled tree;
// why_new=#3686's ledger baseline 5,769,355,282 was the root thread's counter, sibling lanes included (GST-467); seam=none; no_claim=the exact per-run cost, which only a root replay thread measures
test('every cost line of a tree-scoped run is labeled, whether asked for or implied by a subagent reading "this"; a self run is not', () => {
for (const [start, env] of [[['--meter-scope', 'tree'], {}], [['--subagent', '--reading-from', 'this'], {}], [['--reading-from', 'this'], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) {
const r = observedRun(fresh(), [...start], env);
for (const out of [r.start, r.note, r.close]) {
expect(out.code, out.err).toBe(0);
const labels = costLinesLabeled(out.out);
expect(labels.length, out.out).toBeGreaterThan(0);
expect(labels.every(Boolean), out.out).toBe(true);
}
expect(r.close.out).toMatch(/^BUDGET_FINAL: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m);
expect(r.close.out).toMatch(/^BUDGET_TIMELINE: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m);
}
for (const start of [['--meter-scope', 'self'], ['--reading-from', 'this', '--meter-scope', 'self'], []]) {
const r = observedRun(fresh(), start);
for (const out of [r.start, r.note, r.close]) {
expect(out.code, out.err).toBe(0);
expect(out.out).not.toContain('METER: tree');
}
}
});
// Value: protects=resume and a handoff child of a tree-scoped run keep the label; fails_when=resume or verify-handoff prints a BUDGET line with no METER: tree line under it;
// why_new=the test above drives start, note and close only; seam=none; no_claim=a child's exact cost
test('resume and a handoff child of a tree-scoped run label their cost lines too', () => {
const h = fresh();
const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const resumed = h.sb(['resume', '--run', run, '--reading', '1200000']);
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
expect(ho.code, ho.out + ho.err).toBe(0);
const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]);
for (const out of [resumed, ho, child]) {
expect(out.code, out.out + out.err).toBe(0);
const labels = costLinesLabeled(out.out);
expect(labels.length, out.out).toBeGreaterThan(0);
expect(labels.every(Boolean), out.out).toBe(true);
}
});
// Value: protects=a tree run rebuilt from its record after a lost ledger keeps the label; fails_when=the record block drops meter_scope or rebuildLedger does not restore it (red-team, ship gst467-p1);
// why_new=the cases above never lose the ledger; seam=none; no_claim=checkpoint restore, which copies the whole ledger
test('a tree run rebuilt from its record still labels its cost lines', () => {
const h = fresh();
const record = h.record();
const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000', '--meter-scope', 'tree']);
const ledger = h.field(s.out, 'BUDGET_RUN', 'ledger');
fs.writeFileSync(ledger, '{"kind": "gstack-ship-budget", oops');
const rebuilt = h.sb(['resume', '--record', record, '--rebuild-from-record', '--reading', '1500000']);
expect(rebuilt.out, rebuilt.out + rebuilt.err).toContain('REBUILT:');
const close = h.sb(['close', '--record', record, '--reading', '2000000']);
for (const out of [rebuilt, close]) {
const labels = costLinesLabeled(out.out);
expect(labels.length, out.out).toBeGreaterThan(0);
expect(labels.every(Boolean), out.out).toBe(true);
}
});
// Value: protects=re-running start with tree flags on an open self run upgrades it, and a tree child's saved return file keeps the label the parent reads; fails_when=the re-start returns before applying the scope, or the return file is written before labelScope (outside voice, ship gst467-p1);
// why_new=start's resume branch and the return file were never driven with tree scope; seam=none; no_claim=a downgrade, which tree never allows
test('a re-start asking for tree upgrades an open run, and a tree child return file carries the label once', () => {
const h = fresh();
const record = h.record();
const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000']);
expect(s.out).not.toContain(TREE);
const again = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1100000', '--subagent', '--reading-from', 'this', '--meter-scope', 'tree']);
const close = h.sb(['close', '--record', record, '--reading', '2000000']);
for (const out of [again, close]) {
expect(out.code, out.out + out.err).toBe(0);
const labels = costLinesLabeled(out.out);
expect(labels.length, out.out).toBeGreaterThan(0);
expect(labels.every(Boolean), out.out).toBe(true);
}
const p = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']);
const run = h.field(p.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]);
const ret = h.sb(['return', '--run', h.field(child.out, 'BUDGET_RUN', 'id'), '--to', '11.5']);
expect(ret.code, ret.out + ret.err).toBe(0);
for (const text of [ret.out, fs.readFileSync(returnFile, 'utf8')]) {
const labels = costLinesLabeled(text);
expect(labels.length, text).toBeGreaterThan(0);
expect(labels.every(Boolean), text).toBe(true);
expect(text.split(TREE).length - 1, text).toBe(labels.length);
}
});
// Value: protects=every root-counter line a tree run prints is labeled once, drift and reconcile lines included; fails_when=labelScope misses BUDGET_DRIFT or RECONCILED, or labels an already-labeled line twice (adversarial, ship gst467-p1);
// why_new=no CLI case above prints drift or reconcile lines; seam=labelScope; no_claim=which commands print them
test('labelScope labels drift and reconcile lines too, and never twice', () => {
const tree = { meter_scope: 'tree' } as Ledger;
const lines = ['RECONCILED: handoff=x child=complete child_used=5', 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', 'GSTACK_RESULT: skill=ship'];
expect(labelScope(tree, [...lines])).toEqual(['RECONCILED: handoff=x child=complete child_used=5', TREE, 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', TREE, 'GSTACK_RESULT: skill=ship']);
expect(labelScope({} as Ledger, [...lines])).toEqual(lines);
});
// Value: protects=a subagent cannot claim its own cost while reading the root counter; fails_when=start accepts --meter-scope self from a subagent metering "this", or the refusal lacks its code and fix;
// why_new=GST-467; seam=none; no_claim=whether the brief sets the subagent flag
test('a subagent metering "this" that asks for a self scope is refused with METER_SCOPE_PARENT and a fix', () => {
const h = fresh();
for (const [args, env] of [[['--subagent'], {}], [[], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) {
const r = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--reading-from', 'this', '--meter-scope', 'self', ...args], env);
expect(r.code).toBe(2);
expect(r.err).toContain('(METER_SCOPE_PARENT)');
expect(r.err).toContain('--meter-scope tree');
}
const bad = h.sb(['start', '--record', h.record(), '--reading', '1', '--meter-scope', 'mine']);
expect(bad.code).toBe(2);
expect(bad.err).toContain('--meter-scope');
expect(RESULT_CODES.METER_SCOPE_PARENT.fix).toContain('--meter-scope tree');
expect(fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8')).toContain(`<a id="${RESULT_CODES.METER_SCOPE_PARENT.anchor}"></a>`);
});
// Value: protects=the /ship brief tells a subagent to meter tree-scoped and the docs say where exact cost comes from; fails_when=the Capy skill or docs/ship-budget.md drops --meter-scope tree or the root-replay pointer;
// why_new=GST-467; seam=none; no_claim=agent compliance
test('the Capy skill and docs/ship-budget.md tell a subagent to pass --meter-scope tree; exact per-run cost comes from root replay threads', () => {
const skill = fs.readFileSync(path.join(ROOT, 'capy/skill/gstack/SKILL.md'), 'utf8').replace(/\s+/g, ' ');
expect(skill).toContain('--subagent --reading-from this --meter-scope tree');
const doc = fs.readFileSync(path.join(ROOT, 'docs/ship-budget.md'), 'utf8').replace(/\s+/g, ' ');
expect(doc).toContain('--meter-scope tree');
expect(doc).toContain(TREE);
expect(doc).toContain('root replay thread');
});
});