mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* ship: token cap with real meters and fresh-context delegation (GSTA-152 PR A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (stopped or lost capped run no longer blocks uncapped review starts; short child return reconciles incomplete) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry estimate spend across a meter epoch change; calibration docs match their evidence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: load the committed calibration file by its repo path so pregate maps it to a lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: fix fence placeholders and register claude-usage fixture (GSTA-152 PR A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run ship-budget and review-log bins through their interpreters so Windows can launch them Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
300 lines
20 KiB
TypeScript
300 lines
20 KiB
TypeScript
/**
|
|
* 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<string, string | true>;
|
|
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 ?? '<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 ?? '<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/<base> (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 <id>, 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 <id>, or echo GSTACK_BUDGET_NONCE=${crypto.randomBytes(8).toString('hex')} once and rerun with --nonce <that value>`);
|
|
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 <invocation record path>');
|
|
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 <n> on Capy, --session <id> 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 <id>`);
|
|
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 <who> --words <their 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 <who> --words <the owner\'s 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 };
|