/** * ship-budget-cli — the commands of bin/gstack-ship-budget (docs/ship-budget.md): * start, check, settle, release, close and resume here; handoff, verify-handoff, * return and reconcile in lib/ship-handoff.ts. Every command returns its lines * and exit code: 0 continue or delegate, 3 stop (the next step does not start), * 2 usage or validation (stderr, with the code, the value and the repair). * GSTACK_RESULT is printed only on a stop, an incomplete close and a child's * return; the run's real terminal line stays /ship Step 22's. */ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { resultLine } from './result-codes'; import { BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, newEpoch, parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, readLedger, reserveIn, restoreRun, reviewPaths, runDirFor, segmentEstimate, settleIn, stepProjection, projection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, type Ledger, type MeterKind, type Next, } from './ship-budget'; import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage'; export type Opts = Record; export interface Outcome { stdout: string[]; exit: number } export interface Ctx { env: NodeJS.ProcessEnv; cwd: string } export function parseArgs(argv: string[], values: string[], flags: string[] = []): Opts { const out: Opts = {}; for (let i = 0; i < argv.length; i++) { const name = argv[i]!.replace(/^--/, ''); if (!argv[i]!.startsWith('--')) throw usageError(`unexpected argument '${argv[i]}'`); if (flags.includes(name)) { out[name] = true; continue; } if (!values.includes(name)) throw usageError(`unknown flag --${name}; valid: ${[...values, ...flags].map(v => '--' + v).join(' ')}`); const value = argv[++i]; if (value === undefined) throw usageError(`--${name} needs a value`); out[name] = value; } return out; } export const str = (o: Opts, k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined); export const tokensOpt = (o: Opts, k: string) => (str(o, k) === undefined ? undefined : parseTokens(str(o, k), `--${k}`)); const meterFile = (l: Ledger) => path.join(l.dir, 'claude-meter.json'); /** Take this command's reading under the run's meter contract; returns the per-call charge when known. */ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | null { const reading = tokensOpt(o, 'reading'); const prior = tokensOpt(o, 'prior-reading'); const meter = l.meter.contract.meter; if (meter !== 'capy') { if (reading !== undefined) applyReading(l, reading, 'flag'); if (meter === 'estimate') return null; const state = loadMeterState(meterFile(l)) ?? newMeterState(l.meter.config_dir ?? claudeConfigDir(), l.meter.sessions ?? []); const usage = readClaudeUsage(state); saveMeterState(meterFile(l), state); applyReading(l, usage.total, 'host'); return usage.lastCall; } if (reading === undefined) { if (requireReading && l.cap !== null) throw budgetError('BUDGET_READING_MISSING', `run ${l.run} meter=capy unit=capy-tokens epoch=${l.meter.epoch}`); return null; } if (prior !== undefined && prior > reading) throw budgetError('BUDGET_READING_REJECTED', `--prior-reading ${prior} is above --reading ${reading}`); applyReading(l, reading, 'flag'); return prior === undefined ? null : reading - prior; } /** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */ export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] { l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; const t = tally(l); const cal = loadCalibration(l.calibration); const from = f.next.startsWith('dispatch:') ? f.step : f.next; const tailCost = segmentEstimate(cal, l.meter.contract.unit, from, '22') + l.tail + l.stop_overhead; const lines = [budgetLine(l, { ...f, verdict: 'stop' })]; if (f.reason === 'budget_exhausted') { lines.push(`STOP: reason=budget_exhausted spent=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap} next=${f.next} projected=${f.projected}`, resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${l.tail + l.stop_overhead}`), `RECOVER: /ship --resume ${l.record ?? ''} --token-cap ${t.used + t.reserved + t.unknown + tailCost}`); } else { lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} threshold=${DELEGATE_SEGMENT_CALL} next=${f.next} (${l.role === 'child' ? 'a child never delegates' : '--delegate never'})`, `RECOVER: /ship --resume ${l.record ?? ''} in a fresh session`); } lines.push(`GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); if (l.role === 'child' && l.parent) writeReturnFile(l.parent.return_file, [`RETURN: handoff=${l.parent.handoff_sha} status=incomplete steps=${l.parent.from}-${l.parent.to} next=${f.next}`, ...lines]); return lines; } export function writeReturnFile(file: string, lines: string[]): void { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 }); } /** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */ export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { const cal = loadCalibration(l.calibration); const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`; const projected = projection(cal, l.meter.contract.unit, next); if (!admits(tally(l), projected, l.tail + l.stop_overhead, l.cap)) { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 }; } const boundary = next.kind === 'step' && (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); const wants = boundary && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL); if (wants && (l.role === 'child' || l.delegate === 'never')) { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold' }), exit: 3 }; } if (wants && next.kind === 'step') { return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }), `DELEGATE: Steps ${next.step}-${SEGMENTS[next.step]} go to a fresh child: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${SEGMENTS[next.step]} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; } const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; if (l.cap !== null) { const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); } return { stdout: lines, exit: 0 }; } /** Bind the Claude Code session: --session , or a nonce echoed once; zero or several matches refuse. */ function bindClaude(o: Opts, env: NodeJS.ProcessEnv): string[] { const dir = claudeConfigDir(env); const session = str(o, 'session'); if (session) { if (sessionJournals(dir, session).length !== 1) throw budgetError('BUDGET_METER_UNAVAILABLE', `--session ${session}: ${sessionJournals(dir, session).length} journals under ${dir}/projects (need exactly one)`); return [session]; } const nonce = str(o, 'nonce'); if (!nonce) throw budgetError('BUDGET_METER_UNAVAILABLE', `meter=claude needs --session , or echo GSTACK_BUDGET_NONCE=${crypto.randomBytes(8).toString('hex')} once and rerun with --nonce `); const found = nonceSessions(dir, nonce); if (found.length !== 1) throw budgetError('BUDGET_METER_UNAVAILABLE', `--nonce ${nonce}: ${found.length} journals under ${dir}/projects contain it (need exactly one)`); return found; } function pickMeter(o: Opts): MeterKind | undefined { const m = str(o, 'meter'); if (m !== undefined && !['capy', 'claude', 'estimate'].includes(m)) throw usageError(`--meter '${m}' is not capy, claude or estimate`); if (m) return m as MeterKind; if (str(o, 'reading') !== undefined) return 'capy'; if (str(o, 'session') || str(o, 'nonce')) return 'claude'; return undefined; } export function cmdStart(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger']); const record = str(o, 'record'); if (!record) throw usageError('start needs --record '); const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP; const cap = rawCap === undefined || rawCap === 'none' ? null : parseTokens(rawCap, '--token-cap'); const delegate = str(o, 'delegate') ?? 'auto'; if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`); const existing = parseRecordBlock(record); if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) { const l = readLedger(existing.ledger); return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 }; } const meter = pickMeter(o); if (!meter) { if (cap !== null) throw budgetError('BUDGET_METER_UNAVAILABLE', 'tried --reading (absent), a Claude Code session (--session/--nonce absent) and --meter estimate (not passed)'); return { stdout: ['BUDGET: unmetered admission_cap=none (no readable meter: --reading on Capy, --session on Claude Code)'], exit: 0 }; } const run = mintRunId(); const dir = runDirFor(ctx.env, run); const cal = loadCalibration(str(o, 'calibration')); const contract = METER_CONTRACTS[meter]; const paths = reviewPaths(ctx.cwd, ctx.env); const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined; const l: Ledger = { schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: str(o, 'ledger') ?? path.join(dir, 'budget.json'), record: path.resolve(record), created_at: new Date().toISOString(), cap, tail: tokensOpt(o, 'reserve') ?? DEFAULT_TAIL, stop_overhead: unitConstants(cal, contract.unit).stop_overhead, delegate, role: 'parent', meter: { contract, epoch: 1, baseline: 0, last: null, ...(sessions ? { sessions, config_dir: claudeConfigDir(ctx.env) } : {}) }, epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [], marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}), }; if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy'); if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!; const call = readMeter(l, o, true); if (meter === 'claude') l.meter.baseline = l.meter.last ?? 0; const out = decide(l, '0.9', { kind: 'step', step: '1' }, call, true); writeLedger(l, ctx.env); pointMarker(l.marker, l); return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }; } export function cmdCheck(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading']); const step = parseStep(str(o, 'step')); const next = parseNext(str(o, 'next')); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { if (l.closed_at) throw usageError(`run ${l.run} is closed`); if (l.stopped) return { stdout: [budgetLine(l, { call: null, next: l.stopped.next, projected: 0, verdict: 'stop' }), `STOP: reason=${l.stopped.reason} at Step ${l.stopped.step}; resume first: gstack-ship-budget resume --record ${l.record}`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`], exit: 3 }; for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); const call = readMeter(l, o, true); return decide(l, step, next, call); }); } export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome { const o = parseArgs(argv, ['run', 'record', 'reservation'], release ? [] : ['unknown']); const id = str(o, 'reservation'); if (!id) throw usageError(`${release ? 'release' : 'settle'} needs --reservation `); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { const r = l.reservations.find(x => x.id === id); if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`); if (release) { if (r.state === 'open') r.state = 'released'; } else settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) }); return { stdout: [`${release ? 'RELEASED' : 'SETTLED'}: id=${id} state=${r.state}`], exit: 0 }; }); } export function cmdClose(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['run', 'record', 'reading', 'approved-by', 'words']); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); readMeter(l, o, false); const t = tally(l); const open = l.reservations.filter(r => r.kind === 'dispatch' && r.state === 'consumed').length; const lines = [`BUDGET_FINAL: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} open_dispatches=${open}`]; const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []); const skipped = l.cap === null ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started)); if (skipped.length && str(o, 'approved-by') && str(o, 'words')) { (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started)]; lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); } else if (skipped.length) { for (const s of skipped) lines.push(resultLine('gstack-ship-budget', 'BUDGET_CHECK_SKIPPED', `role=${s.skill} step=${s.step} started=${s.started} spend=unknown`)); l.stopped = { at: new Date().toISOString(), reason: 'budget_check_skipped', step: '22', next: '22' }; lines.push(`STOP: reason=budget_check_skipped dispatches=${skipped.length}; owner decision: accept the unreserved spend (close --record ${l.record} --approved-by --words ) or rerun them under reservations`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); return { stdout: lines, exit: 3 }; } l.closed_at = new Date().toISOString(); if (l.marker) { try { fs.unlinkSync(l.marker); } catch {} } return { stdout: lines, exit: 0 }; }); } /** resume: re-attach after a stop or a machine change; rebuild or restore a lost ledger; journal a cap raise. */ export function cmdResume(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['record', 'run', 'reading', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words'], ['rebuild-from-record']); const cap = str(o, 'token-cap'); if (cap !== undefined && (!str(o, 'approved-by') || !str(o, 'words'))) throw usageError('resume --token-cap needs --approved-by --words ; raising the cap is the owner\'s call'); const lines: string[] = []; let file: string; const record = str(o, 'record'); const block = record ? parseRecordBlock(record) : undefined; const run = str(o, 'run') ?? block?.run; file = block?.ledger ?? ledgerFileFor({ run }, ctx.env); if (!fs.existsSync(file) && run) { const restored = restoreRun(run, ctx.env); if (restored) { file = restored.ledger; lines.push(`RESTORED: ledger=${restored.ledger} record=${restored.record ?? 'none'} from=${restored.from}`); } } if (o['rebuild-from-record'] === true) { try { readLedger(file); } catch { rebuildLedger(file, record, ctx, o); lines.push(`REBUILT: ledger=${file} from=${record}`); } } const out = mutateLedger(file, ctx.env, l => { const meter = pickMeter(o) ?? l.meter.contract.meter; if (meter !== l.meter.contract.meter) { const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined; newEpoch(l, METER_CONTRACTS[meter], meter === 'capy' ? tokensOpt(o, 'reading') ?? 0 : 0); if (sessions) { l.meter.sessions = sessions; l.meter.config_dir = claudeConfigDir(ctx.env); try { fs.unlinkSync(path.join(l.dir, 'claude-meter.json')); } catch {} } const call = readMeter(l, { ...o, ...(meter === 'capy' ? {} : { reading: undefined as any }) }, false); if (meter === 'claude') { l.meter.baseline = l.meter.last ?? 0; } void call; lines.push(`EPOCH: ${l.meter.epoch} meter=${meter} unit=${l.meter.contract.unit} carried_used=${l.carried_used}`); } else readMeter(l, o, false); if (cap !== undefined) { const to = cap === 'none' ? null : parseTokens(cap, '--token-cap'); l.cap_raises.push({ from: l.cap, to, by: str(o, 'approved-by')!, words: str(o, 'words')!, at: new Date().toISOString() }); l.cap = to; } const was = l.stopped; delete l.stopped; pointMarker(l.marker, l); return [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, `RESUMED: from=${was ? `Step ${was.step} (${was.reason})` : 'running'}`, budgetLine(l, { call: null, next: was?.next ?? '-', projected: 0, verdict: 'continue' })]; }); return { stdout: [...lines, ...out], exit: 0 }; } /** Rebuild a ledger from the record's gstack-budget block; open reservations are charged as unknown. */ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Opts): void { const b = record ? parseRecordBlock(record) : undefined; if (!b?.run || !b.meter || !(b.meter in METER_CONTRACTS)) throw budgetError('BUDGET_LEDGER_INVALID', `${record ?? '--record'}: no usable gstack-budget block to rebuild from`); const meter = b.meter as MeterKind; const reading = tokensOpt(o, 'reading'); if (meter === 'capy' && reading === undefined) throw budgetError('BUDGET_READING_MISSING', 'resume --rebuild-from-record on meter=capy'); const num = (k: string) => Number(b[k] ?? 0) || 0; const l: Ledger = { schema_version: 1, kind: 'gstack-ship-budget', run: b.run, dir: path.dirname(file), file, record: path.resolve(record!), created_at: new Date().toISOString(), cap: b.cap === 'none' ? null : num('cap'), tail: num('tail') || DEFAULT_TAIL, stop_overhead: unitConstants(loadCalibration(), METER_CONTRACTS[meter].unit).stop_overhead, delegate: 'auto', role: b.role === 'child' ? 'child' : 'parent', meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null }, epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [], ...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(), }; if (meter === 'estimate') l.estimate_used = 0; fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 }); writeLedger(l, ctx.env); } export { SHIP_STEPS, stepProjection, BudgetError };