Files
gstack/lib/ship-budget.ts
T
Garry Tanandcapy-ai[bot] 89f2033004 v1.91.358.0 review: targeted read-only pass on money, gate, auth and secret code (GST-257 part 1, report-only) (#3671)
* review: targeted read-only pass on money, gate, auth and secret diffs (GST-257 part 1, report-only)

lib/review-targeted.ts classifies a diff's added and deleted lines into
money, gate, auth and secret (token-bounded, docs and tests excluded,
fails closed to all four). gstack-review-plan prints TARGETED and
dispatches one read-only targeted reader at full effort when a class
hits; review/specialists/targeted.md is its brief. The receipt records
review_targeted bound to product_patch_id, a carry keeps it, and
gstack-ship-queue ready / train build print a report-only
REVIEW_TARGETED line that refuses nothing.

No-loss evidence: the dispatch-superset test in
test/review-targeted.test.ts runs gstack-review-plan on 18 fixture diffs
and requires every reviewer main dispatched at 8be7f21, unchanged, plus
only the targeted reader. Brief identity: gstack-review-dispatch render
on this tree and origin/main 2356792 gives 13 of 13 existing reviewer
briefs byte-identical (handoffs/GSTA-160/gst257-briefcmp/SUMMARY.txt).

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

* fix: pre-landing review fixes (targeted patch id covers tests, record binds the diff it read, role list pin)

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

* fix: targeted reader reads the merge base on every pass; dirty-tree record test

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

* fix: targeted render takes --base and any spelling of the merge base; Step 11 says the merge base; docs gate-path list

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

* test: type the fail-closed class list (typecheck:test ratchet)

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>
2026-10-31 09:07:30 +00:00

657 lines
41 KiB
TypeScript

/**
* 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
* <state root>/ship-runs/<run>/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 { SMALL_DIFF_LINES, type Role } from './review-depth';
export const SHIP_STEPS = ['0.9', '1', '2', '3', '4', '5', '6', '6.5', '7', '8', '9', '10', '11', '11.5', '12', '13', '14', '14.5', '15', '16', '17', '18', '19', '20', '21', '22'] as const;
/** Reviewer roles plus `docs`, the Step 14.5 documentation audit: each is one reader priced and reconciled the same way. */
export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted', 'docs'] as const satisfies readonly (Role | 'docs')[];
type MissingRole = Exclude<Role, typeof DISPATCH_ROLES[number]>;
const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true;
void ROLES_COMPLETE;
/**
* D3: per-call context above the host's per-call floor (GSTA-167) 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;
/** Handoffs between the root run and the deepest child; a run at this depth continues instead of handing off. */
export const MAX_CHAIN_DEPTH = 3;
/** Segment boundaries (before Step 9, before Step 12) and the last step each segment runs: Steps 7-8 stay with the parent, so the review child reads only what dispatch needs. */
export const SEGMENTS: Record<string, string> = { '1': '22', '9': '11.5', '12': '22' };
export const DEFAULT_TAIL = 2_000_000;
const DIFF_REVIEW_STEP: Record<string, string> = { review: '9', 'review-fix-check': '9', 'design-review-lite': '9', 'adversarial-review': '11', 'codex-review': '11' };
export type MeterKind = 'capy' | 'claude' | 'estimate';
export interface MeterContract { version: 1; meter: MeterKind; unit: string; host_field: string; scope: 'thread' | 'session' | 'run'; descendants: boolean }
export const METER_CONTRACTS: Record<MeterKind, MeterContract> = {
capy: { version: 1, meter: 'capy', unit: 'capy-tokens', host_field: 'tools.thread.get({ thread_id: "<this agent\'s own jam id>" }).cost.tokens', scope: 'thread', descendants: true },
claude: { version: 1, meter: 'claude', unit: 'claude-usage-tokens', host_field: 'journal message.usage input+cache_creation+cache_read+output, max per (message.id, requestId)', scope: 'session', descendants: true },
estimate: { version: 1, meter: 'estimate', unit: 'estimate-tokens', host_field: 'lib/ship-budget-calibration.json projections', scope: 'run', descendants: true },
};
export interface Reservation {
id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string;
state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string;
prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[];
settled_reading?: number; unmeasured?: boolean; dispatched_at?: number; readers?: number; late?: boolean;
/** Every review-log start this reservation admitted: one per role (token and started_at keep the first). */
starts?: Array<{ token: string; at: string }>;
}
/** One observation: the run's totals at a program's end, and the readers it dispatched when it measured them. */
export interface Note { step: string; label?: string; at: string; used: number; credits?: number; readers?: { n: number; tokens: number; credits?: number } }
export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number }
export interface Handoff {
n: number; sha: string; file: string; from: string; to: string; sub_cap: number; reservation: string; reading_at: number | null;
epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; depth?: number;
/** Ledgers of the child runs verify-handoff opened on this brief: close audits their starts too. */
child_ledgers?: string[];
}
export interface Ledger {
schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string;
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; samples?: Sample[]; tail_explicit?: boolean;
call_floor?: number; depth?: number; steps_done?: string[];
/** Capy per-call charges (--reading minus a fresh --prior-reading), per epoch: step priors use their median. */
calls?: Array<{ epoch: number; value: number }>;
/** A Capy handoff child verified with no reading: a subagent cannot read its own jam id, so the parent's reconcile reading measures it. */
parent_metered?: boolean;
/** observe (ship/DRIVER.md): readings are recorded by note and the cap is one hard stop; no reservation, admission or delegation. */
mode?: 'admit' | 'observe';
/** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */
credits?: { baseline: number; last: number };
notes?: Note[];
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
}
/** 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<string, Array<{ run: string; value: number; source: string }>>;
constants: Record<'per_call' | 'per_dispatch' | 'stop_overhead', number>;
step_calls: Record<string, number>; step_dispatches: Record<string, number>; segment_dispatches: Record<string, number>;
step_calls_observed?: Record<string, Array<{ run: string; value: number; source: string }>>;
units: Record<string, { fit?: string; seeded_from?: string; why?: string; call_floor?: number; call_floor_observed?: number | null; call_floor_why?: string }>;
}
export const CALIBRATION_FILE = path.join(import.meta.dir, 'ship-budget-calibration.json');
/** 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 const stepCalls = (cal: Calibration, step: string) => cal.step_calls[step] ?? cal.step_calls.default ?? 0;
/** A step's prior: its calibrated calls at `perCall` (the unit's per_call by default) plus any dispatches the step itself carries. */
export function stepProjection(cal: Calibration, unit: string, step: string, perCall = unitConstants(cal, unit).per_call): number {
return stepCalls(cal, step) * perCall + (cal.step_dispatches[step] ?? 0) * unitConstants(cal, unit).per_dispatch;
}
/** Calls the per-call median looks at, and how many it needs before it replaces the calibrated per_call. */
export const CALL_WINDOW = 5;
export const MIN_CALLS = 3;
/** The Capy run's median per-call charge over its last CALL_WINDOW measured calls of this epoch; null below MIN_CALLS or on another meter. */
export function measuredPerCall(l: Ledger): number | null {
if (l.meter.contract.meter !== 'capy') return null;
const xs = (l.calls ?? []).filter(c => c.epoch === l.meter.epoch).slice(-CALL_WINDOW).map(c => c.value).sort((a, b) => a - b);
if (xs.length < MIN_CALLS) return null;
const mid = Math.floor(xs.length / 2);
return xs.length % 2 ? xs[mid]! : Math.round((xs[mid - 1]! + xs[mid]!) / 2);
}
export function projection(cal: Calibration, unit: string, next: Next): number {
return next.kind === 'dispatch' ? next.roles.length * unitConstants(cal, unit).per_dispatch : stepProjection(cal, unit, next.step);
}
/** How many recent measurements of a kind the run's factor looks at (GST-101). */
export const SAMPLE_WINDOW = 3;
const roundUp = (n: number, to: number) => Math.ceil(n / to) * to;
/**
* The run's own factor for steps or dispatches: the largest actual/prior ratio
* among its last SAMPLE_WINDOW measurements times the calibration headroom, or
* null before the first measurement (the prior applies until then).
*/
export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind']): number | null {
const rows = (l.samples ?? []).filter(s => s.kind === kind && s.epoch === l.meter.epoch && s.actual > 0 && s.prior > 0).slice(-SAMPLE_WINDOW);
return rows.length ? Math.max(...rows.map(s => s.actual / s.prior)) * cal.headroom : null;
}
/**
* Projections calibrated by the run's measured factors. The tail reserve scales
* with the step factor unless --reserve set it; the stop overhead keeps its
* calibrated fit so a run always has room to stop. `floor` is the host's
* per-call floor: start --call-floor, else the unit's calibrated value.
* Once a Capy run has MIN_CALLS measured calls, a step is priced at its
* calibrated calls x their median (measuredPerCall) instead, unscaled: that
* median is already the run's own measurement.
* `reader` prices one dispatch reader: the calibrated per-dispatch prior times the
* dispatch factor, but never above the larger of that prior and the run's clean
* per-reader mean, and never below the role's own measured mean.
*/
export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) {
const unit = l.meter.contract.unit;
const floor = l.call_floor ?? cal.units[unit]?.call_floor ?? 0;
const step = measuredFactor(l, cal, 'step'), dispatch = measuredFactor(l, cal, 'dispatch');
const scale = (n: number, f: number | null) => (f === null ? n : roundUp(n * f, cal.round_to));
const measured = new Map<string, number[]>();
for (const r of l.reservations) {
if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue;
for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / (r.readers ?? r.roles!.length)]);
}
const mean = (xs: number[]) => (xs.length ? Math.ceil(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const clean = mean([...measured.values()].flat());
const reader = (role: string) => {
const prior = unitConstants(cal, unit).per_dispatch;
const scaled = scale(prior, dispatch);
return Math.max(clean ? Math.min(scaled, Math.max(prior, clean)) : scaled, mean(measured.get(role) ?? []));
};
const perCall = measuredPerCall(l);
const project = (next: Next) => {
if (next.kind === 'dispatch') return { prior: projection(cal, unit, next), tokens: next.roles.reduce((n, role) => n + reader(role), 0) };
if (perCall !== null) {
const prior = stepProjection(cal, unit, next.step, perCall);
return { prior, tokens: prior };
}
const prior = projection(cal, unit, next);
return { prior, tokens: scale(prior, step) };
};
const segment = (from: string, to: string) => SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1)
.reduce((sum, s) => sum + project({ kind: 'step', step: s }).tokens + (cal.segment_dispatches[s] ?? 0) * project({ kind: 'dispatch', roles: ['specialist'] }).tokens, 0);
const label = (f: number | null) => (f === null ? 'prior' : f.toFixed(3));
return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}${perCall === null ? '' : `,call:${perCall}`}`, floor };
}
/**
* Measure settled reservations against the current (fresh) reading (GSTA-167).
* A dispatch is measured only from the reading taken right before its readers
* started (--dispatched-at) to the one it was settled with after they all
* returned, both read inside the dispatch program, where the parent makes no
* model call; dispatches with the same pair split it per reader (--readers,
* else one per role). One settled without a reading (review-log --finish, a
* step change), without --dispatched-at (a later program), or after another
* command read the meter in between is UNMEASURED: its interval also holds the
* parent's own work, so it teaches nothing.
* A step is measured on a step change, from its reading to now minus the
* dispatches reserved after it, so the parent's interleaved calls stay in the
* step; a window holding an unmeasured dispatch leaves the step unmeasured. A
* charge the meter cannot see (settled --unknown, or a Claude-metered outside
* voice) is never measured. Returns one DRIFT or UNMEASURED line each.
*/
export function measureSettled(l: Ledger, stepChange = true): string[] {
const last = l.meter.last;
if (last === null) return [];
const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen && !r.unmeasured
&& typeof r.reading_at === 'number' && r.epoch === l.meter.epoch && last >= r.reading_at);
const lines: string[] = [];
const measure = (r: Reservation, actual: number) => {
r.actual = actual;
(l.samples ??= []).push({ id: r.id, kind: r.kind as Sample['kind'], epoch: l.meter.epoch, prior: r.prior ?? r.tokens, projected: r.tokens, actual });
lines.push(`DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`);
};
const unmeasured = (r: Reservation, reason: string) => {
r.unmeasured = true;
lines.push(`UNMEASURED: id=${r.id} kind=${r.kind} next=${r.next} reason=${reason}`);
};
const dispatches = pending.filter(r => r.kind === 'dispatch');
for (const r of dispatches) {
if (r.settled_reading === undefined) unmeasured(r, 'settled_without_reading');
else if (r.late) unmeasured(r, 'reading_between');
else if (r.dispatched_at === undefined) unmeasured(r, 'no_dispatch_reading');
}
const count = (r: Reservation) => r.readers ?? r.roles?.length ?? 1;
const measurable = dispatches.filter(r => !r.unmeasured);
for (const key of new Set(measurable.map(r => `${r.dispatched_at}:${r.settled_reading}`))) {
const group = measurable.filter(r => `${r.dispatched_at}:${r.settled_reading}` === key);
const span = group[0]!.settled_reading! - group[0]!.dispatched_at!;
const readers = group.reduce((n, r) => n + count(r), 0);
for (const r of group) measure(r, Math.round((span * count(r)) / readers));
}
if (!stepChange) return lines;
for (const r of pending.filter(x => x.kind === 'step')) {
const inside = l.reservations.slice(l.reservations.indexOf(r) + 1).filter(d => d.kind === 'dispatch' && d.state !== 'released' && !d.unseen && d.epoch === l.meter.epoch);
if (inside.some(d => d.actual === undefined)) { unmeasured(r, 'dispatch_unmeasured'); continue; }
measure(r, Math.max(0, last - r.reading_at! - inside.reduce((n, d) => n + d.actual!, 0)));
}
return lines;
}
/** Steps from..to inclusive plus their usual reviewer batches at the prior: a child's segment estimate. */
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);
}
/**
* Small-run rule (GSTA-167 PR 4). A delegation pays a fixed child cost (boot, install check, verify-handoff,
* section reads, reconcile: gstack-evals #34's two children cost 4.53M, 2-4.5M each) to save the parent's
* context above the host floor on each call of the segment. #34's parent calls ran 0.2-0.3M over a 0.15M
* floor, so a fresh child saves at most about 3/8 of a call, and a segment under CHILD_FIXED_COST over that
* share (12M) cannot pay for its child. A light-depth diff (runtime lines at most SMALL_DIFF_LINES) keeps
* its segment when the segment's calibrated calls at this run's own per-call charge stay under that.
*/
export const SMALL_RUN_LINES = SMALL_DIFF_LINES;
export const CHILD_FIXED_COST = 4_500_000;
export const SMALL_RUN_SEGMENT = CHILD_FIXED_COST / 0.375;
/** #34's per-call charge midpoint, used when this call's charge is unknown. */
export const SMALL_RUN_CALL = 250_000;
export function smallRunSegment(cal: Calibration, from: string, to: string, call: number | null): number {
const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1);
return steps.reduce((n, s) => n + stepCalls(cal, s), 0) * (call ?? SMALL_RUN_CALL);
}
/** The SMALL_RUN line when a segment boundary should continue instead of delegating; null keeps the delegate rule. */
export function keepsSmallRun(cal: Calibration, o: { runtimeLines: number | undefined; from: string; to: string; call: number | null }): string | null {
if (o.runtimeLines === undefined || o.runtimeLines > SMALL_RUN_LINES) return null;
const segment = smallRunSegment(cal, o.from, o.to, o.call);
if (segment > SMALL_RUN_SEGMENT) return null;
return `SMALL_RUN: runtime_lines=${o.runtimeLines}<=${SMALL_RUN_LINES} segment=${o.from}-${o.to} projected=${segment}<=${SMALL_RUN_SEGMENT}; continuing here: a child's fixed ${CHILD_FIXED_COST} would cost more than it saves`;
}
export const runDirFor = (env: NodeJS.ProcessEnv, run: string) => path.join(resolveStateRoot(env), 'ship-runs', run);
export function mintRunId(now = new Date()): string {
return `ship-${now.toISOString().replace(/[-:]/g, '').slice(0, 15)}-${crypto.randomBytes(3).toString('hex')}`;
}
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<T>(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;
}
/** A cost.credits value: a non-negative decimal, kept to two places. */
export function parseCredits(raw: string | undefined, flag: string): number {
if (!/^\d+(?:\.\d+)?$/.test((raw ?? '').trim())) throw usageError(`${flag} '${raw ?? ''}' is not a credits reading; fix: pass cost.credits from the same thread.get as --reading, e.g. 845009.2`);
return Math.round(Number(raw) * 100) / 100;
}
/** Store a credits reading; the first is the baseline, and one below the last is refused like a token reading. */
export function applyCredits(l: Ledger, value: number | undefined): void {
if (value === undefined) return;
if (l.credits && value < l.credits.last) throw budgetError('BUDGET_READING_REJECTED', `--credits ${value} is below the last credits reading ${l.credits.last}`);
l.credits = { baseline: l.credits?.baseline ?? value, last: value };
}
export const creditsUsed = (l: Ledger): number | null => (l.credits ? Math.round((l.credits.last - l.credits.baseline) * 100) / 100 : null);
export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string {
const t = tally(l);
const p = projector(l);
const credits = creditsUsed(l);
return `BUDGET: used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`;
}
/** The per-call context above the host floor that the delegate thresholds compare; null when the charge is unknown. */
export const aboveFloor = (call: number | null, floor: number) => (call === null ? null : Math.max(0, call - floor));
export function reserveIn(l: Ledger, r: Omit<Reservation, 'id' | 'at' | 'state'>): Reservation {
const row: Reservation = { ...r, id: `${l.run}-r${l.next_id++}`, at: new Date().toISOString(), state: 'open', reading_at: l.meter.last, epoch: l.meter.epoch };
l.reservations.push(row);
return row;
}
/**
* Settle at completion: the meter already holds the spend; estimate and unseen
* charges are booked here. `dispatched` is the dispatch program's reading from
* right before the readers started, with the reader count; `late` marks one
* another command's reading came after.
*/
export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration; reading?: number; dispatched?: { at: number; readers?: number; late: boolean } } = {}): void {
if (r.state === 'settled' || r.state === 'released') return;
r.state = 'settled';
r.settled_at = new Date().toISOString();
if (opts.reading !== undefined) r.settled_reading = opts.reading;
if (opts.reading !== undefined && opts.dispatched) {
if (opts.dispatched.late) r.late = true;
else r.dispatched_at = opts.dispatched.at;
if (opts.dispatched.readers !== undefined) r.readers = opts.dispatched.readers;
}
if (opts.unknown) { l.unknown += r.tokens; r.unseen = true; return; }
if (l.meter.contract.meter === 'estimate') { l.estimate_used += r.tokens; return; }
const unseen = l.meter.contract.meter === 'claude' ? (r.roles ?? []).filter(x => x === 'outside-voice').length : 0;
if (unseen && r.roles?.length) { l.unknown += Math.round((r.tokens * unseen) / r.roles.length); r.unseen = true; }
}
/** 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';
const credits = creditsUsed(l);
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`;
}
/** The record's fenced gstack-budget block: written by the bin, never by the agent. */
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<string, string> | 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.
* A handoff child writes the shared record's block while it runs; once it has
* written its return file the record is its parent's again, so --record
* follows the parent link (the parent reconciles by --record after a child).
*/
export function ledgerFileFor(o: { run?: string; record?: string }, env: NodeJS.ProcessEnv): string {
if (o.record) {
const block = parseRecordBlock(o.record);
if (!block?.ledger) throw budgetError('BUDGET_LEDGER_INVALID', `${o.record}: no gstack-budget block (run gstack-ship-budget start --record ${o.record} first)`);
let file = block.ledger;
while (fs.existsSync(file)) {
const parent = readLedger(file).parent;
if (!parent || !fs.existsSync(parent.return_file)) break;
file = parent.ledger;
}
return file;
}
if (o.run) {
if (!/^[A-Za-z0-9._-]+$/.test(o.run)) throw usageError(`--run '${o.run}' is not a run id`);
return path.join(runDirFor(env, o.run), 'budget.json');
}
throw usageError('pass --run <id> or --record <path>; 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 || l.mode === 'observe') { 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.mode !== 'observe' && !l.closed_at ? l : undefined;
}
/**
* gstack-review-log --start under a cap: the dispatch needs a dispatch
* reservation of the active run with a role left to start (one start per role,
* so Step 11's native and outside voices share one reservation), consumed
* atomically with the start capture. No marker (no cap), a marker whose ledger is gone, or a stopped run
* started without --reservation (a later uncapped review on the branch) runs
* capture() exactly as before.
*/
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 <s> --next dispatch:<roles> and pass --reservation <id>`;
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);
const starts = r.starts ?? (r.token ? [{ token: r.token, at: r.started_at ?? '' }] : []);
const slots = r.roles?.length ?? 1;
if (r.state !== 'open' && !(r.state === 'consumed' && starts.length < slots)) throw new BudgetError('BUDGET_CHECK_SKIPPED', resultLine('gstack-review-log', 'BUDGET_CHECK_SKIPPED', `reservation ${reservation} is already ${r.state}${r.token ? ` by start ${r.token}` : ''} (one start per role: ${starts.length} of ${slots} used); ${fix}`), 1);
const out = capture();
const start = { token: out.split('\n')[0]!.trim(), at: new Date().toISOString() };
r.state = 'consumed';
r.starts = [...starts, start];
r.token ??= start.token;
r.started_at ??= start.at;
return out;
});
}
/**
* gstack-review-log --finish TOKEN: the pass is over, so settle the reservation
* that start consumed, every other dispatch reservation started in the same
* step (a pass logs one row even when its reviewers were admitted one by one),
* and every later batch of the pass reserved after it that took no start (Step
* 9's Red Team or an ADMIT remainder).
*/
export function settleReviewFinish(marker: string, token: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const active = activeLedger(marker);
const started = (r: Reservation) => (r.state === 'consumed' || r.state === 'settled') && (r.token === token || !!r.starts?.some(s => s.token === token));
if (!active || !active.reservations.some(started)) return undefined;
return mutateLedger(active.file, env, l => {
const r = l.reservations.find(started);
if (!r) return undefined;
const at = l.reservations.indexOf(r);
const pass = l.reservations.filter((x, i) => x.kind === 'dispatch' && x.step === r.step && (x.state === 'consumed' || (x.state === 'open' && i > at)));
for (const x of pass) settleIn(l, x);
return pass.map(x => x.id).join(',');
});
}
/** Every review-log start a reservation of this run or of a handoff child below it admitted. */
function treeStarts(l: Ledger, seen = new Set<string>()): Array<{ token: string; at: string }> {
if (seen.has(l.file)) return [];
seen.add(l.file);
const own = l.reservations.flatMap(r => r.starts ?? (r.started_at && r.token ? [{ token: r.token, at: r.started_at }] : []));
const children = l.handoffs.flatMap(h => h.child_ledgers ?? []).flatMap(file => { try { return treeStarts(readLedger(file), seen); } catch { return []; } });
return [...own, ...children];
}
/** close's audit: review-log rows of this run with no reservation, its own or a handoff child's, consumed before they started. */
export function unreservedDispatches(l: Ledger): Array<{ skill: string; step: string; started: string }> {
if (!l.review_log) return [];
let lines: string[];
try { lines = fs.readFileSync(l.review_log, 'utf8').split('\n').filter(Boolean); } catch { return []; }
const starts = treeStarts(l);
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;
if (!starts.some(s => Math.abs(Date.parse(s.at) - Date.parse(started)) < 5_000)) 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 };
}