/** * 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, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, aboveFloor, admits, applyReading, budgetError, budgetLine, 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, } 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'); /** Whether this command took a fresh reading (Claude Code reads its journals on every command). */ const freshReading = (l: Ledger, o: Opts) => l.meter.contract.meter === 'claude' || str(o, 'reading') !== undefined; /** 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'; threshold?: number }): string[] { l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; const t = tally(l); const p = projector(l); const from = f.next.startsWith('dispatch:') ? f.step : f.next; const tailCost = p.segment(from, '22') + p.reserve; 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 ${p.reserve}`), `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'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} threshold=${f.threshold ?? DELEGATE_SEGMENT_CALL} next=${f.next} (--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 (a dispatch batch goes to admitBatch). The rule compares the call's context above the host floor (an * unknown charge delegates at a segment boundary, never at start). A child * never stops on it: after a completed step it hands its remaining steps to a * grandchild, and before one, at the maximum depth or past its segment it * continues with a CONTEXT_HIGH note. Admission still stops anyone. */ export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call); const p = projector(l); const nextId = next.step; const { prior, tokens: projected } = p.project(next); if (!admits(tally(l), projected, p.reserve, l.cap)) { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 }; } const threshold = startThreshold ? DELEGATE_START_CALL : DELEGATE_SEGMENT_CALL; const above = aboveFloor(call, p.floor); const boundary = (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); const wants = boundary && (above === null ? !startThreshold : above >= threshold); const depth = l.depth ?? (l.parent ? 1 : 0); const hold = !wants || !l.parent ? undefined : !l.steps_done?.length ? 'no step completed since verify-handoff' : depth >= MAX_CHAIN_DEPTH ? `chain depth ${depth} is the maximum` : l.delegate === 'never' ? '--delegate never' : SHIP_STEPS.indexOf(nextId as any) > SHIP_STEPS.indexOf(l.parent.to as any) ? `Step ${nextId} is past this child's Steps ${l.parent.from}-${l.parent.to}` : undefined; if (wants && !l.parent && l.delegate === 'never') { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold', threshold }), exit: 3 }; } if (wants && !hold) { const to = l.parent ? l.parent.to : SEGMENTS[next.step]; return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }), `DELEGATE: Steps ${next.step}-${to} go to a fresh ${l.parent ? `grandchild (chain depth ${depth + 1})` : 'child'}: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${to} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; } const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; if (hold) lines.push(`CONTEXT_HIGH: call=${call ?? 'unknown'} call_floor=${p.floor} above_floor=${above ?? 'unknown'} threshold=${threshold} next=${nextId} depth=${depth}; continuing: ${hold}`); if (l.cap !== null) { const r = reserveIn(l, { kind: 'step', step, next: nextId, tokens: projected, prior }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); } return { stdout: lines, exit: 0 }; } /** * A dispatch batch: each reader is priced at max(its projection, the run's * measured mean for its role) and the batch is reserved as one sum. When it * does not fit, the largest prefix that does is admitted and ADMIT names the * rest; the reservation keeps them as `remaining` until a later check of the * same step requests them, and close flags any still left. Only a batch whose * first reader does not fit stops. */ function admitBatch(l: Ledger, step: string, roles: string[], call: number | null): Outcome { const p = projector(l); const prices = roles.map(p.reader); const cost = (k: number) => prices.slice(0, k).reduce((a, b) => a + b, 0); const t = tally(l); let k = roles.length; while (k > 0 && !admits(t, cost(k), p.reserve, l.cap)) k--; if (k === 0) return { stdout: stopLines(l, { call, next: `dispatch:${roles.join(',')}`, step, projected: cost(roles.length), reason: 'budget_exhausted' }), exit: 3 }; const admitted = roles.slice(0, k), remaining = roles.slice(k), nextId = `dispatch:${admitted.join(',')}`; const lines = [budgetLine(l, { call, next: nextId, projected: cost(k), verdict: 'continue' })]; if (remaining.length) lines.push(`ADMIT: ${k} of ${roles.length} (remaining=${remaining.join(',')}); dispatch these ${k} now and re-check --next dispatch:${remaining.join(',')} after they settle`); if (l.cap === null) return { stdout: lines, exit: 0 }; for (const role of roles) { const older = l.reservations.find(r => r.step === step && r.remaining?.includes(role)); older?.remaining!.splice(older.remaining!.indexOf(role), 1); } const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted }).prior, roles: admitted, ...(remaining.length ? { remaining } : {}) }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`); 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', 'call-floor']); 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 callFloor = tokensOpt(o, 'call-floor'); 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, ...(tokensOpt(o, 'reserve') !== undefined ? { tail_explicit: true } : {}), 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')!) } : {}), ...(callFloor !== undefined ? { call_floor: callFloor } : {}), }; 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 }; } /** * check: read the meter, settle what is done (open step reservations, the * dispatches named by --returned, and on a step change every dispatch a * reviewer consumed), measure them, then admit the next step or dispatch. */ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading', 'returned']); const step = parseStep(str(o, 'step')); const next = parseNext(str(o, 'next')); const returned = (str(o, 'returned') ?? '').split(',').filter(Boolean); 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 }; const back = returned.map(id => l.reservations.find(r => r.id === id && r.kind === 'dispatch')); const bad = returned.filter((_, i) => !back[i]); if (bad.length) throw usageError(`--returned ${bad.join(',')} is not a dispatch reservation of run ${l.run}; fix: pass the RESERVATION ids check printed for the reviewers that came back`); const call = readMeter(l, o, true); for (const r of l.reservations) { const done = r.kind === 'step' ? r.state === 'open' : r.kind === 'dispatch' && (back.includes(r) || (next.kind === 'step' && r.state === 'consumed')); if (done) settleIn(l, r); } const drift = freshReading(l, o) ? measureSettled(l) : []; if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])]; const out = decide(l, step, next, call); return { stdout: [...drift, ...out.stdout], exit: out.exit }; }); } export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome { const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading'])], 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'; return { stdout: [`RELEASED: id=${id} state=${r.state}`], exit: 0 }; } readMeter(l, o, false); settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) }); return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(freshReading(l, o) ? measureSettled(l) : [])], 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 => { readMeter(l, o, false); for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); if (freshReading(l, o)) measureSettled(l); 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 sum = (kind: string, k: 'projected' | 'actual') => (l.samples ?? []).filter(s => s.kind === kind).reduce((n, s) => n + s[k], 0); const count = (kind: string) => (l.samples ?? []).filter(s => s.kind === kind).length; lines.push(`BUDGET_DRIFT: steps=${count('step')} step_projected=${sum('step', 'projected')} step_actual=${sum('step', 'actual')} dispatches=${count('dispatch')} dispatch_projected=${sum('dispatch', 'projected')} dispatch_actual=${sum('dispatch', 'actual')}`); 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)); const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id)); if ((skipped.length || dropped.length) && str(o, 'approved-by') && str(o, 'words')) { (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started), ...dropped.map(r => r.id)]; lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches${dropped.length ? ` and ${dropped.length} unfinished batches` : ''} by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); } else if (skipped.length || dropped.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`)); for (const r of dropped) lines.push(`DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`); const reason = skipped.length ? 'budget_check_skipped' : 'batch_incomplete'; l.stopped = { at: new Date().toISOString(), reason, step: '22', next: '22' }; lines.push(`STOP: reason=${reason} dispatches=${skipped.length}${dropped.length ? ` unfinished_batches=${dropped.length}` : ''}; owner decision: accept ${skipped.length ? 'the unreserved spend' : 'the readers that never ran'} (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 };