v1.91.320.0 stamp (train 48 slot 1): merge main ad8e12d

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 15:58:47 +00:00
commit ada48de3f7
183 files changed
+9388 -740

No files matched your search

+18 -5
View File
@@ -52,6 +52,11 @@ export type Env = NodeJS.ProcessEnv;
const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
/** A branch this checkout knows: a local head or an origin remote-tracking ref. */
export function branchExists(cwd: string, branch: string): boolean {
return [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`].some(ref => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).status === 0);
}
/** One contained path segment for a branch: readable prefix plus a hash of the exact ref. */
export function branchKey(branch: string): string {
const readable = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, 60) || 'branch';
@@ -226,13 +231,15 @@ export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
}
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string }
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string;
carried_gate_from?: string; carried_gate_rev?: number; carried_answered_by?: string; carried_under?: string; carry_check?: string }
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
/**
* Rows for every answered gate in an autoplan run: one row per answer event (gate_rev, time, answerer), covering
* the items it settled. Carried-forward answers were recorded when first given and are skipped. Ids are stable,
* the items it settled. Item answers carried from an earlier revision were recorded when first given and are skipped;
* a whole gate carried forward (lib/autoplan-carry.ts) gets its own row naming the revision its words came from. Ids are stable,
* so a rerun (or a retry after an interrupted import) adds nothing twice.
*/
export function importAutoplan(runDir: string, branch: string, by?: string): ApprovalRow[] | RecordError {
@@ -265,10 +272,16 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended);
const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending');
const answerer = first.answered_by ?? 'unknown';
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`;
const carried = p1?.carried_gate_from ? p1 : undefined;
const relayed = carried?.carried_answered_by && carried.carried_answered_by !== 'human' ? `, relayed by ${carried.carried_answered_by}` : '';
const who = carried ? `${by ?? 'the owner'} (words from gate_rev ${carried.carried_gate_rev}${relayed}), carried forward by gstack-autoplan`
: answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
const unseen = items.filter(r => r.carried_under).map(r => stripRef(r.title ?? ''));
const checked = [...new Set(items.map(r => r.carry_check).filter((c): c is string => !!c))];
const scope = [`the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`,
unseen.length ? `taken under "do it", not seen by the owner: ${unseen.join('; ')}` : '', checked.length ? `post-cap delta checked by ${checked.join(', ')}` : ''].filter(Boolean).join('; ');
const built = buildRow({
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${carried ? `gate_rev ${rev} carried forward from gate_rev ${carried.carried_gate_rev}, ` : ''}${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
words: first.owner_reply ?? first.reply ?? '(no words recorded)',
by: who.slice(0, CAPS.by),
scope: scope.slice(0, CAPS.scope),
+252
View File
@@ -0,0 +1,252 @@
/**
* autoplan-carry — carry the owner's last answer to a later gate revision (GSTA-155,
* docs/unattended.md#carry-forward). When a gate_rev N+1 asks nothing the owner has not
* already decided, the runner answers it with the owner's recorded gate_rev N words instead
* of a second round trip, and records that it did (decisions.jsonl `carried_gate_from`,
* journal `gate_carried`, `<out>/decisions.tsv`). It never invents an answer: every
* carried answer is the owner's own earlier words, and every refusal says which condition failed.
*
* A carry needs all of: carry not turned off (`--no-carry-forward`); gate_rev N approved (p1 a or b)
* with the owner's words recorded, and not itself a carry; every change accepted at N
* incorporated or settled; the plan file unchanged since the gate or the revise that took those
* changes, and the reviewed plan equal to it; no open urgent finding; and every new item already
* carried. Two kinds of new item do not block: items a "do it" covers (the owner's words are
* "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
* capped-delta item recommending "accept", which first gets one reviewer on just the delta
* (`CARRY_CHECK:`, answered with `gstack-autoplan check`); a High or Critical finding there
* means no carry, and the gate goes to the owner as before.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { attemptEstimate } from './autoplan-budget';
import { lineDiff } from './autoplan-confirm';
import { answerGate } from './autoplan-gate';
import { driftLine } from './autoplan-plan-text';
import { BOUNDARY, RESULT_FORMAT, REVIEWER_TRAILER } from './autoplan-prompts';
import { parseReviewResult } from './autoplan-reconcile';
import { FILES, RunError, journal, now, readDecisions, readJournal, readState, writeDecisions, writeState, type RunState } from './autoplan-state';
import { appendTrailRow } from './decision-trail';
import { decisionId, parseReply, type GateItem, type GateList } from './gate-list';
import { readJsonl } from './headless-artifacts';
import { modelFamily } from './outside-voice-runner';
import { release, reserve, settle } from './spend-ledger';
export const DEFAULT_CARRY_CHECK_CAP_USD = 5;
const HIGH = ['Critical', 'High'];
const DO_IT = /^\s*(?:(?![^:]*\bcaptain\b)[a-z]+(?: [a-z]+){0,2}:\s*)?do it[\s.!]*$/i;
/** The owner's verbatim words are a "do it" (optionally after their name), not a Captain's words or an item token (`p3b: do it`) naming one. */
export const isDoIt = (words: string | undefined): boolean => !!words && DO_IT.test(words);
export interface CarryCheck {
attempt: string; prompt: string; result: string; delta_sha256: string; items: string[]; estimate_usd: number;
status: 'dispatched' | 'passed' | 'failed' | 'unavailable'; model?: string; max_severity?: string; findings?: number; at: string;
}
export interface CarryRecord {
gate_rev: number; from_gate_rev?: number; verdict: 'carried' | 'refused' | 'check_pending';
reason: string; words?: string; under_do_it?: string[]; check?: CarryCheck; lines: string[]; at: string;
}
const sha256 = (text: string | Buffer) => createHash('sha256').update(text).digest('hex');
const planFile = (state: RunState) => state.plan_input ?? state.plan_source;
const fileSha = (file: string) => (fs.existsSync(file) ? sha256(fs.readFileSync(file)) : undefined);
export const atGateCopy = (out: string, rev: number) => path.join(out, `plan-at-gate.r${rev}.md`);
/** What finishGate journals beside gate_written: the plan file's bytes and a copy of the reviewed plan the owner is asked about. */
export function gateEvidence(state: RunState, rev: number): { source_file_sha256?: string } {
if (fs.existsSync(state.active_plan)) fs.copyFileSync(state.active_plan, atGateCopy(state.out, rev));
return { source_file_sha256: fileSha(planFile(state)) };
}
/** The lines the latest carry decision printed, for the command that wrote this gate revision. */
export function carryLines(state: RunState): string[] {
const c = state.carry;
return c && c.gate_rev === state.gate_rev ? c.lines : [];
}
function record(state: RunState, c: Omit<CarryRecord, 'at'>): CarryRecord {
const full = { ...c, at: now() };
state.carry = full;
journal(state.out, { event: c.verdict === 'carried' ? 'gate_carried' : c.verdict === 'check_pending' ? 'carry_check_dispatched' : 'carry_refused', gate_rev: c.gate_rev, from_gate_rev: c.from_gate_rev ?? null, reason: c.reason, ...(c.under_do_it?.length ? { under_do_it: c.under_do_it } : {}), ...(c.check ? { check: c.check.attempt, check_status: c.check.status } : {}) });
appendTrailRow(path.join(state.out, 'decisions.tsv'), ['gate', c.verdict === 'carried' ? `carried gate_rev ${c.gate_rev} from gate_rev ${c.from_gate_rev}` : c.verdict === 'check_pending' ? `delta check before carrying gate_rev ${c.gate_rev}` : `no carry for gate_rev ${c.gate_rev}`,
c.reason, c.check ? path.basename(c.check.result) : 'decisions.jsonl', c.check?.status ?? c.verdict]);
return full;
}
const refuse = (state: RunState, rev: number, from: number | undefined, reason: string) => record(state, { gate_rev: rev, from_gate_rev: from, verdict: 'refused', reason, lines: [`CARRY: no (${reason})`] });
/** Drop a delta check still waiting for its reviewer (the owner answered, the plan was revised, a new gate was written) and release its reservation. */
export function voidPendingCheck(state: RunState, why: string): string[] {
const c = state.carry;
if (c?.verdict !== 'check_pending' || !c.check) return [];
release(path.join(state.out, FILES.spend), c.check.attempt);
const out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check ${c.check.attempt} void: ${why}`);
out.check = { ...c.check, status: 'unavailable' };
return out.lines;
}
/** The reviewed plan's changes since the copy taken when the owner was asked at `rev`, and their hash. */
function deltaSince(state: RunState, rev: number): { diff: string; sha: string } | undefined {
const before = atGateCopy(state.out, rev);
if (!fs.existsSync(before)) return undefined;
const diff = lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(state.active_plan, 'utf8')).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n');
return { diff, sha: sha256(diff) };
}
interface Source { id: string; gate_rev: number; words: string; by?: string }
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number };
/** Every condition but the delta check; returns why not, or what the carry takes. */
function assess(state: RunState, prev: GateList, list: GateList): Plan {
const from = prev.gate_rev;
if (state.carry_forward === false) return { reason: 'carry-forward is off for this run (--no-carry-forward)', from };
const p1 = readDecisions(state.out).find(r => r.gate_rev === from && (r.label ?? '').toLowerCase() === 'p1');
if (!p1 || p1.status === 'pending' || !p1.answered_at) return { reason: `gate_rev ${from} was not answered`, from };
const unanswered = readDecisions(state.out).find(r => r.gate_rev === from && r.status === 'pending');
if (unanswered) return { reason: `gate_rev ${from} left ${unanswered.label ?? unanswered.id} unanswered`, from };
if (p1.chosen !== 'a' && p1.chosen !== 'b') return { reason: `gate_rev ${from} answer was p1${p1.chosen}, not an approval`, from };
if (p1.carried_gate_from) return { reason: `gate_rev ${from} was itself carried; the owner answers this one`, from };
if (parseReply(p1.reply ?? '', prev).mode !== 'rec') return { reason: `gate_rev ${from} was answered with item tokens, not rec or "do it"`, from };
const words = p1.owner_reply ?? (p1.answered_by === 'human' ? p1.reply : undefined);
if (!words?.trim()) return { reason: `no owner words recorded at gate_rev ${from}`, from };
const amendments = (state.amendments ?? []).filter(a => a.gate_rev === from);
const open = amendments.find(a => a.status !== 'incorporated' && a.status !== 'settled');
if (open) return { reason: `accepted change ${open.decision} is ${open.status}`, from };
const journalRows = readJournal(state.out);
const at = journalRows.map(r => r.event === 'gate_written' && r.gate_rev === from).lastIndexOf(true);
let expected: string | undefined = at >= 0 ? journalRows[at]!.source_file_sha256 : undefined;
for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') expected = r.source_file_sha256;
if (!expected) return { reason: 'no plan evidence recorded for the gate or the revise (a run written before carry-forward)', from };
if (fileSha(planFile(state)) !== expected) return { reason: `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`, from };
if (driftLine(state, 'PLAN_SOURCE_CHANGED')) return { reason: 'the reviewed plan differs from the plan file', from };
const urgent = readJsonl(path.join(state.out, 'findings.jsonl')).rows.find(r => (r.urgent === true || r.outside_plan === true) && !r.settled_by);
if (urgent) return { reason: `urgent finding open: ${String(urgent.title ?? urgent.id)}`, from };
const doIt: GateItem[] = []; const deltas: GateItem[] = [];
for (const item of list.items) {
if (item.id === 'p1' || item.carried) continue;
const option = item.options.find(o => o.key === item.recommended);
if (item.cap?.kind === 'delta' && !option?.changes_plan && item.recommended === 'a') { deltas.push(item); continue; }
if (isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
return { reason: `new item ${item.id}: ${item.title}`, from };
}
return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by }, doIt, deltas };
}
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
export function evaluateCarry(state: RunState, prev: GateList | undefined, list: GateList): CarryRecord | undefined {
voidPendingCheck(state, `gate_rev ${list.gate_rev} was written`);
if (!prev || list.gate_rev < 2) return undefined;
try {
const plan = assess(state, prev, list);
if ('reason' in plan) return refuse(state, list.gate_rev, plan.from, plan.reason);
if (plan.deltas.length) return dispatchCheck(state, prev, list, plan);
return carry(state, list, plan.source, plan.doIt, plan.deltas);
} catch (e: any) {
if (readDecisions(state.out).some(r => r.gate_rev === list.gate_rev && r.answered_at && !r.carried_from)) throw e;
return refuse(state, list.gate_rev, prev.gate_rev, `error: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}`);
}
}
function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Extract<Plan, { source: Source }>): CarryRecord {
const rev = list.gate_rev;
const cap = state.carry_check_cap_usd ?? DEFAULT_CARRY_CHECK_CAP_USD;
const estimate = attemptEstimate(state, undefined, process.env).usd;
if (estimate > cap) return refuse(state, rev, prev.gate_rev, `delta check estimate ${estimate.toFixed(2)} is over the per-gate cap ${cap.toFixed(2)} (--carry-check-cap)`);
const delta = deltaSince(state, prev.gate_rev);
if (!delta) return refuse(state, rev, prev.gate_rev, `no copy of the plan the owner answered at gate_rev ${prev.gate_rev}`);
const current = fs.readFileSync(state.active_plan, 'utf8');
const { diff, sha: deltaSha } = delta;
const attempt = `${state.run}-c${rev}`;
const prompt = path.join(state.out, `carry-check-g${rev}-prompt.md`);
const result = path.join(state.out, `carry-check-g${rev}.md`);
try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: 'carry-check' }); }
catch (e: any) { return refuse(state, rev, prev.gate_rev, `delta check does not fit the run's spend cap: ${e?.message ?? e}`); }
const accepted = (state.amendments ?? []).filter(a => a.gate_rev === prev.gate_rev).map(a => `- ${a.title} -> ${a.text}`).join('\n') || '- (none recorded)';
fs.writeFileSync(prompt, [BOUNDARY, '',
`You are checking one edit to a reviewed plan before gstack reuses the owner's approval of gate_rev ${prev.gate_rev} for gate_rev ${rev}. The owner accepted these changes:`, accepted, '',
'The plan was edited after that answer, but the phases that would re-review the edit were at their round cap, so no reviewer has read it. Read the delta and say whether it implements the accepted changes, adds nothing the owner did not accept (scope, cost or direction), and leaves the plan correct. Rate a finding High or Critical only when the edit adds an unaccepted change, contradicts an accepted one, or would make the plan fail a test, break production, or lose data or secrets; anything else is Medium or lower.', '',
`Delta (reviewed plan at gate_rev ${prev.gate_rev} -> now; + added, - removed; SHA-256 ${deltaSha}):`, '```diff', diff, '```', '',
'The complete current plan follows as context.', '', current,
REVIEWER_TRAILER, RESULT_FORMAT('carry-check', deltaSha, result)].join('\n'));
const check: CarryCheck = { attempt, prompt, result, delta_sha256: deltaSha, items: plan.deltas.map(i => i.id), estimate_usd: estimate, status: 'dispatched', at: now() };
const family = state.native_model ? `not ${modelFamily(state.native_model)}` : 'differs from the plan reviewers where available';
return record(state, { gate_rev: rev, from_gate_rev: prev.gate_rev, verdict: 'check_pending', words: plan.source.words, under_do_it: plan.doIt.map(i => i.id), check,
reason: `only an unreviewed post-cap delta is new (${check.items.join(',')}); one reviewer reads it first`,
lines: [`CARRY_CHECK: attempt=${attempt} items=${check.items.join(',')} prompt=${prompt} result=${result} model_family=${family} estimate=${estimate.toFixed(2)} cap=${cap.toFixed(2)}`,
`NEXT: dispatch one reviewer on the prompt, then gstack-autoplan check --out ${state.out} --attempt ${attempt} --result ${result} --model <id> (or --unavailable "<reason>" to relay the gate as today)`] });
}
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord {
const rev = list.gate_rev;
const p1 = list.items.find(i => i.id === 'p1')!;
const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } });
const marks = new Map<string, Record<string, string>>([...doIt.map(i => [decisionId(list.run, rev, i.id), { carried_under: 'do it' }] as const), ...deltas.map(i => [decisionId(list.run, rev, i.id), { carry_check: check?.attempt ?? '' }] as const)]);
if (marks.size) writeDecisions(state.out, readDecisions(state.out).map(r => (marks.has(r.id) ? { ...r, ...marks.get(r.id) } : r)));
const lines = [`CARRIED: gate_rev=${rev} <- gate_rev=${source.gate_rev} words=${JSON.stringify(source.words)} by=${source.by ?? 'unknown'} reason=${reason}`,
...doIt.map(i => `CARRIED_UNDER_DO_IT: ${i.id} ${i.title} -> ${i.options.find(o => o.key === i.recommended)?.text ?? i.recommended} (the owner has not seen this item)`),
...answered.lines];
return record(state, { gate_rev: rev, from_gate_rev: source.gate_rev, verdict: 'carried', reason, words: source.words, under_do_it: doIt.map(i => i.id), ...(check ? { check } : {}), lines });
}
export interface CheckOptions { out: string; attempt?: string; result?: string; model?: string; unavailable?: string; sameFamily?: string }
/** `gstack-autoplan check`: bind the delta reviewer's result (or its absence) and carry, or leave the gate for the owner. */
export function submitCarryCheck(o: CheckOptions): { state: RunState; lines: string[] } {
const state = readState(path.resolve(o.out));
const c = state.carry;
if (!c || c.verdict !== 'check_pending' || !c.check || state.status !== 'gate_pending' || c.gate_rev !== state.gate_rev) throw new RunError('PHASE_NOT_AWAITING', `no delta check is pending (run is ${state.status}, gate_rev=${state.gate_rev ?? 'none'})`, 3);
const check = c.check;
if (o.attempt !== check.attempt) throw new RunError('ATTEMPT_MISMATCH', `pass --attempt ${check.attempt}`, 3);
const stale = readDecisions(state.out).some(r => r.gate_rev === c.gate_rev && r.answered_at && !r.carried_from) ? `the owner answered gate_rev ${c.gate_rev}`
: deltaSince(state, c.from_gate_rev ?? 0)?.sha !== check.delta_sha256 ? 'the plan changed after the delta check was dispatched' : undefined;
if (stale) {
const lines = voidPendingCheck(state, stale);
writeState(state);
return { state, lines };
}
const gateFile = path.join(state.out, 'gate.json');
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
const spend = path.join(state.out, FILES.spend);
let out: CarryRecord;
if (o.unavailable !== undefined) {
release(spend, check.attempt);
list.carry_check = { attempt: check.attempt, model: 'none', verdict: 'unavailable', max_severity: 'none', findings: 0, at: now() };
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check unavailable: ${o.unavailable}`);
out.check = { ...check, status: 'unavailable' };
} else {
if (!o.result || !o.model) throw new RunError('RESULT_RECEIPT_MISSING', 'check needs --result <file> and --model <id>, or --unavailable "<reason>"', 3);
if (!fs.existsSync(o.result)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${o.result}`);
const family = modelFamily(o.model);
const author = state.native_model ? modelFamily(state.native_model) : undefined;
if (!o.sameFamily && (family === 'unknown' || family === author)) throw new RunError('MODEL_FAMILY_CONFLICT', `${o.model} is ${family === 'unknown' ? 'of no recognized family' : `the plan reviewers' family (${family})`}; use another family, or pass --same-family "<why none is available>"`, 3);
const text = fs.readFileSync(o.result, 'utf8');
const parsed = parseReviewResult(text, { phase: 'carry-check', sha256: check.delta_sha256 });
if (!parsed.receipt.ok) throw new RunError('RESULT_RECEIPT_MISSING', parsed.errors[0]!);
if (!parsed.fenceFound || parsed.errors.length) throw new RunError('RESULT_FINDINGS_MISSING', parsed.errors.join('; '));
fs.writeFileSync(path.join(state.out, `carry-check-g${c.gate_rev}.bound.md`), text, process.platform === 'win32' ? {} : { mode: 0o444 });
settle(spend, check.attempt, 'unknown');
const order = ['Informational', 'Low', 'Medium', 'High', 'Critical'];
const max = parsed.findings.map(f => String(f.severity)).reduce((a, b) => (order.indexOf(b) > order.indexOf(a) ? b : a), 'none');
const blocking = parsed.findings.find(f => HIGH.includes(String(f.severity)));
const done: CarryCheck = { ...check, status: blocking ? 'failed' : 'passed', model: o.model, max_severity: max, findings: parsed.findings.length, at: now() };
list.carry_check = { attempt: check.attempt, model: o.model + (o.sameFamily ? ` (same family: ${o.sameFamily})` : ''), verdict: blocking ? 'failed' : 'passed', max_severity: max, findings: parsed.findings.length, at: done.at };
if (blocking) for (const item of list.items) if (check.items.includes(item.id)) (item.flags ??= []).push(`carry check ${o.model}: ${blocking.severity} ${blocking.title}`);
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
if (blocking) {
out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check found ${blocking.severity}: ${blocking.title}`);
out.check = done;
} else {
const plan = assess(state, { ...list, gate_rev: c.from_gate_rev! } as GateList, list);
if ('reason' in plan) { out = refuse(state, c.gate_rev, c.from_gate_rev, plan.reason); out.check = done; }
else {
out = carry(state, list, plan.source, plan.doIt, plan.deltas, done);
out.lines.unshift(`CARRY_CHECK: passed attempt=${check.attempt} model=${o.model} findings=${done.findings} highest=${max}`);
}
}
}
writeState(state);
return { state, lines: out.lines };
}
+37 -10
View File
@@ -24,7 +24,8 @@ import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHAN
import type { Phase } from './autoplan-prompts';
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, voiceTerminal, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state';
import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget';
import { capyRelayLine } from './captain-brief';
import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry';
import { buildEli5, capyRelayLine } from './captain-brief';
import { previousReview, reviewRows, settledIds } from './autoplan-confirm';
import { phaseClose } from './autoplan-timing';
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
@@ -237,7 +238,8 @@ export function buildGate(state: RunState, gateRev: number): GateList {
}
items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` }));
items.push(planApproval(state, items));
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 };
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0,
...(fs.existsSync(state.active_plan) ? { eli5: buildEli5(fs.readFileSync(state.active_plan, 'utf8'), state.active_plan) } : {}) };
const problems = validateGateList(list);
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
return list;
@@ -285,9 +287,29 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
...[capyRelayLine(state.out)].filter((l): l is string => !!l)];
}
/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */
/** The gate list the owner last answered: gate.json, or its archive (`gate.r<rev>[.v<k>].json`) once a reopen moved it. */
function previousGate(out: string, rev: number): GateList | undefined {
if (rev < 1) return undefined;
const re = new RegExp(`^gate\\.r${rev}(?:\\.v(\\d+))?\\.json$`);
const archived = fs.readdirSync(out).map(f => [f, re.exec(f)] as const).filter(([, m]) => m).sort((a, b) => Number(b[1]![1] ?? 1) - Number(a[1]![1] ?? 1)).map(([f]) => f);
for (const name of ['gate.json', ...archived]) {
const file = path.join(out, name);
if (!fs.existsSync(file)) continue;
const list = JSON.parse(fs.readFileSync(file, 'utf8')) as GateList;
if (list.gate_rev === rev) return list;
}
return undefined;
}
/**
* Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged
* answered proposals carry forward, and a later revision that asks nothing new carries the owner's last answer
* (lib/autoplan-carry.ts; its lines are on `carryLines(state)`).
*/
export function finishGate(state: RunState): GateList {
const gateRev = (state.gate_rev ?? 0) + 1;
const gateFile = path.join(state.out, 'gate.json');
const prev = previousGate(state.out, state.gate_rev ?? 0);
const list = buildGate(state, gateRev);
const kept = readDecisions(state.out).filter(r => r.gate_rev !== gateRev);
const answered = new Map(kept.filter(r => r.origin && r.chosen != null && r.status !== 'pending').map(r => [r.origin!, r]));
@@ -295,7 +317,7 @@ export function finishGate(state: RunState): GateList {
const prev = item.origin ? answered.get(item.origin) : undefined;
if (prev && item.options.some(o => o.key === prev.chosen)) item.carried = { from: prev.carried_from ?? prev.id, chosen: prev.chosen!, gate_rev: prev.gate_rev };
}
fs.writeFileSync(path.join(state.out, 'gate.json'), JSON.stringify(list, null, 2) + '\n');
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
const rows = decisionRows(list).map(row => {
const item = list.items.find(i => decisionId(list.run, gateRev, i.id) === row.id)!;
if (!item.carried) return row;
@@ -308,7 +330,8 @@ export function finishGate(state: RunState): GateList {
state.status = 'gate_pending';
state.gate = { rev: gateRev, items: list.items.length, written_at: now() };
exportRun(state);
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id) });
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id), ...gateEvidence(state, gateRev) });
evaluateCarry(state, prev, list);
return list;
}
@@ -321,6 +344,8 @@ export interface AnswerOptions {
recHash?: string;
/** The owner's verbatim words; required for every `--by agent:<id>` answer. */
ownerReply?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision and the owner's words it reuses. Replaces ownerReply and its re-parse. */
carry?: { from: string; gate_rev: number; words: string; by?: string; reason: string };
}
/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */
@@ -361,7 +386,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3);
if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3);
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined && !o.carry) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
const rows = readDecisions(state.out);
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
@@ -372,12 +397,13 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3);
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3);
if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
const ownerParsed = o.ownerReply !== undefined && !o.carry ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.carry ? { owner_reply: o.carry.words, carried_gate_from: o.carry.from, carried_gate_rev: o.carry.gate_rev, carry_reason: o.carry.reason, ...(o.carry.by ? { carried_answered_by: o.carry.by } : {}) }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
const plan = parsed.choices.find(c => c.id === 'p1');
const lines: string[] = [];
const lines: string[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
const answeredAt = now();
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => {
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
@@ -399,7 +425,8 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []);
record();
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance };
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
const answerClass = plan.chosen !== 'a' && plan.chosen !== 'b' ? `p1${plan.chosen}` : changing.length ? 'plan-changes' : overrides.length ? 'overrides' : 'as-recommended';
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, answer_class: answerClass, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
if (plan.chosen === 'c') {
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
+7 -2
View File
@@ -17,10 +17,12 @@
* written and `revise_intent` journaled before anything moves; `resume`
* (recoverRevise) completes an interrupted revise from the stage.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { carryLines, voidPendingCheck } from './autoplan-carry';
import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
@@ -109,8 +111,10 @@ export function decisionSeverity(state: RunState, decision: string): string {
function approveOrRegate(state: RunState, at: string): string[] {
if (pendingDeltas(state).length) {
finishGate(state);
const carried = carryLines(state);
if (state.status !== 'gate_pending') return carried;
const escalate = escalateLine(state);
return [`GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
return [...carried, `GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
@@ -176,6 +180,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
const delta = capped.length || quiet ? diffStat(lineDiff(fs.readFileSync(state.active_plan, 'utf8'), fs.readFileSync(path.join(staging, FILES.active), 'utf8'))) : { added: 0, removed: 0 };
const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle });
voidPendingCheck(state, 'the plan was revised');
const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : [];
for (const phase of capped) {
@@ -199,7 +204,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : pendingDeltas(state).length ? 'gate_pending' : 'complete';
// A quiet revise that ends at gate_pending still has an earlier capped delta waiting: the owner gets a new gate for it.
const regate = (capped.length > 0 || quiet) && state.status === 'gate_pending';
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null };
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, source_file_sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), reopened: reopen, added, at_gate_rev: atGate ?? null };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
publish(state, staging, renames, committed, settled);
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`,
+22 -6
View File
@@ -20,6 +20,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate';
import { carryLines } from './autoplan-carry';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
@@ -71,7 +72,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number }
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
export function initRun(o: InitOptions, lines: string[] = []): RunState {
@@ -121,6 +122,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
...(o.light ? { light: true } : {}),
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
};
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
@@ -176,6 +178,17 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) });
}
/** The first next's price line: the most the review can reserve at this scope and round cap (a reservation total, not a spend ceiling). */
export function reviewEstimateLine(state: RunState, perVoiceUsd: number): string {
const voices = state.phase_order.map(p => (!state.outside_enabled || (p === 'ceo' && state.scope.docs_only) ? 1 : 2));
const perRound = voices.reduce((a, b) => a + b, 0);
const rounds = state.max_rounds ?? null;
const per = perVoiceUsd > 0 ? perVoiceUsd.toFixed(2) : 'unknown';
const attempts = rounds === null ? `${perRound} per round, rounds uncapped` : `<=${perRound * rounds}`;
const total = rounds === null || perVoiceUsd <= 0 ? 'unknown' : `<=${(perRound * rounds * perVoiceUsd).toFixed(2)}`;
return `REVIEW_ESTIMATE: phases=${state.phase_order.join(',')} voices=${voices.join('+')} rounds${rounds === null ? '=uncapped' : `<=${rounds}`} attempts${attempts.startsWith('<=') ? attempts : `=${attempts}`} per_voice=${per} reservations${total === 'unknown' ? '=unknown' : total} (a reservation total, not a spend ceiling; actual host spend stays unknown; round grants at the gate add more)`;
}
export function nextStep(o: NextOptions): NextResult {
const state = readState(path.resolve(o.out));
const lines: string[] = [];
@@ -193,7 +206,7 @@ export function nextStep(o: NextOptions): NextResult {
lines.push(...closeDeferred(state, phase, { reason: `round cap: round ${roundOf(state, phase)} would pass max ${roundCap(state, phase)}`, by: 'runner', cause: 'cap' }).lines);
phase = currentPhase(state);
}
if (!phase) { finishGate(state); writeState(state); return result; }
if (!phase) { finishGate(state); lines.push(...carryLines(state)); writeState(state); return result; }
const first = !state.phase_order.some(p => state.phases[p]?.snapshot);
openPhase(state, phase, o.cwd);
const ps = state.phases[phase]!;
@@ -208,6 +221,7 @@ export function nextStep(o: NextOptions): NextResult {
const prompts = ps.prompts!;
const estimate = attemptEstimate(state, o.estimateUsd, process.env);
if (estimate.line) lines.push(estimate.line);
if (first) lines.push(reviewEstimateLine(state, estimate.usd));
const fresh = (['native', 'outside'] as const).filter(voice => { const v = voiceState(state, phase!, voice); const open = openAttempt(v); return !voiceTerminal(v) && (!open || (open.state === 'execution_unknown' && o.redispatch?.includes(open.attempt))); });
const before = summarize(readLedger(path.join(state.out, FILES.spend), state.spend_cap_usd));
if (state.spend_cap_usd !== null && fresh.length && before.worst_case_usd + fresh.length * estimate.usd > state.spend_cap_usd + 1e-9) {
@@ -369,8 +383,9 @@ export function submitResult(o: SubmitOptions): SubmitResult {
function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] {
const close = closePhase(state, phase, { overridesFile: reconcile });
if (!currentPhase(state)) finishGate(state); else state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`];
const gated = !currentPhase(state);
if (gated) finishGate(state); else state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...(gated ? carryLines(state) : [])];
}
/**
@@ -451,14 +466,15 @@ export function resumeRun(out: string): ResumeResult {
if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
}
const stopped = resumeStop(state);
if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state);
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status);
if (gated) finishGate(state);
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v))));
state.status = anyOpen ? 'awaiting_result' : 'running';
}
journal(out, { event: 'resumed', unknown, rebound, status: state.status });
writeState(state);
const lines = [...recovered, ...stopped, `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
const lines = [...recovered, ...stopped, ...(gated ? carryLines(state) : []), `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
for (const a of unknown) lines.push(`EXECUTION_UNKNOWN: ${a}`);
return { state, unknown, rebound, lines };
}
+2
View File
@@ -70,6 +70,8 @@ export interface RunState extends RunManifest {
stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[];
/** Written by export from attempts.jsonl; est_input_tokens is dispatched prompt bytes / 4, a relative estimate only. Absent on older runs. */
dispatches?: DispatchCounts; est_input_tokens?: number;
/** Carry-forward (lib/autoplan-carry.ts): false with --no-carry-forward; the per-gate cap on the delta check; the latest carry decision. */
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+21 -5
View File
@@ -14,7 +14,7 @@ import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gateCost, gateCostSentence, priorChoices } from './autoplan-gate-cost';
import { parseReply, recHash, type GateItem } from './gate-list';
import { parseReply, recHash, type GateEli5, type GateItem } from './gate-list';
import { renderUrgentBlock } from './headless-artifacts';
import { findSection, firstTitle } from './markdown-sections';
import type { BriefInputs } from './owner-brief';
@@ -25,6 +25,8 @@ export const BRIEF_SECTIONS = [
{ title: 'What caused this work', aliases: ['What caused this work', 'What caused us to work on this', 'What caused this', 'Trigger'] },
{ title: 'Big decisions', aliases: ['Big decisions', 'Big decisions we made', 'Key decisions'] },
] as const;
/** The 5-10 plain sentences a plan opens its ask with; required in a captain brief only when the gate carries `eli5` (written by gstack 1.91.305+). */
export const ELI5_SECTION = { title: 'ELI5', aliases: ['ELI5', 'In plain words'] } as const;
const SECTION_LINES = 24;
const SECTION_CHARS = 3000;
@@ -60,6 +62,13 @@ function capped(body: string, planPath: string): string {
return hidden > 0 || out.length < body.length ? `${out}\n\n_+ more in the plan (${path.basename(planPath)})._` : out;
}
/** gate.json `eli5`: the plan's ELI5 and the four approvals-shape sections, capped as the brief caps them. */
export function buildEli5(planText: string, planFile: string): GateEli5 {
const body = (aliases: readonly string[]) => { const s = findSection(planText, [...aliases]); return s?.body.trim() ? capped(s.body.trim(), planFile) : ''; };
const [before, why, cause, decisions] = BRIEF_SECTIONS.map(s => body(s.aliases));
return { summary: body(ELI5_SECTION.aliases), before_after: before!, why: why!, cause: cause!, decisions: decisions!, plan_file_sha256: createHash('sha256').update(planText).digest('hex') };
}
export interface CaptainBrief { text: string; missing: string[]; pending: boolean; recHash: string }
export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }): CaptainBrief {
@@ -71,8 +80,10 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
const rowOf = (id: string) => inputs.decisions.filter(d => d.gate_rev === list.gate_rev).find(d => (d.label ?? '').toLowerCase() === id);
const p1Row = p1 ? rowOf('p1') : undefined;
const pending = !p1Row || p1Row.status === 'pending';
const sections = BRIEF_SECTIONS.map(s => ({ title: s.title, found: findSection(plan.text, [...s.aliases]) }));
const missing = sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title);
const eli5 = list.eli5 ? buildEli5(plan.text, plan.file ?? 'plan.md') : undefined;
const fromGate = eli5 ? [eli5.before_after, eli5.why, eli5.cause, eli5.decisions] : [];
const sections = BRIEF_SECTIONS.map((s, i) => ({ title: s.title, found: eli5 ? { body: fromGate[i]! } : findSection(plan.text, [...s.aliases]) }));
const missing = [...(eli5 && !eli5.summary.trim() ? [ELI5_SECTION.title] : []), ...sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title)];
const urgent = inputs.findings.some(r => r.urgent === true || r.outside_plan === true) ? [renderUrgentBlock(inputs.findings).trimEnd(), ''] : [];
const items = list.items.filter(i => i.id !== 'p1');
const prior = priorChoices(o.outDir, list.gate_rev);
@@ -83,7 +94,11 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
const keptNote = (i: GateItem) => i.carried?.chosen === recKey(i) ? ` (your answer at gate_rev ${i.carried.gate_rev}, carried forward)` : ' (your answer at this gate)';
let header: string[];
if (!pending) {
if (!pending && p1Row?.carried_gate_from) {
const unseen = inputs.decisions.filter(d => d.gate_rev === list.gate_rev && d.carried_under).map(d => (d.title ?? '').replace(/\s*\[[^\]]*\]\s*$/, ''));
header = [`**Nothing pending.** Carried forward from gate_rev ${p1Row.carried_gate_rev}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'} under the owner's words “${p1Row?.owner_reply ?? ''}”, because nothing new needed deciding.`,
...(unseen.length ? ['', `Taken under "do it" without being shown to the owner: ${unseen.join('; ')}.`] : [])];
} else if (!pending) {
header = [`**Nothing pending.** Already decided${p1Row?.answered_at ? ` on ${p1Row.answered_at.slice(0, 10)}` : ''}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'}${p1Row?.owner_reply ?? p1Row?.reply ? ` (“${p1Row?.owner_reply ?? p1Row?.reply}”)` : ''}.`];
} else if (missing.length) {
header = [`**Not ready for approval:** the plan "${title}" is missing ${missing.join(', ')}. The thread will add them and send this again; do not approve from this draft.`];
@@ -94,7 +109,8 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
];
}
const body = sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? capped(s.found.body.trim(), plan.file ?? 'plan.md') : '_Not written for this plan yet._', '']);
const summary = eli5?.summary.trim() ? [`### ${ELI5_SECTION.title}`, '', eli5.summary.trim(), ''] : [];
const body = [...summary, ...sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? (eli5 ? s.found.body.trim() : capped(s.found.body.trim(), plan.file ?? 'plan.md')) : '_Not written for this plan yet._', ''])];
const need: string[] = ['### What we need from you', ''];
if (!pending) need.push('Nothing. This gate is answered.', '');
else {
+135
View File
@@ -0,0 +1,135 @@
/**
* claude-usage — the Claude Code meter for the /ship budget (GSTA-152 D1). A
* live-session reader beside lib/claude-journal-records.ts (record identity and
* ownership) and lib/claude-public-transcript.ts (question metadata): it only
* sums `message.usage`. A session is bound explicitly, by `--session <id>` or by
* a nonce the agent echoed once (zero or several matching journals refuse).
* Its journal, its subagent journals (`<session>/subagents/*.jsonl`) and the
* journals of headless children the handoff recorded all count. Each API call
* is counted once per (message.id, requestId) at the maximum of each usage
* field, which folds streaming duplicates and the copies a compaction or fork
* writes into another journal; compaction summaries carry no charge. Reads are
* incremental from a stored offset, guarded by a hash of the file's first
* bytes: a rewritten journal is read again from the start.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const FIELDS = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens'] as const;
const PREFIX_BYTES = 4096;
const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export interface ClaudeMeterState {
schema_version: 1;
config_dir: string;
sessions: string[];
files: Record<string, { offset: number; prefix_len: number; prefix: string }>;
keys: Record<string, number[]>;
last_key: string | null;
}
export const claudeConfigDir = (env: NodeJS.ProcessEnv = process.env) => env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
function projectDirs(configDir: string): string[] {
const projects = path.join(configDir, 'projects');
try { return fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => path.join(projects, d.name)); }
catch { return []; }
}
/** Journals named for a session id, across project directories. */
export function sessionJournals(configDir: string, session: string): string[] {
if (!SESSION.test(session)) return [];
return projectDirs(configDir).map(d => path.join(d, `${session}.jsonl`)).filter(f => fs.existsSync(f));
}
/** Sessions whose journal contains the nonce; the caller refuses unless exactly one matches. */
export function nonceSessions(configDir: string, nonce: string): string[] {
if (!/^[A-Za-z0-9_-]{8,}$/.test(nonce)) return [];
const found = new Set<string>();
for (const dir of projectDirs(configDir)) {
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.jsonl') || !SESSION.test(name.slice(0, -6))) continue;
if (fs.readFileSync(path.join(dir, name), 'utf8').includes(nonce)) found.add(name.slice(0, -6));
}
}
return [...found];
}
/** A session's own journal first, then its subagent journals. */
export function sessionFiles(configDir: string, session: string): string[] {
return sessionJournals(configDir, session).flatMap(main => {
const sub = path.join(path.dirname(main), session, 'subagents');
let extra: string[] = [];
try { extra = fs.readdirSync(sub).filter(n => n.endsWith('.jsonl')).sort().map(n => path.join(sub, n)); } catch {}
return [main, ...extra];
});
}
export function newMeterState(configDir: string, sessions: string[]): ClaudeMeterState {
return { schema_version: 1, config_dir: configDir, sessions: [...sessions], files: {}, keys: {}, last_key: null };
}
function prefixHash(fd: number, length: number): string {
const buf = Buffer.alloc(length);
fs.readSync(fd, buf, 0, buf.length, 0);
return crypto.createHash('sha256').update(buf).digest('hex');
}
function absorb(state: ClaudeMeterState, line: string, isRoot: boolean): void {
if (!line.includes('"usage"')) return;
let rec: any;
try { rec = JSON.parse(line); } catch { return; }
if (rec?.type !== 'assistant' || rec.isCompactSummary || typeof rec.message?.usage !== 'object' || rec.message.usage === null) return;
const key = `${rec.message.id ?? rec.uuid}|${rec.requestId ?? ''}`;
const prev = state.keys[key] ?? [0, 0, 0, 0];
state.keys[key] = FIELDS.map((f, i) => Math.max(prev[i]!, Number.isFinite(rec.message.usage[f]) ? Number(rec.message.usage[f]) : 0));
if (isRoot && !rec.isSidechain) state.last_key = key;
}
/** Read new complete lines of one file; false when the file was rewritten under us. */
function readIncrement(state: ClaudeMeterState, file: string, isRoot: boolean): boolean {
const fd = fs.openSync(file, 'r');
try {
const size = fs.fstatSync(fd).size;
const seen = state.files[file];
if (seen && (size < seen.offset || prefixHash(fd, seen.prefix_len) !== seen.prefix)) return false;
const start = seen?.offset ?? 0;
const buf = Buffer.alloc(size - start);
if (buf.length) fs.readSync(fd, buf, 0, buf.length, start);
const end = buf.lastIndexOf(0x0a) + 1;
for (const line of buf.subarray(0, end).toString('utf8').split('\n')) if (line) absorb(state, line, isRoot);
const prefixLen = Math.min(PREFIX_BYTES, size);
state.files[file] = { offset: start + end, prefix_len: prefixLen, prefix: prefixHash(fd, prefixLen) };
return true;
} finally { fs.closeSync(fd); }
}
const total = (v: number[] | undefined) => (v ?? []).reduce((s, n) => s + n, 0);
/** The meter reading: every charge of the bound sessions, and the last root call's charge. */
export function readClaudeUsage(state: ClaudeMeterState): { total: number; lastCall: number | null; files: number } {
const files = state.sessions.flatMap((s, i) => sessionFiles(state.config_dir, s).map((f, j) => ({ f, root: i === 0 && j === 0 })));
for (const { f, root } of files) {
if (readIncrement(state, f, root)) continue;
state.files = {};
state.keys = {};
state.last_key = null;
for (const again of files) readIncrement(state, again.f, again.root);
break;
}
const sum = Object.values(state.keys).reduce((s, v) => s + total(v), 0);
return { total: sum, lastCall: state.last_key ? total(state.keys[state.last_key]) : null, files: files.length };
}
export function loadMeterState(file: string): ClaudeMeterState | undefined {
try { return JSON.parse(fs.readFileSync(file, 'utf8')) as ClaudeMeterState; } catch { return undefined; }
}
export function saveMeterState(file: string, state: ClaudeMeterState): void {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
fs.renameSync(tmp, file);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* decision-trail — the append rules of a run's decision trail (docs/unattended.md#the-decision-trail):
* a TSV with one line per decision (ts, phase, decision, why, evidence, result). The header is
* written once and only ever appended, cells lose their tabs and line breaks, and a cell whose first
* byte a spreadsheet would run as a formula (=, +, -, @) gets a leading apostrophe. Shared by
* bin/gstack-decision-trail and the autoplan runner (lib/autoplan-carry.ts records its carry verdicts).
* The rules are a port of pstack's show-me-your-work/scripts/log.sh (Copyright Lauren Tan, MIT);
* licenses/MIT-pstack.txt, NOTICE.md.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export const TRAIL_COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
export const TRAIL_HEADER = TRAIL_COLUMNS.join("\t") + "\n";
export type TrailRow = { line: number; cells: string[] };
function clean(cell: string): string {
const flat = cell.replace(/[\t\n\r]/g, " ");
return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
}
export function readTrailRows(file: string): TrailRow[] {
if (!fs.existsSync(file)) return [];
const lines = fs.readFileSync(file, "utf8").split("\n");
const rows: TrailRow[] = [];
lines.forEach((raw, i) => {
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
// A repeated header (the append-on-flaky-mount case) is never a row.
if (text === "" || text === TRAIL_HEADER.trimEnd()) return;
rows.push({ line: i + 1, cells: text.split("\t") });
});
return rows;
}
/** Append one row (ts stamped in UTC) and return the row count. */
export function appendTrailRow(file: string, cells: readonly string[]): number {
const dir = path.dirname(file);
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// Append the header too, never truncate: on a flaky mount the worst case is
// one stray header line, not lost rows.
// A file with no header line anywhere (empty, blank, or rows written by hand)
// gets one appended once; readTrailRows skips a header line wherever it sits.
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, TRAIL_HEADER);
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
return readTrailRows(file).length;
}
function hasHeader(file: string): boolean {
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
.some(line => line.replace(/\r$/, "") === TRAIL_HEADER.trimEnd());
}
function endsWithNewline(file: string, size: number): boolean {
const fd = fs.openSync(file, "r");
try {
const last = Buffer.alloc(1);
fs.readSync(fd, last, 0, 1, size - 1);
return last[0] === 0x0a;
} finally {
fs.closeSync(fd);
}
}
+21 -1
View File
@@ -42,7 +42,14 @@ export interface GateItem {
}
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
rec_rev?: number }
rec_rev?: number;
/** The plan's plain-language ask, read from the reviewed plan (lib/captain-brief.ts `buildEli5`); printed first. */
eli5?: GateEli5;
/** The one-reviewer check of an unreviewed post-cap delta before a carry (lib/autoplan-carry.ts). */
carry_check?: { attempt: string; model: string; verdict: 'passed' | 'failed' | 'unavailable'; max_severity: string; findings: number; at: string } }
/** The captain approvals shape (docs/captain.md § Approvals), section text taken from the reviewed plan; '' when absent. */
export interface GateEli5 { summary: string; before_after: string; why: string; cause: string; decisions: string; plan_file_sha256: string }
export const ELI5_FIELDS = [['before_after', 'Before → after'], ['why', 'Why it matters'], ['cause', 'What caused this work'], ['decisions', 'Big decisions']] as const;
export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean }
export interface ParsedReply {
@@ -64,6 +71,10 @@ export function validateGateList(list: unknown): string[] {
if (g.schema_version !== 1) problems.push('schema_version must be 1');
if (typeof g.run !== 'string' || !g.run) problems.push('run is required');
if (!Number.isInteger(g.gate_rev) || (g.gate_rev as number) < 1) problems.push('gate_rev must be an integer >= 1');
if (g.eli5 !== undefined) {
if (!g.eli5 || typeof g.eli5 !== 'object') problems.push('eli5 must be an object');
else for (const k of ['summary', 'before_after', 'why', 'cause', 'decisions', 'plan_file_sha256'] as const) if (typeof g.eli5[k] !== 'string') problems.push(`eli5.${k} must be a string`);
}
if (!Array.isArray(g.items) || g.items.length === 0) return [...problems, 'items must be a non-empty array'];
const seen = new Set<string>();
g.items.forEach((item, i) => {
@@ -103,6 +114,8 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
const lines: string[] = [];
lines.push(`GATE: run=${list.run} gate_rev=${list.gate_rev} items=${list.items.length} page=${current}/${pages}${list.title ? ` — ${list.title}` : ''}`);
lines.push('');
if (list.eli5 && current === 1) lines.push(...renderEli5(list.eli5), '');
if (list.carry_check) lines.push(`Carry check ${list.carry_check.attempt}: ${list.carry_check.verdict} (${list.carry_check.model}; ${list.carry_check.findings} finding(s), highest ${list.carry_check.max_severity}).`, '');
list.items.slice(start, start + pageSize).forEach((item, i) => {
const n = start + i + 1;
lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`);
@@ -121,6 +134,13 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
return lines.join('\n') + '\n';
}
/** The ELI5 block: the summary, then each approvals-shape section under its title. An empty summary says so (CAPTAIN_BRIEF_INCOMPLETE). */
export function renderEli5(e: GateEli5): string[] {
const lines = e.summary.trim() ? ['ELI5', e.summary.trim()] : [];
for (const [key, title] of ELI5_FIELDS) if (e[key].trim()) lines.push(...(lines.length ? [''] : []), `${title}:`, e[key].trim());
return lines;
}
function exampleTokens(list: GateList): string {
return list.items.slice(0, 2).map(i => `${i.id}${i.options.find(o => o.key !== i.recommended)?.key ?? i.recommended}`).join(' ');
}
+142 -68
View File
@@ -21,8 +21,9 @@ import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation';
import { runBashSync } from './script-invocation';
import { frontmatterList, routerLine } from './skill-triggers';
export type SkillsMode = 'router' | 'all';
export type SkillsMode = 'router' | 'hybrid' | 'all';
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
type Env = Record<string, string | undefined>;
@@ -33,7 +34,9 @@ export const BLOCK_BEGIN = '<!-- gstack:begin harness-routing -->';
export const BLOCK_END = '<!-- gstack:end harness-routing -->';
/** Where a Dot keeps its own skills (feder-cr/dots nanobot/dots/skills.py DOT_SKILLS_DIR). */
export const DOT_SKILLS_DIR = '/home/dot/skills';
export const ROUTER_DESCRIPTION = 'gstack engineering workflows: code review, QA testing, debugging, security audit, plan and design review, shipping PRs, release docs, retros. Load first for any coding, review, test, plan or release task; it lists every gstack skill and where to read it.';
export const ROUTER_DESCRIPTION = 'Use to review a diff or PR, debug a bug or failing test, QA test a site, plan or review architecture, ship a PR, audit security or write release docs. Load first for any coding task: lists every gstack skill and where to read it.';
/** OpenClaw hybrid mode: these task skills are listed as skills of their own; the router carries the rest. */
export const HYBRID_SKILLS = ['review', 'investigate', 'qa', 'ship', 'plan-eng-review'];
export interface Target {
name: TargetName;
@@ -62,7 +65,7 @@ const ancestorIs = (ancestry: string[], name: string) => ancestry.some(id => {
export const TARGETS: Record<TargetName, Target> = {
openclaw: {
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'router', modes: ['router', 'all'],
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'],
dest: (env, home) => path.join(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'skills'),
inside: (env, anc) => env.OPENCLAW_SHELL === 'exec' || ancestorIs(anc, 'openclaw'),
present: (env, home) => onPath('openclaw', env) || fs.existsSync(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw')),
@@ -109,7 +112,8 @@ export const TARGETS: Record<TargetName, Target> = {
export const HARNESS_CODES = {
HARNESS_UNKNOWN_TARGET: { anchor: 'harness-unknown-target', fix: 'name one of: openclaw, hermes, dots, generic, qm, muse, grok' },
HARNESS_NO_DESTINATION: { anchor: 'harness-no-destination', fix: 'pass --skills-dir <dir> or --context <file>, or --print to see the block without writing' },
HARNESS_MODE_UNSUPPORTED: { anchor: 'harness-mode-unsupported', fix: 'drop --skills, or pass a mode the target supports (Hermes opens only listed skills, so it takes all)' },
HARNESS_MODE_UNSUPPORTED: { anchor: 'harness-mode-unsupported', fix: 'drop --skills, or pass a mode the target supports (Hermes takes all: its skills index lists only skills registered as folders, so a router would leave the rest out of the listing)' },
HARNESS_CONTEXT_REFUSED: { anchor: 'harness-context-refused', fix: 'name a rules file the harness already loads (for Hermes, the existing .hermes.md, HERMES.md or AGENTS.md), never SOUL.md' },
HARNESS_UNSAFE_DESTINATION: { anchor: 'harness-unsafe-destination', fix: 'pick a destination that is a plain folder outside the gstack checkout, not your home folder, with no tab or newline in its path' },
HARNESS_UNOWNED_ENTRY: { anchor: 'harness-unowned-entry', fix: 'move or rename the existing entry (gstack did not write it), then register again' },
HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' },
@@ -184,13 +188,6 @@ function frontmatterField(md: string, field: string): string {
return body.join(' ');
}
/** First sentence of a description, without the trailing "(gstack)" tag, at most 160 characters. */
export function oneLineUse(description: string): string {
const d = description.replace(/\s*\(gstack\)\s*$/, '').replace(/\s+/g, ' ').trim();
const s = (d.match(/^.*?[.!?](\s|$)/)?.[0] ?? d).trim();
return s.length > 160 ? s.slice(0, 157).trimEnd() + '...' : s;
}
/** Agent Skills frontmatter in its strictest reading (dots): single-line name equal to the folder, single-line description of at most 1024 characters. */
export function flatFrontmatter(md: string, name: string): string {
const end = md.startsWith('---\n') ? md.indexOf('\n---', 4) : -1;
@@ -199,14 +196,18 @@ export function flatFrontmatter(md: string, name: string): string {
return '---\nname: ' + name + '\ndescription: ' + (desc.length > 1024 ? desc.slice(0, 1021).trimEnd() + '...' : desc) + '\n---\n' + body;
}
function skillDirs(root: string): SkillEntry[] {
/** Generated skills under root; a render whose frontmatter dropped `triggers:` takes them from triggersByName (the checkout's skills, by name). */
function skillDirs(root: string, triggersByName = new Map<string, string[]>()): Array<SkillEntry & { triggers: string[] }> {
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root).sort().flatMap(name => {
const md = path.join(root, name, 'SKILL.md');
return fs.readdirSync(root).sort().flatMap(dir => {
const md = path.join(root, dir, 'SKILL.md');
if (!fs.existsSync(md)) return [];
const text = fs.readFileSync(md, 'utf8');
if (!text.includes('AUTO-GENERATED')) return [];
return [{ name: frontmatterField(text, 'name') || name, use: oneLineUse(frontmatterField(text, 'description')), dir: path.join(root, name) }];
const name = frontmatterField(text, 'name') || dir;
const own = frontmatterList(text, 'triggers');
const triggers = own.length ? own : triggersByName.get(name) ?? triggersByName.get(name.replace(/^gstack-/, '')) ?? [];
return [{ name, use: routerLine(frontmatterField(text, 'description'), triggers), dir: path.join(root, dir), triggers }];
});
}
@@ -218,33 +219,43 @@ export function sourceSkills(target: Target, checkout: string, scratch: string):
const r = spawnSync(process.execPath, [...bunIsolationArgs(), 'scripts/gen-skill-docs.ts', '--host', target.name, '--install-root', checkout, '--out-dir', out],
{ cwd: checkout, encoding: 'utf8', timeout: 300_000 });
if (r.status !== 0) throw new HarnessError('HARNESS_RENDER_FAILED', 'could not render ' + target.name + ' skills: ' + ((r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' | ')));
const skills = skillDirs(path.join(out, '.' + target.name, 'skills'));
const skills = skillDirs(path.join(out, '.' + target.name, 'skills'), new Map(skillDirs(checkout).map(s => [s.name, s.triggers])));
if (!skills.length) throw new HarnessError('HARNESS_RENDER_FAILED', 'the ' + target.name + ' render produced no skills');
return skills;
}
export function routerSkill(skills: Array<{ name: string; use: string; path: string }>, target: Target): string {
/** The router skill; direct names the skills hybrid mode lists as skills of their own. */
export function routerSkill(skills: Array<{ name: string; use: string; path: string }>, target: Target, direct: string[] = []): string {
const claudeNote = target.render === 'claude'
? '\nThese files use Claude Code tool names (Bash, Read, Edit, Agent, AskUserQuestion); use your own equivalents.\n' : '';
const directNote = direct.length ? ' ' + direct.join(', ') + ' are skills of their own in your list; this router carries the rest.' : '';
return [
'---', 'name: gstack', 'description: ' + ROUTER_DESCRIPTION, '---',
'<!-- AUTO-GENERATED by gstack-harness register: rerun it instead of editing -->',
'# gstack skills', '',
'Pick the one skill whose line matches the task, then read that SKILL.md in full and follow it. Read nothing else up front.' + claudeNote,
'Pick the one skill whose line matches the task, then read that SKILL.md in full and follow it. Read nothing else up front.' + directNote + claudeNote,
...skills.map(s => '- **' + s.name + '**: ' + s.use + ' Read `' + s.path + '`'),
'',
].join('\n');
}
export function contextBlock(routerPath: string): string {
return [BLOCK_BEGIN, '## gstack skills',
"gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. For a coding, review, test, plan or release task, read `" + routerPath + '`, pick the one skill whose line matches, then read and follow that skill. Load nothing else up front.',
BLOCK_END].join('\n');
/** The marked context block for the registration's mode: router (a router path), hybrid (a router path and direct skills) or all (no router). */
export function contextBlock(routerPath: string | null, direct: string[] = []): string {
const lead = "gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. ";
const pick = ', pick the one skill whose line matches, then read and follow that skill. Load nothing else up front.';
const body = !routerPath
? 'For a coding, review, test, plan or release task, load the matching gstack skill from your skills list and follow it. Load nothing else up front.'
: direct.length
? 'For a task that ' + direct.join(', ') + ' fits, load that skill from your skills list; for any other coding, review, test, plan or release task, read `' + routerPath + '`' + pick
: 'For a coding, review, test, plan or release task, read `' + routerPath + '`' + pick;
return [BLOCK_BEGIN, '## gstack skills', lead + body, BLOCK_END].join('\n');
}
// --- ledger ----------------------------------------------------------------
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: Array<{ sha: string; path: string }> }
/** pad: what gstack added before a block it appended, so uninstall restores the file byte for byte: new (it created the file), 0 (the file was empty), 1 (one blank line), 2 (a newline and a blank line); '' in ledgers that predate it. */
export interface LedgerBlock { sha: string; path: string; pad: string }
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: LedgerBlock[] }
export const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
export const ledgerDir = (stateRoot: string) => path.join(stateRoot, 'harness');
@@ -254,13 +265,15 @@ export const ledgerPath = (stateRoot: string, id: string) => path.join(ledgerDir
export function readLedger(file: string): Ledger | null {
if (!fs.existsSync(file)) return null;
const l: Ledger = { meta: {}, files: [], dirs: [], blocks: [] };
const pads = new Map<string, string>();
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const [kind, a, ...rest] = line.split('\t');
const b = rest.join('\t');
if (kind === 'meta') l.meta[a] = b;
else if (kind === 'file') l.files.push({ sha: a, path: b });
else if (kind === 'dir') l.dirs.push(b);
else if (kind === 'block') l.blocks.push({ sha: a, path: b });
else if (kind === 'blockpad') pads.set(b, a);
else if (kind === 'block') l.blocks.push({ sha: a, path: b, pad: pads.get(b) ?? '' });
}
return l;
}
@@ -270,7 +283,7 @@ export function writeLedger(file: string, l: Ledger): void {
...Object.entries(l.meta).map(([k, v]) => 'meta\t' + k + '\t' + v),
...l.files.map(f => 'file\t' + f.sha + '\t' + f.path),
...l.dirs.map(d => 'dir\t-\t' + d),
...l.blocks.map(b => 'block\t' + b.sha + '\t' + b.path)];
...l.blocks.flatMap(b => [...(b.pad ? ['blockpad\t' + b.pad + '\t' + b.path] : []), 'block\t' + b.sha + '\t' + b.path])];
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
fs.writeFileSync(file + '.tmp', rows.join('\n') + '\n', { mode: 0o600 });
fs.renameSync(file + '.tmp', file);
@@ -290,8 +303,10 @@ export interface RegisterOptions {
mode?: SkillsMode;
/** Destination override: the harness skills folder (--skills-dir). */
skillsDir?: string;
/** Context file for the routing block (generic). */
/** Context file for the routing block: written only into the file named here (--context). */
context?: string;
/** Refresh: context only identifies a generic registration; blocks already recorded are kept while they are still in their files, never re-added. */
inherit?: boolean;
checkout: string;
stateRoot: string;
env: Env;
@@ -336,8 +351,8 @@ function resolveDest(opts: RegisterOptions, id?: string): string | null {
return opts.context && id ? path.join(ledgerDir(opts.stateRoot), id) : null;
}
/** Desired files (absolute path -> bytes) and the router path, for a skill-folder or generic target. */
export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map<string, Buffer>; router: string | null; names: string[] } {
/** Desired files (absolute path -> bytes), the router path, the skill names the harness lists, the skills listed directly beside a router, and the skill count. */
export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map<string, Buffer>; router: string | null; visible: string[]; direct: string[]; skills: number } {
const t = opts.target;
const skills = sourceSkills(t, opts.checkout, scratch);
const files = new Map<string, Buffer>();
@@ -346,23 +361,27 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod
files.set(path.join(to, 'SKILL.md'), Buffer.from(flatFrontmatter(fs.readFileSync(path.join(s.dir, 'SKILL.md'), 'utf8'), path.basename(to))));
if (fs.existsSync(path.join(s.dir, 'sections'))) copyDir(path.join(s.dir, 'sections'), path.join(to, 'sections'));
};
const listed = (s: SkillEntry) => {
const to = path.join(dest, path.basename(s.dir));
if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
return t.render === 'host' ? s.name : path.basename(s.dir);
};
if (mode === 'all') {
for (const s of skills) {
const to = path.join(dest, path.basename(s.dir));
if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
}
return { files, router: null, names: skills.map(s => t.render === 'host' ? s.name : path.basename(s.dir)) };
const names = skills.map(listed);
return { files, router: null, visible: names, direct: [], skills: names.length };
}
const isDirect = (s: SkillEntry) => mode === 'hybrid' && HYBRID_SKILLS.includes(path.basename(s.dir).replace(/^gstack-/, ''));
const direct = skills.filter(isDirect).map(listed);
const routerDir = path.join(dest, 'gstack');
const entries = skills.map(s => {
const entries = skills.filter(s => !isDirect(s)).map(s => {
if (t.render !== 'host') return { name: s.name, use: s.use, path: path.join(s.dir, 'SKILL.md') };
const to = path.join(routerDir, 'skills', path.basename(s.dir));
copyDir(s.dir, to);
return { name: s.name, use: s.use, path: path.join(to, 'SKILL.md') };
});
const router = path.join(routerDir, 'SKILL.md');
files.set(router, Buffer.from(routerSkill(entries, t)));
return { files, router, names: ['gstack', ...entries.map(e => e.name)] };
files.set(router, Buffer.from(routerSkill(entries, t, direct)));
return { files, router, visible: ['gstack', ...direct], direct, skills: skills.length };
}
/** An edited file stays beside itself under a name no harness loads, so no uninstall step that removes gstack's folders can take it. */
@@ -389,25 +408,67 @@ function onDiskSha(file: string): string | null {
/** The ledger's hash of a context block: its lines, each ending in a newline, as the bash reader's awk prints them. */
export const blockSha = (block: string) => sha256(block + '\n');
/** The gstack block in a file: absent (no marker lines, or no file), owned (one marker pair hashing to sha) or other. */
export function blockState(file: string, sha: string): 'absent' | 'owned' | 'other' {
if (!fs.existsSync(file)) return 'absent';
const lines = fs.readFileSync(file, 'utf8').split('\n');
const at = (m: string) => lines.flatMap((l, i) => l === m ? [i] : []);
const [bs, es] = [at(BLOCK_BEGIN), at(BLOCK_END)];
if (!bs.length && !es.length) return 'absent';
return bs.length === 1 && es.length === 1 && es[0] > bs[0] && sha256(lines.slice(bs[0], es[0] + 1).join('\n') + '\n') === sha ? 'owned' : 'other';
}
/**
* The text of file with the block in it: replaces the one gstack block there (whole-line markers) when it is the block
* gstack wrote (ownedSha) or already current, else appends. A lone or repeated marker, or a block someone
* edited, is refused so nothing outside gstack's own lines is ever rewritten.
* The text of file with the block in it, and the pad gstack added before an appended block: replaces the one gstack
* block there (whole-line markers) when it is the block gstack wrote (ownedSha) or already current (pad ''), else
* appends. A lone or repeated marker, or a block someone edited, is refused so nothing outside gstack's own lines is
* ever rewritten.
*/
export function withBlock(file: string, block: string, ownedSha?: string): string {
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
export function withBlock(file: string, block: string, ownedSha?: string): { text: string; pad: string } {
const exists = fs.existsSync(file);
const text = exists ? fs.readFileSync(file, 'utf8') : '';
const lines = text.split('\n');
const at = (m: string) => lines.flatMap((l, i) => l === m ? [i] : []);
const [bs, es] = [at(BLOCK_BEGIN), at(BLOCK_END)];
let next: string;
if (!bs.length && !es.length) next = text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block + '\n';
else if (bs.length === 1 && es.length === 1 && es[0] > bs[0]) {
if (!bs.length && !es.length) {
const pad = !exists ? 'new' : !text ? '0' : text.endsWith('\n') ? '1' : '2';
return { text: text + (pad === '2' ? '\n\n' : pad === '1' ? '\n' : '') + block + '\n', pad };
}
if (bs.length === 1 && es.length === 1 && es[0] > bs[0]) {
const cur = sha256(lines.slice(bs[0], es[0] + 1).join('\n') + '\n');
if (cur !== blockSha(block) && cur !== ownedSha) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'the gstack block in ' + file + ' was edited or gstack did not write it; delete the lines from ' + BLOCK_BEGIN + ' to ' + BLOCK_END);
lines.splice(bs[0], es[0] - bs[0] + 1, ...block.split('\n'));
next = lines.join('\n');
} else throw new HarnessError('HARNESS_UNOWNED_ENTRY', file + ' has a gstack harness-routing marker line without exactly one partner; fix or delete the marker lines');
return next;
return { text: lines.join('\n'), pad: '' };
}
throw new HarnessError('HARNESS_UNOWNED_ENTRY', file + ' has a gstack harness-routing marker line without exactly one partner; fix or delete the marker lines');
}
/** Whether a context file belongs to the harness's own home (profile: every session) or to a project (sessions started there). */
export function contextScope(t: Target, file: string, env: Env, home: string): 'profile' | 'project' {
const harnessHome = t.name === 'hermes' ? env.HERMES_HOME || path.join(home, '.hermes') : t.name === 'openclaw' ? env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw') : null;
return harnessHome && path.resolve(file).startsWith(path.resolve(harnessHome) + path.sep) ? 'profile' : 'project';
}
/** Hermes loads one project context type, first match wins, walking to the git root (agent/prompt_builder.py build_context_files_prompt); within a group the first name in a folder wins, and the nearest .hermes.md/HERMES.md wins. */
const HERMES_CONTEXT_ORDER = [['.hermes.md', 'HERMES.md'], ['AGENTS.override.md', 'AGENTS.md', 'agents.md'], ['CLAUDE.md'], ['.cursorrules']];
/** Refuses SOUL.md (the harness's identity file), and for Hermes a new file that would shadow the rules it loads now. */
function assertContextFile(t: Target, file: string): void {
const refuse = (why: string) => { throw new HarnessError('HARNESS_CONTEXT_REFUSED', 'refusing to write the gstack block into ' + file + ': ' + why); };
if (path.basename(file).toLowerCase() === 'soul.md') refuse('SOUL.md is the harness\'s identity file');
if (t.name !== 'hermes' || fs.existsSync(file)) return;
const rank = HERMES_CONTEXT_ORDER.findIndex(g => g.includes(path.basename(file)));
if (rank < 0) return;
const group = HERMES_CONTEXT_ORDER[rank];
const later = HERMES_CONTEXT_ORDER.slice(rank + 1).flat();
const dirs = [path.dirname(file)];
for (let d = dirs[0]; !fs.existsSync(path.join(d, '.git')) && path.dirname(d) !== d; d = path.dirname(d)) dirs.push(path.dirname(d));
const chain = fs.existsSync(path.join(dirs.at(-1)!, '.git')) ? dirs : dirs.slice(0, 1);
for (const dir of chain) {
const own = dir === dirs[0] ? group.slice(group.indexOf(path.basename(file)) + 1) : rank === 0 ? group : [];
const shadowed = [...own, ...later].find(n => fs.existsSync(path.join(dir, n)));
if (shadowed) refuse('Hermes loads ' + path.basename(file) + ' instead of ' + path.join(dir, shadowed) + ', so creating it would hide those rules; name that file instead');
}
}
/** Runs fn from one of the checkout's sourced bash libraries, passing args through the environment. */
@@ -417,11 +478,6 @@ function sourcedBash(checkout: string, lib: string, fn: string, args: string[],
{ env: { ...process.env, ...env, ...argEnv, GSTACK_HARNESS_LIB: path.join(checkout, 'bin', lib), GSTACK_STATE_ROOT: stateRoot }, encoding: 'utf8', timeout: 60_000 });
}
/** Cuts a block this ledger recorded through the bash reader, so register and uninstall share one cutter. */
function cutBlock(opts: RegisterOptions, file: string, sha: string): void {
process.stdout.write(sourcedBash(opts.checkout, 'gstack-harness-ledger.sh', '_gstack_harness_cut_block', [file, sha], opts.stateRoot, opts.env).stdout || '');
}
function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boolean {
return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0;
}
@@ -437,14 +493,13 @@ export function register(opts: RegisterOptions): RegisterResult {
const id = registrationId(t.name, provisional);
const dest = resolveDest(opts, id)!;
assertSafeDest(dest, opts);
if (opts.context) assertSafeDest(path.resolve(opts.context), opts);
const explicit = opts.context && !opts.inherit ? path.resolve(opts.context) : '';
if (explicit) { assertSafeDest(explicit, opts); assertContextFile(t, explicit); }
const lfile = ledgerPath(opts.stateRoot, id);
const prev = readLedger(lfile);
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-'));
try {
const { files, router, names } = planPayload(opts, dest, mode, scratch);
const skills = mode === 'router' ? names.length - 1 : names.length;
const visible = mode === 'router' ? ['gstack'] : names;
const { files, router, visible, direct, skills } = planPayload(opts, dest, mode, scratch);
const prevFiles = new Map((prev?.files ?? []).map(f => [f.path, f.sha]));
const topOf = (p: string) => path.join(dest, path.relative(dest, p).split(path.sep)[0]);
const owned = new Set([...prevFiles.keys()].map(topOf));
@@ -463,13 +518,28 @@ export function register(opts: RegisterOptions): RegisterResult {
}
for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath(dest, p);
const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) }));
const ctx = opts.context ? path.resolve(opts.context) : '';
const block = opts.context ? contextBlock(router ?? '') : null;
if (block && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes');
const blocks = block ? [{ sha: blockSha(block), path: ctx }] : [];
const ctxText = block ? withBlock(ctx, block, prev?.blocks.find(b => b.path === ctx)?.sha) : null;
const context = opts.context ? path.resolve(opts.context) : prev?.meta.context ?? '';
const block = contextBlock(router, direct);
if ((explicit || prev?.blocks.length) && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes');
const blocks: LedgerBlock[] = [];
const blockWrites = new Map<string, string>();
for (const b of prev?.blocks ?? []) {
if (b.path === explicit) continue;
const state = blockState(b.path, b.sha);
if (state === 'absent') continue;
if (state === 'other') { blocks.push(b); continue; }
blockWrites.set(b.path, withBlock(b.path, block, b.sha).text);
blocks.push({ sha: blockSha(block), path: b.path, pad: b.pad });
}
if (explicit) {
const before = prev?.blocks.find(b => b.path === explicit);
const w = withBlock(explicit, block, before?.sha);
blockWrites.set(explicit, w.text);
blocks.push({ sha: blockSha(block), path: explicit, pad: w.pad || before?.pad || '' });
}
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
&& (prev.meta.context ?? '') === ctx && prev.blocks.every(b => blocks.some(n => n.path === b.path && n.sha === b.sha))
&& (prev.meta.context ?? '') === context && prev.blocks.length === blocks.length
&& blocks.every(b => prev.blocks.some(p => p.path === b.path && p.sha === b.sha && p.pad === b.pad))
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const kept: string[] = [];
@@ -478,16 +548,19 @@ export function register(opts: RegisterOptions): RegisterResult {
const dirs = new Set(prev?.dirs ?? []);
for (const p of files.keys()) for (let d = path.dirname(p); !fs.existsSync(d) && path.dirname(d) !== d; d = path.dirname(d)) dirs.add(d);
const record = (recorded: typeof want) => writeLedger(lfile, {
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(ctx ? { context: ctx } : {}) },
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(context ? { context } : {}) },
files: recorded, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks,
});
record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))]);
for (const b of prev?.blocks ?? []) if (b.path !== ctx) cutBlock(opts, b.path, b.sha);
for (const [p, sha] of prevFiles) {
if (files.has(p)) continue;
const disk = onDiskSha(p);
if (disk === sha) fs.rmSync(p, { force: true }); else if (disk) kept.push(keepEdit(p, stamp));
}
for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
if ([...files.keys()].some(p => p.startsWith(dir + path.sep))) continue;
try { fs.rmdirSync(dir); dirs.delete(dir); } catch {}
}
for (const [p, b] of files) {
const disk = onDiskSha(p);
if (disk === sha256(b)) continue;
@@ -499,9 +572,10 @@ export function register(opts: RegisterOptions): RegisterResult {
}
record(want);
}
if (ctxText !== null && ctxText !== (fs.existsSync(ctx) ? fs.readFileSync(ctx, 'utf8') : null)) {
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, ctxText);
for (const [file, text] of blockWrites) {
if (text === (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null)) continue;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, text);
}
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept };
@@ -536,7 +610,7 @@ export function refresh(base: Omit<RegisterOptions, 'target'>, skip: Set<string>
.map(({ ledger: l }) => register({
...base, target: getTarget(l.meta.target), mode: l.meta.mode as SkillsMode,
skillsDir: l.meta.context && l.meta.dest.startsWith(ledgerDir(base.stateRoot)) ? undefined : l.meta.dest,
context: l.meta.context || undefined,
context: l.meta.context || undefined, inherit: true,
}));
}
+18 -3
View File
@@ -41,6 +41,8 @@ export interface DecisionRow {
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
/** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */
owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision whose owner words this answer reuses, its gate_rev, why, who relayed them, items taken under "do it", and the delta check attempt. */
carried_gate_from?: string; carried_gate_rev?: number; carry_reason?: string; carried_answered_by?: string; carried_under?: string; carry_check?: string;
}
export interface TaskRow {
schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[];
@@ -69,6 +71,8 @@ export interface PregateCheck {
stage?: 'preflight' | 'tests'; inputs?: Record<string, string>; fix?: string; code?: string; lines?: string[];
// Lane D (GSTA-112): a non-pass names its cause; an environment cause carries its kind and evidence.
cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' };
/** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */
proven?: string[];
}
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
@@ -99,7 +103,14 @@ export interface ShipReceipt {
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; dispatches?: Record<string, number>; est_input_tokens?: number };
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] };
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
/** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
head_tree?: string; product_patch_id?: string;
/** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */
gstack_version?: string;
review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] };
/** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } };
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
@@ -161,6 +172,7 @@ export const ARTIFACT_SCHEMAS = {
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
answered_at: str, reply: str, source: str, cost: str,
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
carried_gate_from: str, carried_gate_rev: { type: 'integer', minimum: 1 }, carry_reason: str, carried_answered_by: str, carried_under: str, carry_check: str,
},
},
tasks: {
@@ -203,7 +215,7 @@ export const ARTIFACT_SCHEMAS = {
type: 'object', required: ['schema_version', 'tree', 'checks'],
properties: {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } } } } },
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } },
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str,
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
@@ -219,7 +231,10 @@ export const ARTIFACT_SCHEMAS = {
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList } },
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
head_tree: str, product_patch_id: str, gstack_version: str,
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } },
review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } },
issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } },
},
},
+3 -2
View File
@@ -23,7 +23,7 @@ function run(ctx: PregateContext): CheckResult {
fix: 'rerun with --allow-repo-commands (what /ship passes)', lines: commands.map(c => `COMMAND: ${c.id}=${JSON.stringify(c.command)} outputs=${c.outputs.join(',')}`),
};
}
const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), env: ctx.env, timeoutMs: ctx.timeoutMs });
const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), prove: ctx.touched.map(t => t.path), env: ctx.env, timeoutMs: ctx.timeoutMs });
const lines = results.map(renderEntry);
const stale = results.filter(r => r.status === 'stale');
const failed = results.filter(r => r.status === 'failed');
@@ -32,7 +32,8 @@ function run(ctx: PregateContext): CheckResult {
if (depsMissing.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_DEPS_MISSING', detail: `dependencies missing; ${depsMissing[0]!.fix} (${depsMissing.length} entr${depsMissing.length === 1 ? 'y' : 'ies'} unverified: ${depsMissing.map(d => d.id).join(', ')})`, fix: depsMissing[0]!.fix };
if (failed.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_COMMAND_FAILED', detail: `${failed.length} regen command(s) failed: ${failed.map(f => f.id).join(', ')}`, fix: failed.map(f => f.fix).join('; ') };
if (stale.length) return { id: 'regen', stage: 'preflight', status: 'fail', inputs, lines, code: 'REGEN_STALE', detail: `${stale.length} stale: ${stale.flatMap(s => s.changed.concat(s.outside)).join(' ')}`, fix: stale.map(s => s.fix).join('; ') };
return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
const proven = results.flatMap(r => r.proven ?? []).sort();
return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, ...(proven.length ? { proven } : {}), detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
}
export const regenCheck: CheckDef = { id: 'regen', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: true, run };
+1 -1
View File
@@ -321,7 +321,7 @@ export function explainConfig(): string {
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
+59 -2
View File
@@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean {
);
}
/**
* Bare integers that programs print as constants: 2^30 through 2^49 and each
* one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers,
* offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash,
* the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a,
* MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164
* reads each as a bare 10-15 digit number. Only the exact separator-free value
* is exempt: the same digits with a `+`, spaces, dashes or dots still report,
* so an E.164 number, which carries its `+`, is never exempt.
*/
const NUMERIC_CONSTANTS: ReadonlySet<string> = new Set([
...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]),
"2654435761",
"2654435769",
"1103515245",
"1013904223",
"2166136261",
"1540483477",
"3432918353",
"2246822507",
"3266489909",
"1812433253",
"4294967291",
]);
export function isNumericConstant(span: string): boolean {
return NUMERIC_CONSTANTS.has(span);
}
/** Context window for pairing a normalized parcel ID with its punctuated form. */
const PARCEL_CONTEXT_CHARS = 400;
@@ -446,6 +475,31 @@ export function isDotenvFilename(match: RegExpExecArray): boolean {
return spanStart > 0 && input[spanStart - 1] === ".";
}
/** A trailing run of file extensions that are not top-level domains (checked
* against the IANA root zone list, 2026-10-28). Extensions that are also TLDs
* (`.md`, `.py`, `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on
* purpose. */
const NON_TLD_EXTENSIONS = /(?:\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff))+$/i;
/**
* True when a pii.email span is a filename with an `@` in it rather than an
* address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`,
* `eslint-config@next.yaml`. The package@version convention puts a version or
* tag after the `@`, so either sign decides: the "domain" ends in file
* extensions no TLD uses and what precedes them cannot be a domain (one dotless
* label or a dotted version), or it is a dotted version (two or more all-digit
* labels) followed by an extension that doubles as a TLD (`a@1.2.md`). A file
* named after an address (`jane@acme.com.json`), `user@163.com`,
* `alice@1.2.com` and an address at a TLD that doubles as an extension
* (`dev@acme.sh`, `ops@acme.md`) still report.
*/
export function looksLikeAtFilename(span: string): boolean {
const domain = span.slice(span.lastIndexOf("@") + 1);
const stem = domain.replace(NON_TLD_EXTENSIONS, "");
if (stem !== domain) return !/\.[A-Za-z]{2,}$/.test(stem);
return /^\d+(?:\.\d+)+\.(?:md|py|sh|rs|so|zip|mov|map)$/i.test(domain);
}
/** Extensions that mark a `<name>.local.<ext>` span as a file. Closed on
* purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front
* of a public one, so after `.local` it can only be an extension. */
@@ -955,6 +1009,7 @@ export const PATTERNS: RedactPattern[] = [
regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/,
autoRedactable: true,
redactToken: "<REDACTED-EMAIL>",
validate: (span) => !looksLikeAtFilename(span),
// Engine layers the email allowlist (example.com, noreply@, user's own,
// repo-public authors) on top of this — see redact-engine.ts.
},
@@ -969,12 +1024,14 @@ export const PATTERNS: RedactPattern[] = [
// A digit-only UUID's hyphen groups read as national phone formatting, and
// so do a county tax-map parcel ID (see looksLikeParcelId), vector
// coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON
// values (isNumericMetadataValue), and GitHub Actions run and job ids in
// their id positions (isCiRunIdentifier).
// values (isNumericMetadataValue), GitHub Actions run and job ids in
// their id positions (isCiRunIdentifier), and bare powers of two and
// hash/PRNG constants (isNumericConstant).
validate: (span, match) =>
!insideUuid(match) &&
span.replace(/\D/g, "").length >= 10 &&
!looksLikeCompactTimestamp(span) &&
!isNumericConstant(span) &&
!looksLikeParcelId(span, match) &&
!looksLikeDecimalCoordinates(span) &&
!isNumericMetadataValue(span, match) &&
+19 -3
View File
@@ -164,6 +164,8 @@ export interface EntryResult {
exit?: number | null; stderr?: string; ms: number;
/** The exact command an author runs to refresh the entry. */
fix: string;
/** `check` with `prove`, on a fresh entry: the deleted outputs the proof run recreated byte-identically (an output it never writes, or a proof run that failed, proves nothing). */
proven?: string[];
}
/** Working tree vs the seeded index in a scratch worktree: modified tracked files plus new untracked ones. */
@@ -214,6 +216,8 @@ export interface RunRegenOptions {
only?: string[];
/** Restrict to entries whose inputs or outputs intersect these paths. */
touched?: string[];
/** `check`: outputs to prove on a fresh entry: a second run with each one deleted first, the ones it recreates byte-identically come back as `proven`. The verdict comes from the first run alone. */
prove?: readonly string[];
env?: NodeJS.ProcessEnv; timeoutMs?: number;
}
@@ -248,10 +252,22 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; }
if (o.mode === 'check') {
const differs = changed.length > 0 || outside.length > 0;
if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` });
else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
// Reset the scratch so the next entry's diff is its own.
git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']);
const reset = () => { git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); };
reset();
if (differs && install) { results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); continue; }
// The proof is a second run apart from the verdict: each touched output deleted first, the ones recreated
// byte-identically are proven, and a failure (a generator that reads its own output) proves nothing.
const probe = differs ? [] : (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p)));
let proven: string[] | undefined;
if (probe.length) {
for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true });
const proof = runCommand(cwd, e.command, o.env ?? process.env, timeout);
const redo = changedPaths(cwd);
proven = proof.exit === 0 ? probe.filter(p => !redo.includes(p)) : [];
reset();
}
results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(proven ? { proven } : {}) });
} else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
}
} finally { scratch?.remove(); }
+16 -2
View File
@@ -223,6 +223,19 @@ export function versionFiles(root: string, versionRel: string): string[] {
const dir = path.dirname(pkgRel) === '.' ? '' : path.dirname(pkgRel) + '/';
return [versionRel, pkgRel, ...NPM_LOCKFILES.map(l => dir + l)];
}
/**
* The manifest's path when the repo's convention is a four-part manifest: the policy lists it as a
* stamp file and the base's copy holds a four-part version (gbrain). Read at the base, like the
* policy, so a branch an older restamp cut to three parts heals. Otherwise null, and the writer
* stores the npm-valid three-part form (decision 11; gstack's own manifest).
*/
function manifestKeepsFourParts(plan: Plan, root: string): string | null {
const pkgRel = versionFiles(root, plan.versionRel)[1]!;
if (pkgRel === plan.versionRel || !matchesAny([...plan.policy.stamp_paths, ...plan.policy.stamp_files], pkgRel)) return null;
const atBase = git(root, ['show', `${plan.baseSha}:${pkgRel}`]);
const version = atBase.ok ? extractVersion(atBase.out, pkgRel) : '';
return version && versionWidth(version) === 4 ? pkgRel : null;
}
function allowedOutput(plan: Plan, root: string, rel: string): boolean {
const p = plan.policy;
if (versionFiles(root, plan.versionRel).includes(rel)) return true;
@@ -455,9 +468,10 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
const v = stage('version');
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
else {
const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
const full = manifestKeepsFourParts(plan, staging);
const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion, ...(full ? ['--manifest-form', 'full'] : [])], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
v.status = 'done'; v.detail = `wrote ${plan.newVersion}` + (full ? ` (${full} keeps four parts: a policy stamp file the base holds in four)` : '');
}
injected('version');
+47
View File
@@ -0,0 +1,47 @@
/**
* Result codes for the /ship token budget (bin/gstack-ship-budget, the
* reservation check in `gstack-review-log --start`) and the fresh-context
* handoff (lib/ship-handoff.ts). Kept beside lib/result-codes.ts, which spreads
* this table into RESULT_CODES; the same rules apply: one stable
* docs/troubleshooting.md anchor, a summary and a fix clause each. The flow is
* documented in docs/ship-budget.md.
*/
import type { ResultCode } from './result-codes';
export const BUDGET_RESULT_CODES = {
BUDGET_CAP_REACHED: {
anchor: 'budget-cap-reached',
summary: 'the next step or dispatch would push used + open reservations + its projection + the tail and stop reserve past the admission cap, so it did not start',
fix: 'the run stopped status=incomplete with a resumable record; raising the cap is the owner\'s call: run the printed RECOVER line, whose resume needs --approved-by <who> --words <their words>',
},
BUDGET_READING_MISSING: {
anchor: 'budget-reading-missing',
summary: 'the Capy meter needs a fresh counter reading and none was passed',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "this" }) in one execute program and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
},
BUDGET_READING_REJECTED: {
anchor: 'budget-reading-rejected',
summary: 'the reading does not fit the run\'s meter contract: it is lower than the last reading of the same epoch, it was passed to a meter that reads its own source, or --prior-reading is above --reading',
fix: 'pass the counter the run started with (the BUDGET line names meter, unit and epoch); a different meter starts a new epoch through gstack-ship-budget resume --meter <m>',
},
BUDGET_METER_UNAVAILABLE: {
anchor: 'budget-meter-unavailable',
summary: 'a cap is set and no meter is readable: no --reading, no bound Claude Code session and no --meter estimate',
fix: 'pass --reading <n> on Capy, --session <id> or --nonce <value> on Claude Code, or --meter estimate to accept the conservative estimate (printed meter=estimate)',
},
BUDGET_LEDGER_INVALID: {
anchor: 'budget-ledger-invalid',
summary: 'the budget ledger or the record\'s gstack-budget block is missing, unparseable, or from another schema version',
fix: 'restore it from the Drive checkpoint (gstack-ship-budget resume --run <id>) or rebuild it from the record with resume --record <path> --rebuild-from-record --reading <n>; open reservations are then charged as unknown',
},
BUDGET_CHECK_SKIPPED: {
anchor: 'budget-check-skipped',
summary: 'a dispatch under a cap started without an unspent reservation id from gstack-ship-budget check, or reused or mismatched one',
fix: 'run gstack-ship-budget check --run <id> --step <s> --next dispatch:<roles> before the dispatch and pass its RESERVATION id once; a reservation is never created after the fact, so an unreserved dispatch ends the run status=incomplete for the owner to accept',
},
HANDOFF_PIN_MOVED: {
anchor: 'handoff-pin-moved',
summary: 'the handoff brief no longer matches the tree it was written for: HEAD, the merge base, the working tree or the brief\'s own hash moved',
fix: 'the child returns status=refused; the parent regenerates the handoff at the current tree (gstack-ship-budget handoff ...) or stops; main moving alone never refuses',
},
} as const satisfies Record<string, ResultCode>;
+1 -1
View File
@@ -41,7 +41,7 @@ export const CAPY_RESULT_CODES = {
},
CAPTAIN_BRIEF_INCOMPLETE: {
anchor: 'captain-brief-incomplete',
summary: 'the plan lacks one or more of the sections the Captain brief needs (Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
summary: 'the plan lacks one or more of the sections the Captain brief needs (ELI5 on a run whose gate carries it, Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
fix: 'add the named sections to the plan, revise the run if the plan is under review, and render again; --allow-incomplete prints a preview that still says it is not ready',
},
PR_SHOTS_INPUT_NOT_FOUND: {
+24 -4
View File
@@ -148,6 +148,16 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
},
QUEUE_RECEIPT_DEPTH_RAISED: {
anchor: 'queue-receipt-depth-raised',
summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all',
fix: 'run /ship on the head the READY names; the REVIEW_DEPTH line shows the receipt’s depth, the required depth, its reasons and inputs, and the receipt is left unchanged',
},
QUEUE_RECEIPT_EXCEPTION_INVALID: {
anchor: 'queue-receipt-exception-invalid',
summary: 'the PR body’s /ship receipt records an owner exception (docs or review loop) that is malformed, lacks the fix-check it follows, or whose line the body no longer carries verbatim',
fix: 'restore the exception line in the PR body word for word, or run /ship again and record the owner’s acceptance with --docs or --review-loop-exception',
},
QUEUE_SCREEN_STALE_REF: {
anchor: 'queue-screen-stale-ref',
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
@@ -246,18 +256,28 @@ export const RELEASE_RESULT_CODES = {
},
RECEIPT_CARRY_REFUSED: {
anchor: 'receipt-carry-refused',
summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared',
fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head',
summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the working tree has uncommitted changes, the receipt head is not an ancestor of HEAD and the receipt records no tree to compare, or the product diff could not be compared',
fix: 'commit or stash working-tree changes and rerun carry; when history was rewritten and the receipt predates head_tree, run /ship on the new head (merge the base in instead of rebasing next time)',
},
PROSE_LINT_FOUND: {
anchor: 'prose-lint-found',
summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide',
fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file',
},
REVIEW_LOOP_EXCEPTION_INVALID: {
anchor: 'review-loop-exception-invalid',
summary: 'write --review-loop-exception got an exception that is malformed, follows no fix-check (review_loop.fix_checks is 0 or missing), names a fix-check head that is not the receipt head or its ancestor, or is missing from the PR body',
fix: 'pass { accepted_by, date, via, reason, fix_check: { head, findings } } with the owner’s words, the --review-loop summary that counts the fix-check, and the printed "Review-loop exception: …" line in the body',
},
RECEIPT_CARRY_TREE_MISMATCH: {
anchor: 'receipt-carry-tree-mismatch',
summary: 'the branch was rebased or force-pushed since its /ship receipt, and HEAD\'s tree is not a tree the receipt covers, so no review saw this code',
fix: 'run /ship on the new head; next time merge the base in instead of rebasing a reviewed branch (a rebase carries only onto a reviewed tree with the reviewed product diff)',
},
RECEIPT_CARRY_PRODUCT_CHANGED: {
anchor: 'receipt-carry-product-changed',
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD',
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata or the base merge and the product patch-id is unchanged',
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD',
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged',
},
DOCS_AUDIT_BLOCKED: {
anchor: 'docs-audit-blocked',
+3 -1
View File
@@ -12,6 +12,7 @@
*/
import { RELEASE_RESULT_CODES } from './result-codes-release';
import { CAPY_RESULT_CODES } from './result-codes-capy';
import { BUDGET_RESULT_CODES } from './result-codes-budget';
export interface ResultCode {
/** Stable `<a id>` in docs/troubleshooting.md. */
@@ -597,7 +598,7 @@ export const RESULT_CODES = {
},
UPGRADE_BUSY: {
anchor: 'upgrade-busy',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, so moving the checkout would change gstack under it',
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
},
UPGRADE_FETCH_FAILED: {
@@ -770,6 +771,7 @@ export const RESULT_CODES = {
},
...RELEASE_RESULT_CODES,
...CAPY_RESULT_CODES,
...BUDGET_RESULT_CODES,
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+137
View File
@@ -0,0 +1,137 @@
/**
* review-collect — the git reads behind a review depth (docs/review-policy.md#review-depth),
* kept out of lib/review-depth.ts so the policy stays pure. collectCommit reads git
* objects only (a range between two commits, from any checkout, dirty or not);
* collectLive reads the working tree as /ship reviews it (committed, tracked
* uncommitted edits and untracked files). Both return a ClassInput with per-path
* line counts, the base commit's generated-output registry and collector errors;
* the caller adds the gate-scan result.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GENERATED_PATH, parseRegistry } from './regen';
import { RESULT_CODES } from './result-codes';
import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth';
export interface Collected extends ClassInput {
perPath: PathLines[];
registry: RegistryInfo;
errors: DepthError[];
deleted: string[];
stampOnly: string[];
/** Added lines per path, for scanNumbers. */
added: Record<string, string[]>;
diffLines: number;
mergeBase: string | null;
untracked: string[];
}
function git(root: string, args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
const scratch = (p: string) => p.startsWith('.gstack/tmp/');
const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200);
const unavailable = (input: string, detail: string): RegistryInfo => ({ sha: null, outputs: [], error: { code: 'registry_unavailable', detail: oneLine(detail), input, repair: 'fetch the base branch (git fetch origin <base>) so its commit and registry resolve' } });
/** The registry as committed at `sha`: outputs of entries with a command (a `command: null` entry never excludes); a missing file is an empty registry. */
export function loadRegistryAt(root: string, sha: string): RegistryInfo {
const commit = git(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
if (!commit.ok) return unavailable(`${sha}:${GENERATED_PATH}`, `${sha} is not a commit in this repository`);
const resolved = commit.out.trim();
const input = `${resolved.slice(0, 12)}:${GENERATED_PATH}`;
if (!git(root, ['cat-file', '-e', `${resolved}:${GENERATED_PATH}`]).ok) return { sha: resolved, outputs: [] };
const show = git(root, ['show', `${resolved}:${GENERATED_PATH}`]);
if (!show.ok) return unavailable(input, show.err || 'git show failed');
const { registry, errors } = parseRegistry(show.out);
const e = errors[0];
if (e) return { sha: resolved, outputs: [], error: { code: 'registry_invalid', detail: oneLine(`${e.path || GENERATED_PATH}: ${e.message}`), input, repair: RESULT_CODES[e.code].fix, result: e.code } };
return { sha: resolved, outputs: registry.entries.filter(x => x.command !== null).flatMap(x => x.outputs) };
}
/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'added'> {
const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]);
const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]);
const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]);
const empty = { perPath: [], deleted: [], stampOnly: [], added: {} };
for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) {
if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' }); return empty; }
}
const perPath: PathLines[] = [];
for (const rec of numstat.out.split('\0').filter(Boolean)) {
const [a, d, ...rest] = rec.split('\t');
const p = rest.join('\t');
if (!scratch(p)) perPath.push({ path: p, lines: (Number(a) || 0) + (Number(d) || 0), binary: a === '-' && d === '-' });
}
const statusParts = status.out.split('\0');
const deleted = statusParts.flatMap((s, k) => (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p));
const added: Record<string, string[]> = {};
const changed: Record<string, string[]> = {};
let cur = '';
let minus = '';
let inHunk = false;
for (const line of patch.out.split('\n')) {
if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; }
if (!inHunk) {
if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, '');
else if (line.startsWith('+++ ')) cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, '');
else if (line.startsWith('@@')) inHunk = true;
continue;
}
if (!cur || scratch(cur) || line.startsWith('@@')) continue;
if (line[0] === '+') (added[cur] ??= []).push(line.slice(1));
if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
}
const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l)));
return { perPath, deleted, stampOnly, added };
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked'>): Collected {
const paths = diff.perPath.map(e => e.path).sort();
return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
}
/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */
export function collectCommit(root: string, baseSha: string, headSha: string): Collected {
const errors: DepthError[] = [];
const registry = loadRegistryAt(root, baseSha);
const mb = git(root, ['merge-base', baseSha, headSha]);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' });
return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] });
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase, headSha], errors);
const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]);
const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort();
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] });
}
/** The working tree against the merge base with origin/<base> (or <base>), untracked files counted per path; the registry is the base ref's commit. */
export function collectLive(root: string, base: string): Collected {
const errors: DepthError[] = [];
const ref = git(root, ['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]).ok ? `origin/${base}` : base;
const registry = loadRegistryAt(root, ref);
const mb = git(root, ['merge-base', ref, 'HEAD']);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] });
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase], errors);
const others = git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
if (!others.ok) errors.push({ code: 'numstat_failed', detail: oneLine(others.err || 'git ls-files failed'), input: 'git ls-files --others --exclude-standard', repair: 'run the command by hand and fix what git reports' });
const untracked = others.out.split('\0').filter(p => p && !scratch(p)).sort();
for (const p of untracked) {
let buf: Buffer;
try { buf = fs.readFileSync(path.join(root, p)); } catch { diff.perPath.push({ path: p, lines: 0, binary: true }); continue; }
const binary = buf.subarray(0, 8000).includes(0);
const text = binary ? '' : buf.toString('utf8');
const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
diff.perPath.push({ path: p, lines, binary });
if (!binary) diff.added[p] = text.split('\n');
}
return result(diff, { root, registry, errors, mergeBase, untracked });
}
+176 -43
View File
@@ -1,17 +1,22 @@
/**
* review-depth — the one policy owner for how much review a /ship run dispatches
* (docs/review-policy.md#review-depth). A pure function from the changed paths,
* gstack-diff-scope flags, the diff size, the gate scan and the pass history to a
* diff class, the next step of the loop and the reviewers that step dispatches,
* each with its reasoning-effort tier. bin/gstack-review-plan prints it; the
* (docs/review-policy.md#review-depth). A pure function from the changed paths and
* their line counts, gstack-diff-scope flags, the base registry's generated outputs,
* the gate scan and the pass history to a diff class, a review depth (light,
* standard, full), the next step of the loop and the reviewers that step
* dispatches, each with its effort tier and the pathspecs it leaves out. Git reads
* live in lib/review-collect.ts; bin/gstack-review-plan prints the plan; the
* review-army resolver and the skills read the same definitions.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ResultCodeName } from './result-codes';
import { matchesAny } from './ship-policy';
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
export type PathClass = 'docs' | 'tests' | 'code';
export type Depth = 'light' | 'standard' | 'full';
export type Effort = 'light' | 'full' | 'host';
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check';
export type Step = 'full' | 'fix-check' | 'done' | 'stop';
@@ -22,7 +27,12 @@ export const FULL_PASS_CAP = 2;
export const SMALL_DIFF_LINES = 50;
export const RED_TEAM_LINES = 200;
export const LARGE_DIFF_LINES = 100;
/** The checklists light's batched specialist always reads, whatever the scope. */
export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'maintainability', 'security'];
/** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */
export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)';
const CLASS_RANK: Record<DiffClass, number> = { docs: 0, tests: 1, code: 2, gate: 3 };
const DEPTH_RANK: Record<Depth, number> = { light: 0, standard: 1, full: 2 };
const AGENT_PROSE = [/(^|\/)SKILL\.md(\.tmpl)?$/, /(^|\/)sections\//, /^scripts\/resolvers\//, /^docs\/[^/]*-policy\.md$/, /^docs\/(captain|unattended|pregate)\.md$/, /(^|\/)DRIVER\.md(\.tmpl)?$/, /(^|\/)templates\//, /(^|\/)(AGENTS|CLAUDE|ETHOS)\.md$/, /(^|\/)specialists\//, /(^|\/)checklist\.md$/, /^\.gstack\//];
/** gstack-diff-scope's TEST_PATH_RE (the test-bootstrap rule), plus fixture directories. */
@@ -30,19 +40,41 @@ const TEST_PATH = [/(^|\/)(tests?|spec|__tests__)\/|(^|\/)tests?\.py$|(^|\/)test
const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
/** Release metadata a version stamp writes: never a reason for review depth on its own. */
const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//];
const GATE_PATH = [/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/];
/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */
const GATE_PATH = [
/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/,
/^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
];
/** One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs, templates, any markdown in a skill directory under `root`) is code: it is gstack's product. */
export function classifyPath(p: string, root?: string): PathClass {
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED,
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
/**
* One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs,
* templates, any markdown in a skill directory) is code: it is gstack's product. `root` is the repository root
* (skill directories probed on disk) or the top-level skill directory names read from a commit.
*/
export function classifyPath(p: string, root?: string | readonly string[]): PathClass {
if (RELEASE_METADATA.some(r => r.test(p))) return 'docs';
if (AGENT_PROSE.some(r => r.test(p))) return 'code';
if (TEST_PATH.some(r => r.test(p))) return 'tests';
if (!DOC_PATH.test(p)) return 'code';
const top = p.includes('/') ? p.split('/')[0]! : '';
if (root && top && ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f)))) return 'code';
return 'docs';
if (!top || !root) return 'docs';
if (typeof root === 'string') return ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f))) ? 'code' : 'docs';
return root.includes(top) ? 'code' : 'docs';
}
export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path';
/** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */
export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName }
export interface PathLines { path: string; lines: number; binary: boolean }
/** The base commit's `.gstack/generated.json`: outputs of entries with a command only; `error` when it could not be read or validated. */
export interface RegistryInfo { sha: string | null; outputs: readonly string[]; error?: DepthError }
export interface ClassInput {
paths: readonly string[];
/** gstack-diff-scope output (SCOPE_* keys). */
@@ -53,23 +85,38 @@ export interface ClassInput {
deleted?: readonly string[];
/** Paths whose whole change is a version stamp (package.json's version key): classified as docs. */
stampOnly?: readonly string[];
/** The repository root: markdown inside a skill directory there is code. */
root?: string;
/** The repository root, or the skill directory names at the reviewed commit: markdown inside a skill directory is code. */
root?: string | readonly string[];
/** Changed lines per path; without it a code diff counts every line as runtime. */
perPath?: readonly PathLines[];
registry?: RegistryInfo;
/** Collector failures (a diff git could not list): each fails the depth closed. */
errors?: readonly DepthError[];
}
export interface ClassResult { class: DiffClass; reasons: string[] }
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null }
/** The diff's class: the most restrictive of its paths, raised to gate by any gate signal; errors fail closed. */
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root));
/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
export function classifyDiff(i: ClassInput): ClassResult {
const reasons: string[] = [];
const errors: DepthError[] = [...(i.errors ?? [])];
let cls: DiffClass = 'docs';
const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); };
if (!i.paths.length) raise('docs', 'no changed paths');
for (const p of i.paths) { const c = (i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
const s = i.scope ?? {};
if (s.SCOPE_ERROR) raise('code', `scope error: ${s.SCOPE_ERROR}`);
if (s.SCOPE_ERROR) {
raise('code', `scope error: ${s.SCOPE_ERROR}`);
errors.push({ code: 'scope_error', detail: sanitize(s.SCOPE_ERROR), input: 'gstack-diff-scope <base>', repair: 'run gstack-diff-scope <base> by hand and fix what it reports' });
}
if (CLASS_RANK[cls] >= CLASS_RANK.tests) {
if (i.gateTagged === null) raise('gate', 'gate scan unavailable');
else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
if (i.gateTagged === null) {
raise('gate', 'gate scan unavailable');
errors.push({ code: 'gate_scan_unavailable', detail: 'gstack-gate-diff reported no GATE_SUMMARY', input: 'gstack-gate-diff <base>', repair: 'run gstack-gate-diff <base> and read its GATE_ERROR line, or pass the recorded scan as --gate-tagged <n>' });
} else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
const deletedGuarded = (i.deleted ?? []).filter(p => classifyPath(p, i.root) !== 'docs');
if (deletedGuarded.length) raise('gate', `deletes ${deletedGuarded.length} code or test path(s)`);
}
@@ -77,8 +124,38 @@ export function classifyDiff(i: ClassInput): ClassResult {
if (s.SCOPE_AUTH === 'true') raise('gate', 'auth paths');
if (s.SCOPE_MIGRATIONS === 'true') raise('gate', 'migrations');
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
if (i.registry?.error) errors.push(i.registry.error);
}
return { class: cls, reasons };
if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null };
let runtimeLines = 0;
for (const e of i.perPath) {
if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue;
if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" });
else runtimeLines += e.lines;
}
return { class: cls, reasons, errors, runtimeLines };
}
/** Any classifier error or gate class is full; runtime above SMALL_DIFF_LINES is standard; otherwise light. A floor only raises. */
export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; floor?: Depth }): Depth {
const d: Depth = o.errors.length || o.cls === 'gate' ? 'full' : o.runtimeLines > SMALL_DIFF_LINES ? 'standard' : 'light';
return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d;
}
export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string }
function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult {
const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors });
const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), `runtime=${runtimeLines}${runtimeLines > SMALL_DIFF_LINES ? '>' : '<='}${SMALL_DIFF_LINES}`];
if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`);
const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))];
return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES };
}
/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */
export function depthOfCommitInput(input: ClassInput & { floor?: Depth }): DepthResult {
const c = classifyDiff(input);
return decideDepth(c, c.runtimeLines ?? 0, input.floor);
}
// ---------------------------------------------------------------------------
@@ -153,34 +230,48 @@ export interface PlanInput extends ClassInput {
force?: readonly string[] | 'all';
/** Specialists the hit-rate stats tag GATE_CANDIDATE. */
gated?: readonly string[];
/** A specialist produced a CRITICAL finding (Red Team activation on code class). */
/** A specialist produced a CRITICAL finding (Red Team activation on code class; raises light to standard). */
criticalFound?: boolean;
/** Step 11's native adversarial pass already ran in this invocation. */
adversarialRan?: boolean;
/** The numeric-claim fingerprint the outside voice last reviewed; a change reruns it. */
reviewedNumbers?: string;
/** --depth-floor: raises the depth, never lowers it. */
floor?: Depth;
}
export interface Dispatch { role: Role; name: string; effort: Effort }
export interface ReviewPlan { class: DiffClass; reasons: string[]; next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[] }
/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what light's batched specialist reads. */
export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string }
export interface ReviewPlan extends DepthResult {
/** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */
stepDepth: Depth;
depthReasons: string[];
next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[];
}
const scopeWants: Partial<Record<Specialist, (s: Record<string, string>) => boolean>> = {
performance: s => s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true',
'data-migration': s => s.SCOPE_MIGRATIONS === 'true',
'api-contract': s => s.SCOPE_API === 'true',
design: s => s.SCOPE_FRONTEND === 'true',
'state-machines': s => s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined,
};
const forcedBy = (i: PlanInput) => (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n));
/** The specialists a full pass selects for a class: class sets the eligible set, hit-rate gating applies inside it, gate class ignores gating. */
export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] {
const s = i.scope ?? {};
const forced = (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n));
const forced = forcedBy(i);
if (cls === 'docs') { skipped.push('specialists (docs class)'); return SPECIALISTS.filter(forced); }
if (cls === 'tests') { skipped.push('specialists except testing (tests class)'); return SPECIALISTS.filter(n => n === 'testing' || forced(n)); }
const want = (n: Specialist): boolean => {
if (forced(n)) return true;
if (n === 'state-machines') return s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined;
if (n === 'state-machines') return scopeWants[n]!(s);
if (i.diffLines < SMALL_DIFF_LINES) return false;
switch (n) {
case 'testing': case 'maintainability': return true;
case 'security': return s.SCOPE_AUTH === 'true' || (s.SCOPE_BACKEND === 'true' && i.diffLines > LARGE_DIFF_LINES);
case 'performance': return s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true';
case 'data-migration': return s.SCOPE_MIGRATIONS === 'true';
case 'api-contract': return s.SCOPE_API === 'true';
case 'design': return s.SCOPE_FRONTEND === 'true';
case 'simplification': return i.diffLines > LARGE_DIFF_LINES;
default: return scopeWants[n]!(s);
}
};
return SPECIALISTS.filter(n => {
@@ -190,34 +281,76 @@ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] =
});
}
/** The reviewers the next step dispatches, with each role's effort tier. */
/** A path as one git exclude pathspec; a glob character or a leading colon makes it literal. */
const excludeSpec = (p: string) => `:(exclude${/[*?[\]\\]/.test(p) || p.startsWith(':') ? ',literal' : ''})${p}`;
const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
/** The diff command a dispatched reviewer runs; `<diff-base>` is the literal sha the parent writes in. */
export function diffCommand(d: Dispatch): string {
return d.exclude.length ? `git diff <diff-base> -- . ${d.exclude.map(shellQuote).join(' ')}` : 'git diff <diff-base>';
}
/** The reviewers the next step dispatches at its depth, with each role's effort tier and excluded pathspecs. */
export function planReview(i: PlanInput): ReviewPlan {
const { class: cls, reasons } = classifyDiff(i);
const c = classifyDiff(i);
const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor);
const { class: cls, runtimeLines } = depth;
const next = nextStep(i.history);
const h = i.history;
const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
const stepDepth: Depth = raise ? 'standard' : depth.depth;
const depthReasons = [...depth.reasons, ...(raise ? [`history:${h.fixedBlocking ? 'fixed-blocking' : h.blockingOpen ? 'blocking-open' : 'critical-found'}`] : [])];
const skipped: string[] = [];
const dispatches: Dispatch[] = [];
const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial<Dispatch> = {}) => dispatches.push({ role, name, effort, exclude, ...extra });
const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec);
const tests = i.paths.filter(p => pathClass(i, p) === 'tests' && !isOutput(i, p)).map(excludeSpec);
const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint;
if (next.step === 'full') {
for (const n of specialistsFor(cls, i, skipped)) dispatches.push({ role: 'specialist', name: n, effort: 'full' });
const redTeam = cls === 'gate' || (cls === 'code' && (i.diffLines > RED_TEAM_LINES || !!i.criticalFound));
if (redTeam) dispatches.push({ role: 'red-team', name: 'red-team', effort: 'full' });
else skipped.push(`red-team (${cls} class${cls === 'code' ? `, ${i.diffLines} lines, no critical` : ''})`);
if (i.history.fullPasses === 0 || (!i.adversarialRan && (cls === 'code' || cls === 'gate'))) {
if (cls === 'docs' || cls === 'tests') skipped.push(`adversarial-native (${cls} class)`);
else dispatches.push({ role: 'adversarial-native', name: 'adversarial', effort: 'full' });
if (cls === 'code' || cls === 'gate' || i.numbers.publishes) dispatches.push({ role: 'outside-voice', name: 'outside-voice', effort: 'host' });
else skipped.push(`outside-voice (${cls} class, no numeric claims)`);
const firstVoices = h.fullPasses === 0 || !i.adversarialRan;
if (next.step === 'full' && stepDepth === 'light') {
const forced = forcedBy(i);
if (runtimeLines > 0) {
const scoped = SPECIALISTS.filter(n => !LIGHT_FIXED.includes(n) && (forced(n) || (scopeWants[n]?.(i.scope ?? {}) && !(i.gated ?? []).includes(n))));
add('specialist', 'batched', 'full', docsAndOutputs, { checklists: [...LIGHT_FIXED, ...scoped, 'red-team'] });
skipped.push('red-team (light depth: the batched specialist carries the Red Team prompt)');
if (firstVoices) add('adversarial-native', 'adversarial', 'full', [], { reason: LIGHT_ADVERSARIAL_REASON });
} else {
for (const n of SPECIALISTS.filter(forced)) add('specialist', n, 'full');
skipped.push('specialists, red-team and adversarial-native (light depth, runtime 0)');
}
if (h.fullPasses === 0 && i.numbers.publishes) add('outside-voice', 'outside-voice', 'host');
else if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
else skipped.push(`outside-voice (${cls} class, no numeric claims)`);
} else if (next.step === 'full') {
const full = stepDepth === 'full';
const dcls: DiffClass = full ? 'gate' : cls;
const raised = !full && runtimeLines <= SMALL_DIFF_LINES;
const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0);
for (const n of specialistsFor(dcls, { ...i, diffLines: lines }, skipped)) add('specialist', n, 'full', full ? [] : n === 'testing' ? docsAndOutputs : [...docsAndOutputs, ...tests]);
const redTeam = dcls === 'gate' || (dcls === 'code' && (lines > RED_TEAM_LINES || !!i.criticalFound || raised));
if (redTeam) add('red-team', 'red-team', 'full');
else skipped.push(`red-team (${dcls} class${dcls === 'code' ? `, ${lines} runtime lines, no critical` : ''})`);
const codeLike = dcls === 'code' || dcls === 'gate';
if (h.fullPasses === 0 || (!i.adversarialRan && codeLike)) {
if (codeLike) add('adversarial-native', 'adversarial', 'full');
else skipped.push(`adversarial-native (${dcls} class)`);
if (codeLike || i.numbers.publishes) add('outside-voice', 'outside-voice', 'host');
else skipped.push(`outside-voice (${dcls} class, no numeric claims)`);
} else {
skipped.push('adversarial-native and outside-voice (run once per invocation)');
if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' });
if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
}
} else if (next.step === 'fix-check') {
dispatches.push({ role: 'fix-check', name: 'fix-check', effort: 'full' });
if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' });
add('fix-check', 'fix-check', 'full');
if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
}
return { class: cls, reasons, next, numbers: i.numbers, dispatches, skipped };
return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped };
}
export function reviewPlanLine(p: ReviewPlan, pass: number): string {
return `REVIEW_PLAN: class=${p.class} pass=${pass} next=${p.next.step} dispatches=${p.dispatches.length} numbers=${p.numbers.publishes ? `yes(${p.numbers.claims})` : 'no'} fingerprint=${p.numbers.fingerprint}`;
}
export function reviewDepthLine(p: ReviewPlan): string {
return `REVIEW_DEPTH: ${p.depth} runtime_lines=${p.runtimeLines} rules=${p.rules} reasons=${p.depthReasons.join(',')} codes=${p.codes.join(',') || 'none'}${p.stepDepth !== p.depth ? ` step=${p.stepDepth}` : ''}`;
}
+45 -19
View File
@@ -9,6 +9,8 @@
* answer_markers regexes for a quoted sealed answer in added text
* seed_patterns regexes for a sampling seed in added text
* local_paths true: a local home path (/home/<u>/, /Users/<u>/) in added text
* allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
* case_insensitive true: `paths` and `transcripts` match regardless of case
* visibility "public" | "private": the destination override (reviewed on the base branch)
*
* The publication boundary is every commit the push publishes: the local sha
@@ -18,8 +20,9 @@
* copies, independent of content (binary, empty, add-then-delete); text rules
* see each commit's added lines. The effective policy is the union of the
* destination base branch's `sealed` and the pushed one (a push can tighten,
* never relax); waivers come only from the base branch's
* `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* never relax); `allow_paths` exceptions come only from the base branch's
* policy and remove path findings before waivers, which come only from the
* base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* closed). Visibility: the base override, else a cache under the state root
* (a `public` answer for 24 h, a `private` one for 1 h before it may
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
@@ -28,19 +31,20 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
import { containedRelative, globToRegExp } from './ship-policy';
import { resolveStateRoot } from './state-root';
export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' }
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' }
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
export type ExemptFinding = SealedFinding & { glob: string };
export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
export type Visibility = 'public' | 'private' | 'unknown';
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility'];
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility'];
const MAX_ENTRIES = 64;
const MAX_REGEX = 256;
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
@@ -50,9 +54,9 @@ const PRIVATE_TTL_MS = 3600_000;
/** `<key>: <message>` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */
export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
const errors: Array<{ path: string; message: string }> = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }];
const o = raw as Record<string, unknown>;
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` });
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` });
const list = (key: string, check: (s: string) => string | null) => {
if (!(key in o)) return;
const v = o[key];
@@ -62,8 +66,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
};
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } };
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex);
if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' });
const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); };
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' });
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
return errors;
}
@@ -88,15 +93,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string)
const errors = sealedErrors(raw.sealed);
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
const s = raw.sealed;
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label };
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label };
}
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */
/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
if (!base && !pushed) return null;
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])];
const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility };
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
}
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
@@ -126,12 +131,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string
const HEX = /^[0-9a-f]{40,64}$/;
/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
}
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */
function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
if (!p.paths.length && !p.transcripts.length) return [];
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n');
if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
const out: SealedFinding[] = [];
const transcript = globMatcher(p.transcripts, p.case_insensitive);
const sealedPath = globMatcher(p.paths, p.case_insensitive);
const tokens = r.out.split('\0');
let commit = '';
for (let i = 0; i < tokens.length; i++) {
@@ -141,8 +154,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
const renamed = /^[RC]\d*$/.test(t);
const file = renamed ? tokens[i += 2]! : tokens[++i]!;
if (!renamed && !t.includes('A')) continue;
if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit });
else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit });
if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit });
}
return out;
}
@@ -216,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>;
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
visibility: Visibility; visibilitySource: string; target: string; error?: string;
}
@@ -225,15 +238,20 @@ export interface EvaluateOptions {
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
}
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
export function sealedErrorReport(error: string, target: string): SealedReport {
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
export function evaluateSealed(o: EvaluateOptions): SealedReport {
const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array<SealedFinding & { reason: string }>, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target };
const blank = { ...sealedErrorReport('', o.target), error: undefined };
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' };
if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` };
let policy: SealedPolicy | null = base.policy;
const labels = base.policy ? [base.label] : [];
for (const t of o.targets) {
const pushed = sealedPolicyAt(o.repoRoot, t.pushedSha, `pushed ${t.pushedSha.slice(0, 12)}`);
const pushed = sealedPolicyAt(o.repoRoot, t.pushedSha, `commit ${t.pushedSha.slice(0, 12)}`);
if ('error' in pushed) return { ...blank, status: 'error', error: `the sealed policy in ${pushed.label} is unreadable: ${pushed.error}` };
if (pushed.policy) labels.push(pushed.label);
policy = unionPolicies(policy, pushed.policy);
@@ -243,6 +261,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
let found: SealedFinding[];
try { found = scanCommits(o.repoRoot, commits, policy); }
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
const exception = globMatcher(policy.allow_paths, false);
const exempt: ExemptFinding[] = [];
found = found.filter(f => {
const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
if (glob) exempt.push({ ...f, glob });
return !glob;
});
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = [];
const findings = found.filter(f => {
@@ -252,7 +277,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
});
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
}
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
@@ -260,6 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$
/** stderr text for a report: findings one per line (`<rule> <file[:line]> commit <sha12>`), never the matched text. */
export function renderSealed(r: SealedReport, context: string): string {
const lines: string[] = [];
for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
@@ -268,7 +294,7 @@ export function renderSealed(r: SealedReport, context: string): string {
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'refused') {
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'warned') {
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
}
+77
View File
@@ -0,0 +1,77 @@
{
"schema_version": 1,
"note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only.",
"headroom": 1.5,
"round_to": 10000,
"observations": {
"per_call": [
{
"run": "gstack-evals#19",
"value": 240000,
"source": "GSTA-152 plan v3 evidence: late parent calls charged 0.22M then 0.24M (solved from the 29.3M and 16.3M counter windows; derived)"
},
{
"run": "gstack-evals#26",
"value": 400000,
"source": "GSTA-152 plan v3 evidence: late turns cost about 0.4M each (root counter read after every step)"
},
{
"run": "gstack-evals#31",
"value": 500000,
"source": "GSTA-152 plan v3 evidence: the /ship child's context was about 0.5M per call at the handoff"
}
],
"per_dispatch": [
{
"run": "gstack-evals#19",
"value": 2400000,
"source": "GSTA-152 plan v3 evidence: about 2.4M per child, solved from the two counter windows (derived)"
},
{
"run": "gstack-evals#31",
"value": 4950000,
"source": "GSTA-152 plan v3 evidence: the /ship child and its 10 review dispatches account for about 49.5M (4-5M per dispatch)"
}
],
"stop_overhead": [
{
"run": "gstack-evals#26",
"value": 1650000,
"source": "GSTA-152 plan v3 evidence: the stop message and the parent's checks took the counter from 39.40M to 41.05M"
}
]
},
"constants": {
"per_call": 750000,
"per_dispatch": 7430000,
"stop_overhead": 2480000
},
"step_calls": {
"7": 12,
"8": 12,
"9": 12,
"default": 3
},
"step_dispatches": {
"7": 1,
"8": 1,
"14.5": 1
},
"segment_dispatches": {
"9": 4,
"11": 2
},
"units": {
"capy-tokens": {
"fit": "observations above (Capy root counter, children included)"
},
"claude-usage-tokens": {
"seeded_from": "capy-tokens",
"why": "no Claude Code-metered /ship yet; refit once one completes"
},
"estimate-tokens": {
"seeded_from": "capy-tokens",
"why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs"
}
}
}
+299
View File
@@ -0,0 +1,299 @@
/**
* 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 };
+410
View File
@@ -0,0 +1,410 @@
/**
* 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 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;
export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check'] as const satisfies readonly Role[];
type MissingRole = Exclude<Role, typeof DISPATCH_ROLES[number]>;
const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true;
void ROLES_COMPLETE;
/** D3: per-call charge 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;
/** Segment boundaries (before Step 7, before Step 12) and the last step each segment runs. */
export const SEGMENTS: Record<string, string> = { '1': '22', '7': '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: 'root' | '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" }).cost.tokens', scope: 'root', 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;
}
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;
}
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;
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>;
units: Record<string, { fit?: string; seeded_from?: string; 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 function stepProjection(cal: Calibration, unit: string, step: string): number {
const k = unitConstants(cal, unit);
return (cal.step_calls[step] ?? cal.step_calls.default ?? 0) * k.per_call + (cal.step_dispatches[step] ?? 0) * k.per_dispatch;
}
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);
}
/** Steps from..to inclusive plus their usual reviewer batches: a child's segment estimate and the RECOVER tail. */
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);
}
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;
}
export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string {
const t = tally(l);
return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${l.tail + l.stop_overhead} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} verdict=${f.verdict}`;
}
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' };
l.reservations.push(row);
return row;
}
/** Settle at completion: the meter already holds the spend; estimate and unseen charges are booked here. */
export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration } = {}): void {
if (r.state === 'settled' || r.state === 'released') return;
r.state = 'settled';
r.settled_at = new Date().toISOString();
if (opts.unknown) { l.unknown += r.tokens; 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);
}
/** 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';
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} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}`;
}
/** 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. */
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)`);
return block.ledger;
}
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) { 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.closed_at ? l : undefined;
}
/**
* gstack-review-log --start under a cap: the dispatch needs an unspent, one-shot
* dispatch reservation of the active run, 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);
if (r.state !== 'open') 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-shot); ${fix}`), 1);
const out = capture();
r.state = 'consumed';
r.token = out.split('\n')[0]!.trim();
r.started_at = new Date().toISOString();
return out;
});
}
/** gstack-review-log --finish TOKEN: settle the reservation that start consumed (its completion receipt). */
export function settleReviewFinish(marker: string, token: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const active = activeLedger(marker);
if (!active || !active.reservations.some(r => r.token === token && r.state === 'consumed')) return undefined;
return mutateLedger(active.file, env, l => {
const r = l.reservations.find(x => x.token === token && x.state === 'consumed');
if (!r) return undefined;
settleIn(l, r);
return r.id;
});
}
/** close's audit: review-log rows of this run with no reservation 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 reservedStarts = new Set(l.reservations.filter(r => r.started_at).map(r => r.token));
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;
const startFile = l.reservations.find(r => r.started_at && r.token && Math.abs(Date.parse(r.started_at) - Date.parse(started)) < 5_000);
if (!startFile || !reservedStarts.has(startFile.token)) 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 };
}
+245
View File
@@ -0,0 +1,245 @@
/**
* ship-handoff — fresh-context delegation for /ship (GSTA-152 D3, build-spec r2
* item 3). The parent writes a brief (mode 0600, under its run directory) with
* the pins computed from git at handoff time, the invocation record and its
* start tokens, the decisions in scope with the owner's words, the decision
* trail, the budget and the return contract; its sha256 goes in the parent's
* ledger with a reservation for the child's whole sub-cap. The child verifies
* the brief before anything else and admits against its sub-cap with
* child_used = reading - reading_at_handoff; it never delegates again. It
* returns at most 40 lines through a return file; the parent reconciles once
* per handoff hash (a duplicate return charges nothing twice) and reads the
* record, never the child's transcript.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { resolveClaudeCommand } from './claude-bin';
import { wtreeFingerprint } from './script-invocation';
import { resultLine } from './result-codes';
import {
SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger,
} from './ship-budget';
import { loadMeterState, saveMeterState } from './claude-usage';
import { parseArgs, readMeter, stopLines, str, tokensOpt, writeReturnFile, type Ctx, type Outcome } from './ship-budget-cli';
export const RECORD_HEADINGS = ['Release', 'Decisions', 'Reviews', 'Checks', 'Documentation', 'Next steps'] as const;
export const RETURN_MAX_LINES = 40;
const sha256 = (text: string | Buffer) => crypto.createHash('sha256').update(text).digest('hex');
function git(cwd: string, args: string[]): string | null {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
return r.status === 0 ? r.stdout.trim() : null;
}
export function pinsNow(cwd: string, baseRef: string, env: NodeJS.ProcessEnv): { head: string | null; merge_base: string | null; wtree: string | null } {
const w = wtreeFingerprint({ cwd, env, timeoutMs: 60_000 });
return { head: git(cwd, ['rev-parse', 'HEAD']), merge_base: git(cwd, ['merge-base', 'HEAD', baseRef]), wtree: 'tree' in w ? w.tree : null };
}
/** One record section's lines, from its heading to the next of the six. */
export function recordSection(text: string, name: string): string[] | undefined {
const heading = (line: string) => RECORD_HEADINGS.find(h => new RegExp(`^\\s*(?:[-*]\\s*)?(?:#+\\s*)?(?:\\*\\*)?${h}(?:\\*\\*)?:?(?:\\*\\*)?(?:\\s|$)`).test(line));
const lines = text.split('\n');
const at = lines.findIndex(line => heading(line) === name);
if (at < 0) return undefined;
const end = lines.findIndex((line, i) => i > at && heading(line) !== undefined);
return lines.slice(at, end < 0 ? lines.length : end);
}
function brief(l: Ledger, h: Omit<Handoff, 'sha'>, pins: Record<string, unknown>, recordText: string, o: Record<string, string | undefined>): string {
const reviews = recordSection(recordText, 'Reviews') ?? [];
const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])];
const block = { version: 1, parent_run: l.run, parent_ledger: l.file, from: h.from, to: h.to, sub_cap: h.sub_cap, parent_tail: l.tail,
unit: l.meter.contract.unit, meter: l.meter.contract.meter, epoch: h.epoch, reading_at: h.reading_at, ...pins, record: l.record,
return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens };
return [
`# /ship handoff ${h.n}: Steps ${h.from}-${h.to}`, '',
'```gstack-handoff', JSON.stringify(block, null, 2), '```', '',
'## Start', '',
`1. Run \`gstack-ship-budget verify-handoff --in <this file>\` (on Capy add \`--reading <n>\`) before anything else. On a refusal, return its lines and stop.`,
block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} <your run id>\` before any trail write.` : '2. No decision trail was named.',
`3. Run /ship --continue <this file> for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. You never delegate; a delegate rule stops you.`,
block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '',
'## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '',
'## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '',
'## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}.`, '',
'## Return contract', '',
`Finish with \`gstack-ship-budget return --run <your child run> --to <last step done> --changed <record sections you changed>\`. It writes ${h.return_file} (at most ${RETURN_MAX_LINES} lines: the steps done, new HEAD and tree, the record sections changed, the BUDGET line and GSTACK_RESULT) and prints the same lines. The parent reads the record, never your transcript.`, '',
].join('\n');
}
export function cmdHandoff(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'from', 'to', 'base', 'reviewed-tree', 'trail', 'trail-run', 'sub-cap', 'reading', 'prior-reading', 'child-session']);
const from = parseStep(str(o, 'from'), '--from'), to = parseStep(str(o, 'to'), '--to');
if (SHIP_STEPS.indexOf(from as any) > SHIP_STEPS.indexOf(to as any)) throw usageError(`--from ${from} is after --to ${to}`);
const base = str(o, 'base');
if (!base) throw usageError('handoff needs --base origin/<base> (the merge base is pinned against it)');
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
if (l.role === 'child') throw usageError('a child never delegates again; stop instead');
if (!l.record || !fs.existsSync(l.record)) throw usageError(`the run has no invocation record at ${l.record}`);
const recordText = fs.readFileSync(l.record, 'utf8');
const missing = RECORD_HEADINGS.filter(h => !recordSection(recordText, h));
if (missing.length) throw usageError(`record ${l.record} lacks headings: ${missing.join(', ')}; fix: keep the six headings the ship skill names`);
const child = str(o, 'child-session');
if (child && l.meter.contract.meter === 'claude') {
l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), child])];
const state = loadMeterState(path.join(l.dir, 'claude-meter.json'));
if (state) { state.sessions = l.meter.sessions; saveMeterState(path.join(l.dir, 'claude-meter.json'), state); }
}
const call = readMeter(l, o, true);
const cal = loadCalibration(l.calibration);
const t = tally(l);
const room = l.cap === null ? Infinity : l.cap - t.used - t.reserved - t.unknown - l.tail - l.stop_overhead;
const subCap = tokensOpt(o, 'sub-cap') ?? Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to));
if (subCap <= 0 || subCap > room) return { stdout: stopLines(l, { call, next: from, step: from, projected: subCap, reason: 'budget_exhausted' }), exit: 3 };
const pins = pinsNow(ctx.cwd, base, ctx.env);
const reviewed = str(o, 'reviewed-tree') ?? null;
const r = reserveIn(l, { kind: 'child', step: from, next: `child:${from}-${to}`, tokens: subCap });
const n = l.handoffs.length + 1;
const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) };
const text = brief(l, draft, { ...pins, base_ref: base, reviewed_tree: reviewed }, recordText, { trail: str(o, 'trail'), trailRun: str(o, 'trail-run'), launch: ctx.env.GSTACK_LAUNCH_FILE });
fs.writeFileSync(draft.file, text, { mode: 0o600 });
const sha = sha256(text);
l.handoffs.push({ ...draft, sha });
return { stdout: [`HANDOFF: ${draft.file} sha256=${sha} sub_cap=${subCap}`, `RETURN_FILE: ${draft.return_file}`, budgetLine(l, { call, next: from, projected: subCap, verdict: 'delegate' })], exit: 0 };
});
}
function handoffBlock(text: string): any {
const m = /```gstack-handoff\n([\s\S]*?)\n```/.exec(text);
try { return m ? JSON.parse(m[1]!) : undefined; } catch { return undefined; }
}
export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['in', 'reading', 'session']);
const file = str(o, 'in');
if (!file) throw usageError('verify-handoff needs --in <brief>');
const text = fs.readFileSync(file, 'utf8');
const block = handoffBlock(text);
if (!block?.parent_ledger) throw budgetError('BUDGET_LEDGER_INVALID', `${file}: no gstack-handoff block`);
const parent = readLedger(block.parent_ledger);
const sha = sha256(text);
const h = parent.handoffs.find(x => x.sha === sha);
const moved: string[] = [];
if (!h) moved.push(`brief sha256 expected ${parent.handoffs.find(x => x.file === path.resolve(file))?.sha ?? 'a recorded handoff'} actual ${sha}`);
else if (h.reconciled_at) moved.push(`brief already reconciled at ${h.reconciled_at}`);
const now = pinsNow(ctx.cwd, block.base_ref, ctx.env);
for (const k of ['head', 'merge_base', 'wtree'] as const) if (block[k] !== now[k]) moved.push(`${k} expected ${block[k]} actual ${now[k]}`);
if (block.reviewed_tree && git(ctx.cwd, ['cat-file', '-t', block.reviewed_tree]) !== 'tree') moved.push(`reviewed_tree expected ${block.reviewed_tree} actual missing`);
if (moved.length) {
const lines = [`RETURN: handoff=${sha} status=refused steps=${block.from}-${block.to} head=${now.head} tree=${now.wtree}`,
...moved.map(m => resultLine('gstack-ship-budget', 'HANDOFF_PIN_MOVED', m)), `GSTACK_RESULT: skill=ship status=refused run=${parent.dir}`];
writeReturnFile(block.return_file, lines);
return { stdout: lines, exit: 3 };
}
const run = mintRunId();
const dir = runDirFor(ctx.env, run);
const child: Ledger = {
schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: path.join(dir, 'budget.json'), record: block.record, created_at: new Date().toISOString(),
cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: 'never', role: 'child',
parent: { run: parent.run, ledger: parent.file, handoff_sha: sha, from: block.from, to: block.to, return_file: block.return_file },
meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? block.reading_at ?? 0 : 0, last: null,
...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) },
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration,
};
if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session <this child session id>');
readMeter(child, o, true);
writeLedger(child, ctx.env);
pointMarker(child.marker, child);
return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`, budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 };
}
export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'to', 'changed', 'status', 'reading']);
const to = parseStep(str(o, 'to'), '--to');
const status = str(o, 'status') ?? 'complete';
if (!['complete', 'refused', 'incomplete'].includes(status)) throw usageError(`--status '${status}' is not complete, refused or incomplete`);
return mutateLedger(ledgerFileFor({ run: str(o, 'run') }, ctx.env), ctx.env, l => {
if (l.role !== 'child' || !l.parent) throw usageError(`run ${l.run} is not a child run`);
readMeter(l, o, false);
const head = git(ctx.cwd, ['rev-parse', 'HEAD']);
const tree = pinsNow(ctx.cwd, 'HEAD', ctx.env).wtree;
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${status} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
`GSTACK_RESULT: skill=ship status=${status} run=${l.dir}`];
writeReturnFile(l.parent.return_file, lines);
l.closed_at = new Date().toISOString();
return { stdout: lines, exit: 0 };
});
}
/** Parse a child's return: at most 40 lines with RETURN and GSTACK_RESULT; undefined when it is not a return. */
export function parseReturn(text: string): { handoff: string; status: string; to: string; used: number | null } | undefined {
const lines = text.split('\n').filter(Boolean);
if (lines.length > RETURN_MAX_LINES) return undefined;
const ret = /^RETURN: handoff=([0-9a-f]{64}) status=(\w+) steps=[\d.]+-([\d.]+)/.exec(lines[0] ?? '');
if (!ret || !lines.some(line => line.startsWith('GSTACK_RESULT: skill=ship status='))) return undefined;
const used = /^BUDGET: used=(\d+)/m.exec(text);
return { handoff: ret[1]!, status: ret[2]!, to: ret[3]!, used: used ? Number(used[1]) : null };
}
export function cmdReconcile(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['run', 'record', 'return', 'handoff', 'reading', 'prior-reading']);
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
const retFile = str(o, 'return');
const text = retFile && fs.existsSync(retFile) ? fs.readFileSync(retFile, 'utf8') : undefined;
const parsed = text ? parseReturn(text) : undefined;
const sha = parsed?.handoff ?? str(o, 'handoff') ?? l.handoffs.find(x => x.return_file === (retFile && path.resolve(retFile)))?.sha;
const h = l.handoffs.find(x => x.sha === sha);
if (!h) throw usageError(`no handoff ${sha ?? '(none named)'} in run ${l.run}; pass --return <file> or --handoff <sha256>`);
const exitFor = (s: string | undefined) => (s === 'complete' ? 0 : 3);
if (h.reconciled_at) {
return { stdout: [`RECONCILED: handoff=${h.sha} child=${h.status} child_used=${h.child_used} (already reconciled at ${h.reconciled_at}; nothing charged twice)`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' })], exit: exitFor(h.status) };
}
const own = !parsed && fs.existsSync(h.return_file) ? parseReturn(fs.readFileSync(h.return_file, 'utf8')) : undefined;
const ret = parsed ?? (own?.handoff === h.sha ? own : undefined);
const call = readMeter(l, o, true);
const metered = h.reading_at !== null && h.epoch === l.meter.epoch && l.meter.last !== null;
const childUsed = metered ? Math.max(0, l.meter.last! - h.reading_at!) : ret?.used ?? h.sub_cap;
const r = l.reservations.find(x => x.id === h.reservation);
if (r && (r.state === 'open' || r.state === 'consumed')) { r.state = 'settled'; r.settled_at = new Date().toISOString(); }
if (l.meter.contract.meter === 'estimate') l.estimate_used += childUsed;
h.status = ret && ret.to !== h.to && ret.status === 'complete' ? 'incomplete' : ret?.status ?? 'interrupted';
h.child_used = childUsed;
h.reconciled_at = new Date().toISOString();
pointMarker(l.marker, l);
const after = SHIP_STEPS[SHIP_STEPS.indexOf(h.to as any) + 1] ?? '-';
const lines = [`RECONCILED: handoff=${h.sha} child=${h.status} child_used=${childUsed}`, budgetLine(l, { call, next: h.status === 'complete' ? after : h.from, projected: 0, verdict: h.status === 'complete' ? 'continue' : 'stop' })];
if (h.status !== 'complete') lines.push(`NEXT: the child returned ${h.status}; regenerate the handoff (gstack-ship-budget handoff --run ${l.run} --from ${h.from} --to ${h.to} --base origin/<base>) or stop`);
return { stdout: lines, exit: exitFor(h.status) };
});
}
/** Explicit permissions for a headless child (build-spec r2 item 7, M1): edits and the shell, nothing broader. */
export const CHILD_PERMISSION_ARGS = ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash,Read,Edit,Write,Grep,Glob'];
/** Claude Code: launch the child as a background headless session that answers through the return file. */
export function cmdLaunch(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['in']);
const file = str(o, 'in');
if (!file) throw usageError('launch needs --in <brief>');
const block = handoffBlock(fs.readFileSync(file, 'utf8'));
if (!block?.parent_ledger) throw budgetError('BUDGET_LEDGER_INVALID', `${file}: no gstack-handoff block`);
const claude = resolveClaudeCommand(ctx.env);
if (!claude) throw usageError('no claude binary resolves (PATH, GSTACK_CLAUDE_BIN); launch the child by hand or rerun with --delegate never');
const session = crypto.randomUUID();
const prompt = `Load gstack. Your Claude Code session id is ${session}. Run /ship --continue ${path.resolve(file)}: first gstack-ship-budget verify-handoff --in ${path.resolve(file)} --session ${session}, then only the steps the brief names, then gstack-ship-budget return as its return contract says.`;
const log = path.join(path.dirname(block.return_file), `child-${session}.log`);
return mutateLedger(block.parent_ledger, ctx.env, l => {
if (l.meter.contract.meter === 'claude') {
l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), session])];
const meter = path.join(l.dir, 'claude-meter.json');
const state = loadMeterState(meter);
if (state) { state.sessions = l.meter.sessions; saveMeterState(meter, state); }
}
const out = fs.openSync(log, 'a', 0o600);
const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] });
child.unref();
fs.closeSync(out);
return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 };
});
}
+3 -3
View File
@@ -22,7 +22,7 @@ import { baseOverlapLine } from './pr-wait';
import { versionAtRef, versionFiles } from './restamp';
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
import { loadBasePolicy, type QueueContext } from './ship-queue';
import { bindReceipt } from './ship-receipt';
import { bindShippedReceipt } from './ship-receipt-depth';
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen';
import { stackTestLine, stackTests } from './ship-queue-stack-tests';
@@ -351,8 +351,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
}
const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]);
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
const shipped = bindShippedReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid]);
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
let screen = ready.screen;
+4 -2
View File
@@ -31,7 +31,8 @@ import type { PregateFile } from './headless-artifacts';
import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
import type { ResultCodeName } from './result-codes';
import { bindReceipt, extractReceipt } from './ship-receipt';
import { extractReceipt } from './ship-receipt';
import { bindShippedReceipt } from './ship-receipt-depth';
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
import {
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
@@ -254,7 +255,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`;
} else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`;
}
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
const lines = [
@@ -268,6 +269,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
pregateLine,
gateLine(policy, attempt),
receipt.line,
...(receipt.depthLine ? [receipt.depthLine] : []),
];
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
if (gate) lines.push(gate.line);
+164
View File
@@ -0,0 +1,164 @@
/**
* ship-receipt-depth — what the /ship receipt says about how deeply the
* review read the head, and what the queue checks against it
* (docs/review-policy.md#review-depth, docs/ship-policy.md#ship-receipt).
*
* `review_depth`: the depth the reviewed head needs, recomputed by
* `gstack-ship-receipt write` (receiptDepthAtHead: the committed range from
* git objects, gstack-diff-scope on that range and the gate scan of the clean
* checkout at that head), with the runtime lines, the rules hash, the base and
* registry commits and the gate scan bound to the head.
* `review_loop_exception`: the owner's acceptance of a stop after a
* fix-check edit under frozen scope (#3559), shaped like the docs exception.
* The queue recomputes the depth the head needs from git objects
* (lib/review-collect.ts) and refuses a receipt below it. Kept beside
* lib/ship-receipt.ts so that module stays under the size ratchet.
*/
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
import type { ShipReceipt } from './headless-artifacts';
import { collectCommit } from './review-collect';
import { classifyPath, depthOfCommitInput, type Depth } from './review-depth';
import { runScriptSync } from './script-invocation';
import { bindReceipt, type ReceiptBindingCode } from './ship-receipt';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
export type ReceiptDepth = NonNullable<ShipReceipt['review_depth']>;
export type ReviewLoopException = NonNullable<ShipReceipt['review_loop_exception']>;
const RANK: Record<Depth, number> = { light: 0, standard: 1, full: 2 };
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v);
const sha = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v);
const BIN_DIR = path.join(import.meta.dir, '..', 'bin');
const short = (s: string | null | undefined) => (s ? s.slice(0, 12) : 'none');
function git(root: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
}
/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue. */
function scopeOfRange(root: string, binDir: string, base: string, head: string): Record<string, string> {
const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
const scope: Record<string, string> = {};
for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; }
if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`;
return scope;
}
/**
* The depth record for `head`: the range from git objects (collectCommit) and
* its diff-scope, as the queue recomputes them, plus, for a tests or code diff,
* the gate scan of this checkout. The scan reads the working tree, so it counts
* only when the checkout is `head` with no tracked edits; otherwise it is
* unavailable and the depth fails closed to full.
*/
export function receiptDepthAtHead(repoRoot: string, base: string, binDir: string, head: string): ReceiptDepth {
const branch = base.replace(/^origin\//, '');
const baseSha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${branch}^{commit}`]);
const baseRev = baseSha.ok ? baseSha.out : branch;
const c = collectCommit(repoRoot, baseRev, head);
const scope = scopeOfRange(repoRoot, binDir, baseRev, head);
let tagged: number | null = 0;
if (c.paths.some(p => classifyPath(p, c.root) !== 'docs')) {
const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok;
const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) : null;
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? '');
tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
}
const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged });
return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) };
}
export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need];
export const deeper = (a: Depth, b: Depth): Depth => (RANK[a] >= RANK[b] ? a : b);
/** `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the fix-check it followed. */
export function parseReviewLoopException(value: unknown): { ok: true; exception: ReviewLoopException } | { ok: false; message: string } {
const bad = (message: string) => ({ ok: false as const, message });
if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_loop_exception must be { accepted_by, date, via, reason, fix_check: { head, findings[, files] } }');
const v = value as Record<string, unknown>;
for (const key of Object.keys(v)) if (!['accepted_by', 'date', 'via', 'reason', 'fix_check'].includes(key)) return bad(`review_loop_exception: unknown key ${JSON.stringify(key)}`);
for (const key of ['accepted_by', 'date', 'via', 'reason']) if (!text(v[key])) return bad(`review_loop_exception.${key} must be a non-empty one-line string`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(v.date as string)) return bad('review_loop_exception.date must be YYYY-MM-DD');
const f = v.fix_check as Record<string, unknown> | null;
if (!f || typeof f !== 'object' || Array.isArray(f)) return bad('review_loop_exception.fix_check must be { head, findings[, files] }');
for (const key of Object.keys(f)) if (!['head', 'findings', 'files'].includes(key)) return bad(`review_loop_exception.fix_check: unknown key ${JSON.stringify(key)}`);
if (!sha(f.head)) return bad('review_loop_exception.fix_check.head must be the full commit the fix-check reviewed');
if (typeof f.findings !== 'number' || !Number.isInteger(f.findings) || f.findings < 0) return bad('review_loop_exception.fix_check.findings must be an integer >= 0 (what the fix-check reported)');
if (f.files !== undefined && (!Array.isArray(f.files) || !f.files.every(text))) return bad('review_loop_exception.fix_check.files must be a list of paths');
const files = (f.files as string[] | undefined)?.slice(0, 50);
return { ok: true, exception: { accepted_by: (v.accepted_by as string).trim(), date: v.date as string, via: (v.via as string).trim(), reason: (v.reason as string).trim(), fix_check: { head: f.head, findings: f.findings, ...(files?.length ? { files } : {}) } } };
}
/** The line the PR body must carry, word for word, while the exception stands. */
export function reviewLoopExceptionLine(e: ReviewLoopException): string {
return `Review-loop exception: accepted by ${e.accepted_by} ${e.date} via ${e.via}: ${e.reason} (fix-check at ${e.fix_check.head.slice(0, 12)}, ${e.fix_check.findings} finding${e.fix_check.findings === 1 ? '' : 's'})`;
}
const carries = (body: string, line: string) => body.split(/\r?\n/).some(l => l.trim() === line);
/**
* The owner exceptions a receipt records, checked the same way: valid shape, and the body carries each line verbatim.
* `isAncestor`, when given, also requires the review-loop exception's fix-check head to be the receipt head or its ancestor.
*/
export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (commit: string) => boolean): string[] {
const out: string[] = [];
const raw = r as ShipReceipt & { docs?: unknown };
if (raw.docs !== undefined) {
const d = parseReceiptDocs(raw.docs);
if (!d.ok) out.push(`docs: ${d.message}`);
else { const line = docsExceptionLine(d.docs); if (line && !carries(body, line)) out.push(`the body does not carry the documentation exception line verbatim: ${JSON.stringify(line)}`); }
}
if (r.review_loop_exception !== undefined) {
const e = parseReviewLoopException(r.review_loop_exception);
if (!e.ok) out.push(e.message);
else {
if (!r.review_loop?.fix_checks) out.push('review_loop_exception needs review_loop.fix_checks >= 1 (the stop it accepts follows a fix-check)');
if (isAncestor && !isAncestor(e.exception.fix_check.head)) out.push(`review_loop_exception.fix_check.head ${e.exception.fix_check.head.slice(0, 12)} is not the receipt head or an ancestor of it`);
const line = reviewLoopExceptionLine(e.exception);
if (!carries(body, line)) out.push(`the body does not carry the review-loop exception line verbatim: ${JSON.stringify(line)}`);
}
}
return out;
}
export interface DepthVerdict { ok: boolean; line: string; message?: string }
/**
* The depth the bound head needs against `baseRef`, recomputed from git
* objects; the recorded gate scan counts only when it names the receipt's
* head, otherwise the scan is unavailable and the head needs full. A receipt
* with no review_depth and no gstack_version predates depth receipts and is
* accepted as legacy; one from a writer that records depth but lacks it is not.
*/
export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string): DepthVerdict {
const rd = r.review_depth;
if (!rd && !r.gstack_version) return { ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' };
const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
const baseRev = baseSha.ok ? baseSha.out : baseRef;
const c = collectCommit(root, baseRev, head);
const scanBound = !!rd && rd.gate_scan.tagged !== null && /^[0-9a-f]{40,64}$/.test(rd.gate_scan.head) && rd.gate_scan.head === head.toLowerCase();
const need = depthOfCommitInput({ ...c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), gateTagged: scanBound ? rd!.gate_scan.tagged : null });
const have = rd?.depth;
const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`;
const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`;
if (have && !depthBelow(have, need.depth)) return { ok: true, line };
const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one';
const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; ');
return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` };
}
/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept. */
export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]):
| { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string }
| { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string } {
const b = bindReceipt(body, pr, accepted);
if (!b.ok) return b;
const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok;
const problems = exceptionProblems(body, b.receipt, ancestor);
if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line };
const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head);
if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line };
return { ...b, depthLine: d.line };
}
+52 -10
View File
@@ -17,7 +17,8 @@ export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry',
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'carry',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -118,6 +119,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`);
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
if (r.review_depth && typeof r.review_depth.depth === 'string') parts.push(`depth=${r.review_depth.depth}`);
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
const loop = parseReviewLoop(r.review_loop);
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
@@ -150,7 +152,8 @@ export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md
/** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */
export const CARRY_NAMED_PATHS = 20;
export type CarryClass = 'product' | 'test' | 'metadata';
/** `generated`: an output of a base-registry entry with a command, which the pregate for HEAD proved its command recreates (its inputs are product). */
export type CarryClass = 'product' | 'test' | 'metadata' | 'generated';
export interface CarryPlanInput {
/** The reviewed head the receipt names. */
from: string; head: string;
@@ -163,9 +166,13 @@ export interface CarryPlanInput {
changedProduct: string[];
mergeCommits: number;
rangeDiff: string;
/** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */
retargetedFrom?: string;
}
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string };
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string };
const CARRY_COVERS = ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'];
const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'];
/**
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
@@ -206,9 +213,10 @@ export function stampOnlyText(change: CarryChange): boolean {
* the tree receipt. `change` carries the texts; a caller without them gets the
* list-only answer.
*/
export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[] } = {}, change?: CarryChange): CarryClass {
export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[]; outputs?: readonly string[] } = {}, change?: CarryChange): CarryClass {
const stamp = () => !change || (stampFileKind(rel) ? versionStampOnly(rel, change.before, change.after).stampOnly : stampOnlyText(change));
if (matchesAny(CARRY_METADATA_GLOBS, rel)) return !change || !stampFileKind(rel) || versionStampOnly(rel, change.before, change.after).stampOnly ? 'metadata' : 'product';
if (matchesAny(extra.outputs ?? [], rel)) return 'generated';
if (matchesAny(extra.metadata ?? [], rel)) return stamp() ? 'metadata' : 'product';
if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test';
return 'product';
@@ -226,18 +234,49 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
}
const reasons: string[] = [];
const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : [];
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata');
if (input.changed.some(c => c.class === 'generated')) reasons.push('regenerated outputs');
if (!reasons.length) reasons.push(input.changed.length ? 'no product change' : 'no content change');
return {
ok: true,
carry: {
from: input.from, reason: reasons.join(' + '), product_patch_id: input.productPatchId.head || 'none',
changed_paths: input.changed.slice(0, 50).map(c => `${c.class}:${c.path}`), merge_commits: input.mergeCommits, range_diff: input.rangeDiff,
covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'],
reruns: ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'],
covers: CARRY_COVERS, reruns: CARRY_RERUNS, ancestry: 'linear',
},
};
}
export interface TreeCarryInput {
from: string; head: string;
/** HEAD^{tree}, and every tree the receipt already covers (its head_tree plus earlier carries' trees). */
headTree: string; coveredTrees: readonly string[];
/** The product patch-id against the base the receipt recorded, and the one HEAD has against the current base. */
productPatchId: { recorded: string; head: string };
}
/**
* The carry across rewritten history (a rebase or force-push): the receipt
* follows HEAD only when HEAD's tree is one the receipt already covers and the
* product diff against the current base has the patch-id the receipt recorded.
* The tree alone is not enough: a stacked branch rebased onto main after its
* base closed unmerged keeps its tree while its diff grows by the base's code.
*/
export function planTreeCarry(input: TreeCarryInput): CarryPlan {
if (!input.coveredTrees.includes(input.headTree)) {
return { ok: false, code: 'RECEIPT_CARRY_TREE_MISMATCH', message: `the receipt's head ${input.from.slice(0, 12)} is not an ancestor of HEAD ${input.head.slice(0, 12)} (rebased or force-pushed), and HEAD's tree ${input.headTree.slice(0, 12)} is not a tree the receipt covers (${input.coveredTrees.map(t => t.slice(0, 12)).join(', ') || 'none recorded'}); the receipt is unchanged: run /ship on ${input.head.slice(0, 12)}` };
}
if (input.productPatchId.recorded !== input.productPatchId.head) {
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `HEAD ${input.head.slice(0, 12)} has the reviewed tree, but its product diff against the current base differs from the one reviewed at ${input.from.slice(0, 12)} (the base itself moved under it); the receipt is unchanged: run /ship on ${input.head.slice(0, 12)}` };
}
return {
ok: true,
carry: {
from: input.from, reason: 'rebase onto a reviewed tree', product_patch_id: input.productPatchId.head || 'none',
changed_paths: [], merge_commits: 0, range_diff: 'tree-equal', covers: CARRY_COVERS, reruns: CARRY_RERUNS, ancestry: 'rewritten',
},
};
}
@@ -247,14 +286,17 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
* recorded. A receipt carried before keeps the reviewed head as `from` and adds
* this hop's merges and paths, so the record always names the head a review saw.
*/
export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord): ShipReceipt {
export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord, at: { tree: string; fromTree?: string; productPatchId: string }): ShipReceipt {
const prior = receipt.carry;
const merged: CarryRecord = !prior ? carry : {
...carry, from: prior.from, reason: prior.reason === carry.reason ? carry.reason : `${prior.reason} + ${carry.reason}`,
merge_commits: prior.merge_commits + carry.merge_commits,
changed_paths: [...prior.changed_paths, ...carry.changed_paths.filter(p => !prior.changed_paths.includes(p))].slice(0, 50),
ancestry: prior.ancestry === 'rewritten' ? 'rewritten' : carry.ancestry,
};
return orderReceipt({ ...receipt, head, carry: merged });
const fromTree = receipt.head_tree ?? at.fromTree;
const trees = [...new Set([...(prior?.trees ?? []), ...(fromTree ? [fromTree] : [])])].filter(t => t !== at.tree).slice(-20);
return orderReceipt({ ...receipt, head, head_tree: at.tree, product_patch_id: at.productPatchId || 'none', carry: { ...merged, ...(trees.length ? { trees } : {}) } });
}
/** The loop counters the factory scorecard reads (passes, blocking_found, deferred), as `write --review-loop` records them. */
+85
View File
@@ -0,0 +1,85 @@
/**
* Trigger-first skill wording for harness listings (GSTA-135 skill use).
*
* Frozen rule, built only from each skill's own frontmatter, and the trigger
* part always comes first:
* - Router lines (lib/harness-register.ts, 160 chars): the description's own
* trigger sentence (its first sentence that starts "Use when", "Use to",
* "Use for", ...), followed by the description's first sentence when both
* fit. A description with no trigger sentence (the catalog-trimmed Claude
* render) uses the triggers list below instead.
* - Hermes descriptions (scripts/gen-skill-docs.ts, hosts/hermes.ts): a lead
* of at most 57 chars, the part Hermes's skills index shows: "Use for: "
* and the skill's `triggers:` list in frontmatter order, as many whole
* entries as fit; without triggers, the trigger sentence (or first
* sentence) cut at a word. The description's own trigger sentence, which
* the list repeats, is dropped, so a render does not grow.
*/
const TRIGGER_SENTENCE = /^Use (when|whenever|to|for|after|before|on|if)\b/;
export const normalizeDescription = (description: string) => description.replace(/\s*\(gstack\)\s*$/, '').replace(/\s+/g, ' ').trim();
export const sentences = (description: string): string[] => normalizeDescription(description).split(/(?<=[.!?])\s+/).filter(Boolean);
/** Cuts at the last word boundary that leaves room for "...". */
export function cutWords(text: string, max: number): string {
if (text.length <= max) return text;
const cut = text.slice(0, max - 3);
const space = cut.lastIndexOf(' ');
return (space > max / 3 ? cut.slice(0, space) : cut).replace(/[\s,;:]+$/, '') + '...';
}
/** The description's own trigger sentence, or ''. */
export const triggerSentence = (description: string) => sentences(description).find(s => TRIGGER_SENTENCE.test(s)) ?? '';
/** "Use for: a, b, c." from a skill's triggers list, as many whole entries as fit in max chars ('' without triggers). */
export function triggerList(triggers: string[], max: number): string {
const items = triggers.map(t => t.replace(/\s+/g, ' ').trim()).filter(Boolean);
if (!items.length) return '';
let lead = 'Use for: ' + items[0] + '.';
for (const t of items.slice(1)) {
const next = lead.slice(0, -1) + ', ' + t + '.';
if (next.length > max) break;
lead = next;
}
return cutWords(lead, max);
}
/** A trigger-first lead of at most max chars: the triggers list, else the trigger sentence or first sentence cut at a word. */
export function triggerLead(description: string, triggers: string[], max: number): string {
return triggerList(triggers, max) || cutWords(triggerSentence(description) || sentences(description)[0] || '', max);
}
/** A description led by triggerLead on its own first line; the description's trigger sentence is dropped when the lead comes from the triggers list, which that sentence repeats. */
export function withTriggerLead(description: string, triggers: string[], max: number): string {
const lead = triggerLead(description, triggers, max);
const sentence = triggerSentence(description);
if (!sentence || !triggerList(triggers, max)) return lead + '\n' + description;
const tag = /\(gstack\)\s*$/.test(description) ? ' (gstack)' : '';
return lead + '\n' + sentences(description).filter(s => s !== sentence).join(' ') + tag;
}
/** A router line of at most max chars: the trigger part first, then the description's first sentence when both fit. */
export function routerLine(description: string, triggers: string[], max = 160): string {
const first = sentences(description)[0] ?? '';
const trigger = triggerSentence(description) || triggerList(triggers, max);
if (!trigger) return cutWords(first, max);
const both = trigger + ' ' + first;
return first && first !== trigger && both.length <= max ? both : cutWords(trigger, max);
}
/** The values of a YAML block list field (`field:` then ` - value` lines) in a SKILL.md's frontmatter. */
export function frontmatterList(md: string, field: string): string[] {
const fm = md.startsWith('---\n') ? md.slice(4, md.indexOf('\n---', 4)) : '';
const lines = fm.split('\n');
const i = lines.findIndex(l => l.trimEnd() === field + ':');
if (i < 0) return [];
const out: string[] = [];
for (const l of lines.slice(i + 1)) {
const m = /^\s*-\s+(.*)$/.exec(l);
if (!m) break;
out.push(m[1].trim().replace(/^["']|["']$/g, ''));
}
return out;
}