v1.93.1.0 stamp: merge main 9d9d4c31bb into #3865 (land)

This commit is contained in:
garrytan committed 2026-11-02 14:50:07 +00:00
commit da34529dae
155 files changed
+7490 -703

No files matched your search

+5 -4
View File
@@ -321,10 +321,10 @@ export function serveDir(root: string, nonce: string = randomBytes(16).toString(
// ─── Async spawn (keeps the loopback server's event loop free) ────────────────
async function runProc(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
async function runProc(cmd: string, args: string[], timeoutMs: number, env?: Record<string, string | undefined>): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
try {
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', ...(env ? { env } : {}) });
} catch (e) {
return { code: null, stdout: '', stderr: '', error: (e as Error).message };
}
@@ -529,8 +529,9 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
if (target.kind === 'error') return fail(target.error);
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const browseEnv = { ...process.env, BROWSE_SESSION: '' };
const run = async (args: string[]): Promise<string> => {
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())));
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())), browseEnv);
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
if (r.error || r.code !== 0) {
const first = (r.stderr || r.stdout || r.error || '').trim().split('\n')[0];
@@ -647,7 +648,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
} catch (e) {
return fail((e as Error).message);
} finally {
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000);
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000, browseEnv);
srv?.stop();
if (work) fs.rmSync(work, { recursive: true, force: true });
}
+5 -2
View File
@@ -151,10 +151,13 @@ export function changeBudget(state: RunState, o: BudgetChange): string[] {
return lines;
}
/** Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released. */
/**
* Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released.
* A shadow call journals `shadow_dispatched` before its send: it counts as dispatched and is never released here.
*/
export function releaseUndispatched(state: RunState): string[] {
const file = path.join(state.out, FILES.spend);
const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched').map(r => r.attempt));
const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched' || r.event === 'shadow_dispatched').map(r => r.attempt));
return readLedger(file).reserved.filter(r => !dispatched.has(r.attempt)).map(r => {
release(file, r.attempt);
journal(state.out, { event: 'released_undispatched', attempt: r.attempt, estimate_usd: r.estimate_usd });
+2 -3
View File
@@ -21,7 +21,7 @@ import { buildGate, finishGate } from './autoplan-gate';
import { FINDING_ORIGIN } from './autoplan-confirm';
import { carryLines } from './autoplan-carry';
import { aggregateFindings, exportRun } from './autoplan-export';
import { decisionRows, keepDirection, type GateItem } from './gate-list';
import { decisionRows, ruleKept, type GateItem } from './gate-list';
import { MARKER_RE } from './autoplan-scope';
import { RunError, journal, voiceRows, writeDecisions, type RunState } from './autoplan-state';
import type { DecisionRow, FindingRow } from './headless-artifacts';
@@ -64,8 +64,7 @@ export function finishRun(state: RunState): string[] {
if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
const list = buildGate(state, 1);
const items = list.items.filter(i => i.id !== 'p1' && !i.origin?.endsWith(`|${FINDING_ORIGIN}`));
const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: kept(items[i]!), default_if_unanswered: kept(items[i]!), source: 'ceo-only' }));
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: ruleKept(items[i]!), default_if_unanswered: ruleKept(items[i]!), source: 'ceo-only' }));
writeDecisions(state.out, rows);
state.status = 'reviewed';
exportRun(state);
+10 -5
View File
@@ -18,8 +18,10 @@ import { readLedger, summarize, type SpendSummary } from './spend-ledger';
export interface GateCost {
summary: SpendSummary;
/** Attempts the ledger has seen: settled plus still reserved. */
/** Reviewer attempts the ledger has seen: settled plus still reserved, shadow calls excluded. */
attempts: number;
/** What shadow calls (ledger label `shadow`, lib/autoplan-shadow.ts) spent or hold: in summary and the cap, never in the per-voice price. */
shadow_usd: number;
/** Phases a `rec` reply would reopen through its plan-changing recommendations, in run order; empty when none changes the plan. */
reopens: string[];
voices: number;
@@ -72,11 +74,14 @@ export function priorChoices(outDir: string, gateRev: number): Record<string, st
export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: { defaultPerVoiceUsd?: number } = {}): GateCost {
const ledger = readLedger(path.join(outDir, 'spend.json'), run.spend_cap_usd);
const summary = summarize(ledger);
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const voice = (r: { label?: string }) => r.label !== 'shadow';
const settled = ledger.settled.filter(voice), reserved = ledger.reserved.filter(voice);
const seen = [...settled.map(s => s.reserved_usd), ...reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
const reopens = recReopens(list, run, priorChoices(outDir, list.gate_rev));
const voices = reopens.length * (run.outside_enabled ? 2 : 1);
return { summary, attempts: ledger.settled.length + ledger.reserved.length, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
const shadow_usd = Math.round([...ledger.settled.filter(s => !voice(s)).map(s => (s.usd === 'unknown' ? s.reserved_usd : s.usd)), ...ledger.reserved.filter(r => !voice(r)).map(r => r.estimate_usd)].reduce((a, b) => a + b, 0) * 1e6) / 1e6;
return { summary, attempts: settled.length + reserved.length, shadow_usd, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
}
/** The two printed lines: the ledger at this gate, and the price of `rec`. */
@@ -87,7 +92,7 @@ export function renderGateCost(c: GateCost): string[] {
const unknown = s.unknown_count ? `; ${s.unknown_count} host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host's cost view)` : '';
const after = s.headroom_usd === null ? '' : ` → headroom ${usd(s.headroom_usd - c.rec_more_usd)}${s.headroom_usd - c.rec_more_usd < 0 ? ' (over the cap: raise it with gstack-autoplan budget before answering rec)' : ''}`;
return [
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${unknown}`,
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${c.shadow_usd ? ` shadow=${c.shadow_usd.toFixed(4)}` : ''}${unknown}`,
c.reopens.length
? `GATE_REC_COST: rec reopens up to ${c.reopens.join(',')} ≈ ${c.voices} voice(s) × ${usd(c.per_voice_usd)} = +${usd(c.rec_more_usd)} at most (the run's per-voice reservation)${after}`
: 'GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00',
@@ -97,7 +102,7 @@ export function renderGateCost(c: GateCost): string[] {
/** The JSON payload beside the printed lines. */
export function gateCostPayload(c: GateCost): Record<string, unknown> {
const s = c.summary;
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, ...(c.shadow_usd ? { shadow_usd: c.shadow_usd } : {}), unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
}
/** One sentence for the owner brief. */
+22 -19
View File
@@ -28,8 +28,8 @@ import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry'
import { buildEli5, capyRelayLine } from './captain-brief';
import { FINDING_ORIGIN, previousReview, reviewRows, settledIds } from './autoplan-confirm';
import { phaseClose } from './autoplan-timing';
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, replyShape, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { DecisionRow, FindingRow } from './headless-artifacts';
import { readJsonl, sha256File } from './headless-artifacts';
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
import { vaguePlanTerms } from './plan-reality';
@@ -133,21 +133,21 @@ function capItems(state: RunState, next: { d: () => string; uc: () => string }):
const rows: OpenRow[] = [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'),
...(def.carried_file ? readJsonl(path.join(state.out, def.carried_file)).rows as unknown as OpenRow[] : [])];
push({ id: next.d(), title: `${phase} closed accepted-with-findings at round ${def.round} (${def.reason}): ${def.open} open finding(s), ${severityText(def.counts)}, carried into tasks.jsonl`,
kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`,
kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, item_class: 'cap_deferred', why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`,
options: [{ key: 'a', text: 'accept with findings, deferred to the build', changes_plan: false }, reopen(phase)] }, `deferred-${phase}`);
for (const id of def.blockers) {
const f = rows.find(r => r.id === id);
if (!f) continue;
const risk = f.severity === 'Critical' || f.urgent;
push({ id: next.uc(), title: `${f.severity}${f.urgent ? ' urgent' : ''}${f.user_challenge ? ' user challenge' : ''} left open when ${phase} closed deferred: ${f.title} [${id}]`,
kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id },
kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id }, item_class: 'cap_blocker',
why: `${phase} closed deferred (${def.reason}) with this finding open; only you can accept it`,
options: [{ key: 'a', text: 'carry it into the build as a blocking task (you accept the risk)', changes_plan: false }, reopen(phase)] }, id);
}
}
for (const delta of pendingDeltas(state)) {
push({ id: next.uc(), title: `plan changed after ${delta.phase} cap: unreviewed delta (+${delta.added} -${delta.removed} lines, ${delta.old_plan_sha256.slice(0, 12)} -> ${delta.new_plan_sha256.slice(0, 12)})`,
kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) },
kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) }, item_class: 'cap_delta',
why: `revise v${delta.k} changed the plan after ${delta.phase} used round ${delta.round} of ${delta.cap}, so ${delta.phase} never reviewed it`,
options: [{ key: 'a', text: 'accept the unreviewed delta into the approved plan', changes_plan: false }, reopen(delta.phase)] }, `delta-${delta.k}`);
}
@@ -189,7 +189,7 @@ export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefin
/** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */
function agentDecision(id: string, title: string, phase: string, why: string): GateItem {
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, item_class: 'agent', why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
cost: 'agent decision; b is recorded, no phase reruns', options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change (agent decision; no phase reruns)', changes_plan: false }] };
}
@@ -218,14 +218,14 @@ export function buildGate(state: RunState, gateRev: number): GateList {
const title = `${n.title} [${row.native_id} + ${row.outside_id}]`;
if (reran && !n.changes && !o.changes) { push(agentDecision(`d${++d}`, title, phase, 'both voices recommend changing your stated direction'), ids); continue; }
const def = challengeDefault(row, n, o);
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, item_class: 'challenge2', ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
why: `both voices recommend changing your stated direction; blind spot: ${o.title}; cost if wrong: ${o.resolution ?? n.resolution ?? 'unstated'}`,
options: [KEEP('keep your direction (the plan as reviewed)'), { key: 'b', text: def.text, changes_plan: true }] }, ids);
continue;
}
if (row.disposition === 'disagree') {
challenged.add(o.id); challenged.add(n!.id);
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase, item_class: 'disagree',
why: `voices disagree: ${row.resolution}`, cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`,
options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids);
}
@@ -234,7 +234,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
if (!f.user_challenge || challenged.has(f.id) || f.settled_by) continue;
const why = `one voice (${f.voice}) recommends changing your stated direction`;
if (reran && !f.changes) { push(agentDecision(`d${++d}`, `${f.title} [${f.id}]`, phase, why), f.id); continue; }
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why, ...(f.changes ? { changes: f.changes } : {}),
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, item_class: 'challenge1', why, ...(f.changes ? { changes: f.changes } : {}),
cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
}
// An open Critical or High finding the items above do not cover: rec applies its fix to the plan.
@@ -247,7 +247,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
if (!severity) continue;
const ids = group.map(g => g.id);
const fixes = [...new Set(group.map(g => g.resolution).filter((r): r is string => !!r))];
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, finding: ids,
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, item_class: 'finding', finding: ids,
why: `an open ${severity} finding (${group.map(g => g.voice).join(' + ')}); rec applies its fix to the plan`, cost: `a reopens ${phase}${phase === 'eng' ? '' : ' and eng'} once the plan is revised`,
options: [{ key: 'a', text: `apply the fix to the plan: ${fixes.length ? fixes.join('; ') : `resolve "${f.title}"`}`, changes_plan: true }, { key: 'b', text: 'carry it into the build as a blocking task (no plan change)', changes_plan: false }] }, ids.join('+'), FINDING_ORIGIN);
}
@@ -266,7 +266,7 @@ function planApproval(state: RunState, items: GateItem[]): GateItem {
const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan;
const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`);
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}),
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', item_class: 'p1', ...(flags.length ? { flags } : {}),
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] };
@@ -304,7 +304,7 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
}
/** 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 {
export 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);
@@ -380,15 +380,15 @@ export function amendmentSteps(state: RunState, out: string): string {
edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : ''].filter(Boolean).join('; then ');
}
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean {
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. Returns the owner's parse. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): ParsedReply {
const owner = parseReply(ownerReply, list, gateRev, prior);
if (!owner.valid) return false;
if (!owner.valid) return owner;
const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort());
if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) {
throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3);
}
return true;
return owner;
}
/**
@@ -418,16 +418,19 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (takesRecs && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); \`rec\` and \`p1b\` take its recommendations, so pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
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 && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
: o.ownerReply !== undefined && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed!.valid, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed!.valid } : {};
const shape = o.carry || o.wordsBy ? undefined : replyShape(o.ownerReply ?? reply, list, ownerParsed ?? parsed);
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[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
const answeredAt = now();
const owner = (r: DecisionRow): Partial<DecisionRow> => (shape && r.gate_rev === list.gate_rev && r.answered_at === answeredAt
? { owner_mode: shape.mode, owner_named: shape.named.has((r.label ?? '').toLowerCase()), rec_hash: currentHash, answered_role: answeredBy?.startsWith('agent:') ? 'agent' : 'human' } : {});
const record = (answered = parsed) => writeDecisions(state.out, applyReply(rows, answered, 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;
return c ? { ...r, chosen_text: c.text } : r;
return { ...(c ? { ...r, chosen_text: c.text } : r), ...owner(r) };
}));
if (!plan || parsed.pending.length) {
// Approvals unanswered: record what was answered, but not an auto default that p1b would change (the final answer decides it).
+2 -4
View File
@@ -23,7 +23,7 @@ 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 { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
@@ -112,9 +112,7 @@ 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 [...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 carried;
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
+13 -7
View File
@@ -21,6 +21,7 @@ import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase } from './autoplan-gate';
import { finishRun } from './autoplan-ceo-only';
import type { ShadowSwitch } from './autoplan-shadow';
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';
@@ -38,14 +39,18 @@ import { annotateConfirm, confirmBlock } from './autoplan-confirm';
/** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */
const LOCK_WRITE_GRACE_MS = 30_000;
/** Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed. */
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean } = {}): T {
/**
* Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed.
* `waitMs`: retry a live owner's lock for that long before RUN_LOCKED (the shadow's publishing step, lib/autoplan-shadow.ts).
*/
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean; waitMs?: number } = {}): T {
const waitUntil = Date.now() + (opts.waitMs ?? 0);
fs.mkdirSync(out, { recursive: true });
const lock = path.join(out, FILES.lock);
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() });
const staged = `${lock}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
fs.writeFileSync(staged, mine);
for (let i = 0; ; i++) {
for (let reclaims = 0; ;) {
try {
fs.linkSync(staged, lock);
fs.unlinkSync(staged);
@@ -61,9 +66,10 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
} else if (owner.host === os.hostname() && typeof owner.pid === 'number') {
try { process.kill(owner.pid, 0); alive = true; } catch (k: any) { alive = k.code === 'EPERM'; }
} else if (owner.host && owner.host !== os.hostname()) alive = !opts.steal;
if (alive && Date.now() < waitUntil) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); continue; }
if (alive) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', unreadable ? `${lock} is being written by another command` : `pid ${owner.pid} on ${owner.host} since ${owner.at}`, 3); }
try { fs.unlinkSync(lock); } catch { /* raced with its owner's exit */ }
if (i > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
if (++reclaims > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
}
}
try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* already gone */ } }
@@ -72,7 +78,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; shadow?: ShadowSwitch; 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 {
@@ -120,12 +126,12 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
...(o.light ? { light: true } : {}),
...(o.ceoOnly ? { ceo_only: true } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.ceoOnly ? { ceo_only: true } : {}), ...(o.shadow ? { shadow: o.shadow } : {}), 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, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(o.shadow ? { shadow: o.shadow } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
writeState(state);
return state;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* autoplan-shadow-reader — the read side of shadow mode (lib/autoplan-shadow.ts,
* docs/unattended.md#shadow-mode). `exportShadow` is `gstack-autoplan shadow
* --out <dir> | --runs <file>`, the export gstack-evals reads for taste-replay
* R2: one row per item of each answered gate revision, with the shadow's
* status (recorded, malformed, failed with its code, missing, retrospective, or
* off when the run never turned shadow on), the shadow fields when present, and
* the owner's answer with its provenance (owner_mode, owner_named, carried,
* answered_role, rec_hash at record and at answer). It computes no agreement
* number, only counts. A revision whose plan approval is unanswered or answered
* c (interrogate) refuses SHADOW_BEFORE_ANSWER (a courtesy for honest tools,
* not access control: the journaled sha256 is the tamper evidence), and a
* CEO-only run CEO_ONLY_NO_GATE. Read-only. `validateShadow` is the private
* check `gstack-artifact validate <run.json>` adds: each record's schema and
* its journaled sha256.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { previousGate } from './autoplan-gate';
import { readDecisions, readJournal, readState } from './autoplan-state';
import type { ResultCodeName } from './result-codes';
import type { ValidationError } from './headless-artifacts';
import { RECORD_RE, SHADOW_ACTIONS, recordFile, shadowDir } from './autoplan-shadow';
export type ShadowStatus = 'recorded' | 'malformed' | 'failed' | 'missing' | 'retrospective' | 'off';
export interface ShadowExportRow {
run: string; gate_rev: number; item: string; decision: string; item_class: string | null; ceo_eligible: boolean; status: ShadowStatus; code?: string;
shadow: Record<string, unknown> | null;
owner: { chosen: string | null; status: string; owner_mode: string | null; owner_named: boolean | null; carried: boolean; carried_from: string | null; answered_role: string | null; answered_by: string | null; rec_hash_at_answer: string | null; rec_hash_at_record: string | null };
}
export interface ShadowExport { rows: ShadowExportRow[]; counts: Record<string, Record<string, number>>; refused: Array<{ run: string; gate_rev?: number; code: ResultCodeName; detail: string }> }
const readRows = (file: string) => fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l) as Record<string, any>);
/** Every answered revision of every run, deduped by run folder, gate_rev and item (two runs can share a run id). */
export function exportShadow(dirs: string[]): ShadowExport {
const rows = new Map<string, ShadowExportRow>();
const refused: ShadowExport['refused'] = [];
for (const dir of dirs) {
let state;
try { state = readState(dir); } catch (e: any) { refused.push({ run: dir, code: e.code ?? 'RUN_NOT_INITIALIZED', detail: `${dir}: ${e.detail ?? e.message}` }); continue; }
if (state.ceo_only) { refused.push({ run: state.run, code: 'CEO_ONLY_NO_GATE', detail: `${dir} is CEO-only run ${state.run}: no gate, nothing answered` }); continue; }
const key = fs.realpathSync(dir);
const decisions = readDecisions(dir).filter(r => r.gate_rev >= 1);
const journal = readJournal(dir);
for (const rev of [...new Set(decisions.map(r => r.gate_rev))].sort((a, b) => a - b)) {
const revRows = decisions.filter(r => r.gate_rev === rev);
const p1 = revRows.find(r => (r.label ?? '').toLowerCase() === 'p1');
if (!p1?.chosen || p1.status === 'pending' || p1.chosen === 'c') {
refused.push({ run: state.run, gate_rev: rev, code: 'SHADOW_BEFORE_ANSWER', detail: `${dir} gate_rev ${rev}: the plan approval is ${p1?.chosen === 'c' ? 'answered c (interrogate)' : 'unanswered'}; the export waits for the owner` });
continue;
}
const record = fs.existsSync(recordFile(dir, rev)) ? new Map(readRows(recordFile(dir, rev)).map(r => [String(r.item), r])) : undefined;
const failed = journal.find(r => r.event === 'shadow_failed' && r.gate_rev === rev);
const gate = previousGate(dir, rev);
for (const d of revRows) {
const item = (d.label ?? d.id).toLowerCase();
const s = record?.get(item);
const status: ShadowStatus = s ? (s.retrospective ? 'retrospective' : s.status) : failed ? 'failed' : state.shadow?.enabled ? 'missing' : 'off';
const gateItem = gate?.items.find(i => i.id === item);
const row: ShadowExportRow = {
run: state.run, gate_rev: rev, item, decision: d.id, item_class: s?.item_class ?? gateItem?.item_class ?? null, ceo_eligible: s?.ceo_eligible ?? ((s?.item_class ?? gateItem?.item_class) !== 'finding' && (item === 'p1' || d.phase === 'ceo')), status,
...(status === 'failed' ? { code: String(failed!.code) } : {}),
shadow: s ? Object.fromEntries(['action', 'pick', 'confidence', 'cites', 'reason', 'malformed', 'rule_pick', 'audit', 'model', 'prompt_version', 'prompt_sha256', 'rec_hash', 'rec_rev', 'recorded_at', 'retrospective'].filter(k => k in s).map(k => [k, s[k]])) : null,
owner: { chosen: d.chosen ?? null, status: d.status, owner_mode: d.owner_mode ?? null, owner_named: d.owner_named ?? null, carried: !!(d.carried_from || d.carried_gate_from), carried_from: d.carried_from ?? d.carried_gate_from ?? null,
answered_role: d.answered_role ?? null, answered_by: d.answered_by ?? null, rec_hash_at_answer: d.rec_hash ?? null, rec_hash_at_record: s?.rec_hash ?? null },
};
rows.set(`${key}|${rev}|${item}`, row);
}
}
}
const all = [...rows.values()];
const count = (key: (r: ShadowExportRow) => string) => all.reduce<Record<string, number>>((c, r) => { const k = key(r); c[k] = (c[k] ?? 0) + 1; return c; }, {});
return { rows: all, refused, counts: { status: count(r => r.status), item_class: count(r => r.item_class ?? 'unknown'), owner_mode: count(r => r.owner.owner_mode ?? 'none'), ceo_eligible: count(r => String(r.ceo_eligible)) } };
}
export function renderShadowExport(r: ShadowExport): string[] {
const counts = Object.entries(r.counts).map(([k, c]) => `SHADOW_COUNTS: ${k} ${Object.entries(c).map(([v, n]) => `${v}=${n}`).join(' ') || 'none'}`);
return [...r.rows.map(row => `SHADOW_ROW: ${JSON.stringify(row)}`), ...counts, `SHADOW_EXPORT: rows=${r.rows.length} refused=${r.refused.length} (no agreement number: gstack-evals computes it)`];
}
/** The private check behind gstack-artifact validate: each shadow record matches its journaled sha256 and its rows the schema. */
export function validateShadow(runDir: string): ValidationError[] {
const dir = shadowDir(runDir);
const journal = readJournal(runDir);
const missing = (present: Set<number>): ValidationError[] => [...new Set(journal.filter(r => r.event === 'shadow_recorded').map(r => Number(r.gate_rev)))]
.filter(rev => !present.has(rev))
.map(rev => ({ code: 'ARTIFACT_STALE', path: `shadow/${path.basename(recordFile(runDir, rev))}`, message: `the journal records gate_rev ${rev}, but its shadow record is missing` }));
let st: fs.Stats;
try { st = fs.lstatSync(dir); } catch { return missing(new Set()); }
if (!st.isDirectory()) return [{ code: 'ARTIFACT_PATH_ESCAPE', path: 'shadow', message: 'the shadow folder is a symlink or not a directory' }];
const errors: ValidationError[] = [];
const present = new Set<number>();
for (const name of fs.readdirSync(dir)) {
const m = RECORD_RE.exec(name);
if (!m) continue;
const rev = Number(m[1]);
present.add(rev);
const rel = `shadow/${name}`;
const bytes = fs.readFileSync(path.join(dir, name));
const recorded = journal.filter(r => r.event === 'shadow_recorded' && r.gate_rev === rev).at(-1);
const sha = createHash('sha256').update(bytes).digest('hex');
if (!recorded) errors.push({ code: 'ARTIFACT_STALE', path: rel, message: `no shadow_recorded journal row names gate_rev ${rev}` });
else if (recorded.sha256 !== sha) errors.push({ code: 'ARTIFACT_STALE', path: rel, message: `sha256 ${sha.slice(0, 12)} is not the journaled ${String(recorded.sha256).slice(0, 12)}` });
bytes.toString('utf8').split('\n').filter(Boolean).forEach((line, i) => {
let row: Record<string, any>;
try { row = JSON.parse(line); } catch { errors.push({ code: 'ARTIFACT_SCHEMA', path: `${rel}:${i + 1}`, message: 'not JSON' }); return; }
const problem = rowProblem(row, rev);
if (problem) errors.push({ code: 'ARTIFACT_SCHEMA', path: `${rel}:${i + 1}`, message: problem });
});
}
return [...errors, ...missing(present)];
}
function rowProblem(r: Record<string, any>, rev: number): string | undefined {
if (r.schema_version !== 1 || r.gate_rev !== rev) return `schema_version 1 and gate_rev ${rev} expected`;
for (const k of ['run', 'decision', 'item', 'model', 'prompt_sha256', 'recorded_at']) if (typeof r[k] !== 'string' || !r[k]) return `${k} must be a non-empty string`;
if (typeof r.retrospective !== 'boolean' || typeof r.audit !== 'boolean' || typeof r.ceo_eligible !== 'boolean') return 'retrospective, audit and ceo_eligible must be booleans';
if (r.status === 'malformed') return r.pick === null && !r.audit ? undefined : 'a malformed row carries no pick and no audit mark';
if (r.status !== 'recorded') return `status ${JSON.stringify(r.status)} is not recorded or malformed`;
if (!SHADOW_ACTIONS.includes(r.action)) return 'action must be decide, send_back or hold';
if (typeof r.pick !== 'string' || !Array.isArray(r.item_frozen?.options) || !r.item_frozen.options.some((o: { key?: string }) => o.key === r.pick)) return 'pick must be one of the frozen item\'s options';
if (typeof r.confidence !== 'number' || !(r.confidence >= 0 && r.confidence <= 1)) return 'confidence must be a number from 0 to 1';
if (!Array.isArray(r.cites) || typeof r.reason !== 'string' || r.reason.length > 400) return 'cites must be a list and reason a string of at most 400 characters';
if (r.audit && r.action !== 'decide') return 'only a decide row carries the audit mark';
return undefined;
}
+448
View File
@@ -0,0 +1,448 @@
/**
* autoplan-shadow — shadow mode for the CEO-only decider (GST-537 PR 3,
* docs/unattended.md#shadow-mode). In a driver run that turned it on at init
* (`autoplan_shadow on` or GSTACK_AUTOPLAN_SHADOW=1; off by default), every
* gate the driver publishes gets one hidden model prediction per question,
* written before anyone answers: action (decide, send_back, hold), pick,
* confidence, cites and reason, beside the free keep-direction rule's pick.
* Nothing routes differently and a shadow failure never blocks a gate or an
* answer. The decider prompt is the frozen, hand-written
* autoplan/shadow-decider.md (its sha256 is pinned below and on every row).
*
* The publication is two-phase so the run lock is never held during the call:
* `claimShadow` runs under the run lock of the command that wrote the gate and
* takes a lease (`<run>/shadow/r<rev>.pending`: pid, host, deadline; journaled
* `shadow_pending`); `runShadow` calls the model with no lock held (each call
* reserved under its own ledger id labeled `shadow` and journaled
* `shadow_dispatched` before the send), then retakes the lock with a bounded
* wait and publishes `<run>/shadow/ceo-only.r<rev>.jsonl` with link (a second
* writer never overwrites) plus `shadow_recorded` or `shadow_failed`. A live
* lease makes every other writer pay nothing, and `answer` waits for it
* (`shadowBeforeAnswer`). Only a dead lease (its pid gone, or past its
* deadline) is a crash: its dispatched calls settle `unknown`, never released,
* and `answer` then records the revision with every row `retrospective`.
* This module is the only one that names the shadow folder's paths; the reader
* for gstack-evals and the private validate check are in
* lib/autoplan-shadow-reader.ts.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { extractHeading } from './autoplan-prompts';
import { GSTACK_VERSION } from './autoplan-migrate';
import { withRunLock } from './autoplan-run';
import { FILES, RunError, journal, now, readJournal, readState, voiceRows, type RunState } from './autoplan-state';
import { decisionId, recHash, ruleKept, type GateItem, type GateList } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import { apiKeyFor, callModel, type ApiCallResult } from './outside-voice-api';
import { modelFamily } from './outside-voice-runner';
import { estimateCostUsd, hasPricing } from './pricing';
import { readLedger, release, reserve, settle, summarize } from './spend-ledger';
import { resolveSourceRoot } from './source-root';
import { readConfigKey } from './state-root';
export const SHADOW_PROMPT_VERSION = 1;
/** sha256 of autoplan/shadow-decider.md at SHADOW_PROMPT_VERSION; a prompt edit bumps both (test/autoplan-shadow.test.ts). */
export const SHADOW_PROMPT_SHA256 = 'e4857932ae831b7a6d933c42c182ccd7711c68338fec6901799264f645589344';
export const DEFAULT_SHADOW_MODEL = 'claude-opus-5-5';
/** One deadline for the call and its one retry. */
export const SHADOW_DEADLINE_MS = 90_000;
export const SHADOW_MAX_TOKENS = 4_000;
/** A reply cut at the output limit is asked once more at this limit, inside the same deadline. */
export const SHADOW_RAISED_MAX_TOKENS = 8_000;
export const SHADOW_SINK = 'autoplan-shadow';
export const SHADOW_CONSENT = 'autoplan_shadow on';
const PLAN_CHARS = 40_000;
const REASON_CHARS = 400;
const RELOCK_MS = 30_000;
const LEASE_GRACE_MS = 30_000;
export const SHADOW_ACTIONS = ['decide', 'send_back', 'hold'] as const;
const ROOT = path.resolve(import.meta.dir, '..');
export const shadowPromptFile = () => path.join(resolveSourceRoot(ROOT), 'autoplan', 'shadow-decider.md');
export const shadowDir = (out: string) => path.join(out, 'shadow');
export const recordFile = (out: string, rev: number) => path.join(shadowDir(out), `ceo-only.r${rev}.jsonl`);
export const RECORD_RE = /^ceo-only\.r(\d+)\.jsonl$/;
const leaseFile = (out: string, rev: number) => path.join(shadowDir(out), `r${rev}.pending`);
const saltFile = (out: string) => path.join(shadowDir(out), 'salt');
const sha256 = (data: string | Buffer) => crypto.createHash('sha256').update(data).digest('hex');
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// the switch, fixed at init
// ---------------------------------------------------------------------------
export interface ShadowSwitch { enabled: boolean; model?: string; reason?: string; source: 'env' | 'config' | 'default' }
/**
* GSTACK_AUTOPLAN_SHADOW (1|on|0|off), else gstack-config autoplan_shadow (default off). On also needs a priced model
* (autoplan_shadow_model, default claude-opus-5-5), its family's API key, and a run that is neither --no-outside nor
* CEO-only. Stored in run.json at init and never re-read. The line is printed only when shadow was asked for.
*/
export function shadowSwitch(o: { outsideEnabled: boolean; ceoOnly: boolean }, env: NodeJS.ProcessEnv = process.env): { shadow: ShadowSwitch; line?: string } {
const fromEnv = !!env.GSTACK_AUTOPLAN_SHADOW?.trim();
const value = (fromEnv ? env.GSTACK_AUTOPLAN_SHADOW! : readConfigKey('autoplan_shadow', env) ?? '').trim().toLowerCase();
const source = fromEnv ? 'env' as const : value ? 'config' as const : 'default' as const;
const label = fromEnv ? `GSTACK_AUTOPLAN_SHADOW=${value}` : `autoplan_shadow ${value}`;
const off = (reason: string) => ({ shadow: { enabled: false, reason, source }, line: `SHADOW: off (${reason})` });
if (!value || value === 'off' || value === '0') return { shadow: { enabled: false, reason: source === 'default' ? 'off by default' : label, source } };
if (value !== 'on' && value !== '1') return off(`${label} is not on or off`);
if (o.ceoOnly) return off('a CEO-only run writes no gate');
if (!o.outsideEnabled) return off('--no-outside');
const model = (readConfigKey('autoplan_shadow_model', env) ?? '').trim() || DEFAULT_SHADOW_MODEL;
const family = modelFamily(model);
const keyName = family === 'openai' ? 'OPENAI_API_KEY' : family === 'anthropic' ? 'ANTHROPIC_API_KEY' : `no API transport for family ${family}`;
if (!apiKeyFor(family, env)) return off(`no API key for ${model} (${keyName})`);
if (!hasPricing(model)) return off(`${model} has no price in lib/pricing.ts, so --spend-cap cannot bound it`);
return { shadow: { enabled: true, model, source }, line: `SHADOW: on model=${model} (${label}); one hidden model call per gate page, counted against --spend-cap` };
}
// ---------------------------------------------------------------------------
// the lease
// ---------------------------------------------------------------------------
interface Lease { gate_rev: number; pid: number; host: string; started_at: string; deadline: string; retrospective: boolean }
export interface ShadowClaim { gate_rev: number; retrospective: boolean }
function readLease(out: string, rev: number): Lease | undefined {
try { return JSON.parse(fs.readFileSync(leaseFile(out, rev), 'utf8')) as Lease; } catch { return undefined; }
}
/** Live: before its deadline plus a grace, and (on this host) its pid still running. */
function leaseLive(l: Lease, at = Date.now()): boolean {
if (!(at <= Date.parse(l.deadline) + LEASE_GRACE_MS)) return false;
if (l.host !== os.hostname()) return true;
try { process.kill(l.pid, 0); return true; } catch (e: any) { return e.code === 'EPERM'; }
}
function dropLease(out: string, rev: number): void {
const l = readLease(out, rev);
if (l && l.pid === process.pid && l.host === os.hostname()) fs.rmSync(leaseFile(out, rev), { force: true });
}
const resultRow = (rows: Array<Record<string, any>>, rev: number) => rows.find(r => (r.event === 'shadow_recorded' || r.event === 'shadow_failed') && r.gate_rev === rev);
const callIds = (rows: Array<Record<string, any>>, rev: number) => rows.filter(r => r.event === 'shadow_dispatched' && r.gate_rev === rev).map(r => String(r.attempt));
/** The shadow folder: created on first use; a symlink or a non-directory is refused, never followed. */
function safeDir(out: string): string | undefined {
const dir = shadowDir(out);
try {
const st = fs.lstatSync(dir);
if (st.isSymbolicLink()) return `${dir} is a symlink`;
if (!st.isDirectory()) return `${dir} is not a directory`;
} catch (e: any) {
if (e.code !== 'ENOENT') return `${dir}: ${e.code ?? e.message}`;
fs.mkdirSync(dir, { mode: 0o700 });
}
return undefined;
}
/** A dead lease: its dispatched calls settle unknown at their reservation (they may have been billed), the rest are released. */
function expireLease(out: string, rev: number, rows: Array<Record<string, any>>): void {
const file = path.join(out, FILES.spend);
const sent = new Set(callIds(rows, rev));
const open = readLedger(file).reserved.filter(r => r.attempt.startsWith(`shadow-r${rev}-`));
for (const r of open) if (sent.has(r.attempt)) settle(file, r.attempt, 'unknown'); else release(file, r.attempt);
fs.rmSync(leaseFile(out, rev), { force: true });
journal(out, { event: 'shadow_expired', gate_rev: rev, settled_unknown: open.filter(r => sent.has(r.attempt)).map(r => r.attempt), released: open.filter(r => !sent.has(r.attempt)).map(r => r.attempt) });
}
/**
* Under the run lock: claim the current gate revision's shadow, or say why not (nothing printed when shadow is off, the
* run is not at a gate, or the revision already has its result). A live lease means another writer is paying: this one
* pays nothing. `retrospective` (answer's crash path) claims only a revision whose shadow_pending has no result.
*/
export function claimShadow(out: string, o: { retrospective?: boolean } = {}): { claim?: ShadowClaim; line?: string } {
const state = readState(out);
const rev = state.gate_rev;
if (!state.shadow?.enabled || state.ceo_only || state.status !== 'gate_pending' || !rev) return {};
const rows = readJournal(out);
if (resultRow(rows, rev) || fs.existsSync(recordFile(out, rev))) return {};
const pending = rows.some(r => r.event === 'shadow_pending' && r.gate_rev === rev);
if (o.retrospective && !pending) return {};
const unsafe = safeDir(out);
if (unsafe) {
journal(out, { event: 'shadow_failed', gate_rev: rev, code: 'unsafe_dir', detail: unsafe });
return { line: `SHADOW: failed unsafe_dir (${unsafe})` };
}
const held = readLease(out, rev);
if (held && leaseLive(held)) return { line: `SHADOW: pending (gate_rev ${rev} is being recorded by pid ${held.pid} on ${held.host} until ${held.deadline}; this command pays nothing)` };
if (held || pending) expireLease(out, rev, rows);
const started = Date.now();
const lease: Lease = { gate_rev: rev, pid: process.pid, host: os.hostname(), started_at: new Date(started).toISOString(), deadline: new Date(started + SHADOW_DEADLINE_MS + RELOCK_MS).toISOString(), retrospective: !!o.retrospective };
const staged = `${leaseFile(out, rev)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(staged, JSON.stringify(lease));
try { fs.linkSync(staged, leaseFile(out, rev)); } catch (e: any) { if (e.code !== 'EEXIST') throw e; return { line: `SHADOW: pending (gate_rev ${rev} was claimed by another writer)` }; } finally { fs.rmSync(staged, { force: true }); }
journal(out, { event: 'shadow_pending', ...lease });
return { claim: { gate_rev: rev, retrospective: lease.retrospective } };
}
// ---------------------------------------------------------------------------
// the page: one prompt per gate
// ---------------------------------------------------------------------------
interface PageFinding { id: string; severity: string; title: string }
export interface PageItem { item: GateItem; findings: PageFinding[]; frozen: Record<string, unknown> }
export interface Page { prompt: string; items: PageItem[]; plan_sha256: string; prompt_sha256: string; request_sha256: string }
/** Every finding the gate items can name: both voices of every phase, plus findings a deferred close carried. */
function findingIndex(state: RunState): Map<string, FindingRow> {
const rows: FindingRow[] = [];
for (const phase of state.phase_order) {
rows.push(...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'));
const carried = state.phases[phase]?.deferred?.carried_file;
if (carried && fs.existsSync(path.join(state.out, carried))) rows.push(...readJsonl(path.join(state.out, carried)).rows as unknown as FindingRow[]);
}
return new Map(rows.map(r => [r.id, r]));
}
/** The question as frozen in the record and sent in the prompt; p1 lists every finding the other items name. */
export function buildPage(state: RunState, list: GateList, deciderText: string): Page {
const index = findingIndex(state);
const own = (item: GateItem) => (item.origin?.split('|')[1] ?? '').split('+').filter(id => index.has(id));
const named = [...new Set(list.items.filter(i => i.id !== 'p1').flatMap(own))];
const items = list.items.map(item => {
const findings = (item.id === 'p1' ? named : own(item)).map(id => { const f = index.get(id)!; return { id, severity: f.severity, title: f.title.split('\n')[0]!.slice(0, 200) }; });
const frozen = { id: item.id, item_class: item.item_class ?? null, kind: item.kind, title: item.title, why: item.why ?? null, phase: item.phase ?? null,
options: item.options.map(o => ({ key: o.key, text: o.text, changes_plan: o.changes_plan ?? null })), recommended: item.recommended, recommended_basis: item.recommended_basis ?? null, findings };
return { item, findings, frozen };
});
const planText = fs.existsSync(state.active_plan) ? fs.readFileSync(state.active_plan, 'utf8') : '';
const plan = (extractHeading(planText, 'Implementation plan') ?? planText).slice(0, PLAN_CHARS);
const slots: Record<string, string> = { PLAN: plan, QUESTIONS: '```json\n' + JSON.stringify(items.map(i => i.frozen), null, 2) + '\n```', IDS: items.map(i => i.item.id).join(', ') };
const prompt = deciderText.replace(/\{\{(PLAN|QUESTIONS|IDS)\}\}/g, (_, k: string) => slots[k]!);
return { prompt, items, plan_sha256: sha256(planText), prompt_sha256: sha256(deciderText), request_sha256: sha256(prompt) };
}
// ---------------------------------------------------------------------------
// the reply contract
// ---------------------------------------------------------------------------
export type Verdict = { ok: true; action: typeof SHADOW_ACTIONS[number]; pick: string; confidence: number; cites: string[]; reason: string } | { ok: false; why: string };
export interface ParsedPage { byId: Map<string, Verdict>; foreign: string[]; clean: boolean }
function jsonArray(text: string): unknown {
const fenced = /```(?:json)?\s*\n([\s\S]*?)```/.exec(text)?.[1];
for (const candidate of [fenced, text.trim(), text.slice(text.indexOf('['), text.lastIndexOf(']') + 1)]) {
if (!candidate) continue;
try { return JSON.parse(candidate); } catch { /* next candidate */ }
}
return undefined;
}
/** Exactly one valid object per requested id; a duplicate, omission or invalid field marks that item malformed, a foreign id the page. */
export function parsePage(text: string, items: PageItem[]): ParsedPage {
const byId = new Map<string, Verdict>();
const parsed = jsonArray(text);
if (!Array.isArray(parsed)) {
for (const i of items) byId.set(i.item.id, { ok: false, why: 'no JSON array in the reply' });
return { byId, foreign: [], clean: false };
}
const want = new Map(items.map(i => [i.item.id, i]));
const seen = new Map<string, unknown[]>();
const foreign: string[] = [];
for (const obj of parsed) {
const id = (obj as { id?: unknown })?.id;
if (typeof id === 'string' && want.has(id)) (seen.get(id) ?? seen.set(id, []).get(id)!).push(obj);
else foreign.push(typeof id === 'string' ? id : JSON.stringify(id ?? null));
}
for (const [id, pi] of want) {
const objs = seen.get(id) ?? [];
byId.set(id, objs.length === 0 ? { ok: false, why: 'missing' } : objs.length > 1 ? { ok: false, why: 'duplicate' } : verdict(objs[0] as Record<string, unknown>, pi));
}
return { byId, foreign, clean: foreign.length === 0 && [...byId.values()].every(v => v.ok) };
}
function verdict(o: Record<string, unknown>, pi: PageItem): Verdict {
const cites = Array.isArray(o.cites) ? o.cites : null;
const allowed = new Set(pi.findings.map(f => f.id));
if (!SHADOW_ACTIONS.includes(o.action as typeof SHADOW_ACTIONS[number])) return { ok: false, why: `action ${JSON.stringify(o.action ?? null)} is not decide, send_back or hold` };
if (typeof o.pick !== 'string' || !pi.item.options.some(op => op.key === o.pick)) return { ok: false, why: `pick ${JSON.stringify(o.pick ?? null)} is not an option of ${pi.item.id}` };
if (typeof o.confidence !== 'number' || !Number.isFinite(o.confidence) || o.confidence < 0 || o.confidence > 1) return { ok: false, why: `confidence ${JSON.stringify(o.confidence ?? null)} is not a number from 0 to 1` };
if (!cites || cites.some(c => typeof c !== 'string' || !allowed.has(c))) return { ok: false, why: `cites ${JSON.stringify(o.cites ?? null)} name a finding outside ${pi.item.id}'s origin` };
if (typeof o.reason !== 'string' || o.reason.length > REASON_CHARS) return { ok: false, why: `reason is not a string of at most ${REASON_CHARS} characters` };
return { ok: true, action: o.action as typeof SHADOW_ACTIONS[number], pick: o.pick, confidence: o.confidence, cites: cites as string[], reason: o.reason };
}
/** The 10% audit mark: sha256(salt + decision id) mod 1000 < 100. */
export function auditMark(salt: string, decision: string): boolean {
return BigInt('0x' + sha256(salt + decision)) % 1000n < 100n;
}
// ---------------------------------------------------------------------------
// the call and the record
// ---------------------------------------------------------------------------
export interface ShadowOutcome { status: 'recorded' | 'failed' | 'dropped'; code?: string; model?: string; usd?: number; malformed?: number; line: string }
export interface RunShadowOptions { env?: NodeJS.ProcessEnv; deadlineMs?: number }
class ShadowFailure extends Error { constructor(public code: string, detail: string) { super(detail); } }
interface Reply { result: ApiCallResult; usd: number | 'unknown'; attempt: string }
/** Classify a callModel error: no key, an HTTP status (not billed, settles 0), or a timeout or network loss (may be billed, settles unknown). */
function failureOf(e: any): { code: string; usd: number | 'unknown' } {
const msg = String(e?.message ?? e);
if (/^no API key/.test(msg)) return { code: 'no_key', usd: 0 };
if (/^(anthropic|openai) \d{3}:/.test(msg)) return { code: 'http', usd: 0 };
return { code: /timeout|abort/i.test(msg) || e?.name === 'AbortError' || e?.name === 'TimeoutError' ? 'timeout' : 'network', usd: 'unknown' };
}
/**
* Outside any lock: the page is asked once; a reply cut at the output limit is asked once more at 8,000 tokens, and a
* malformed one once more as a fresh request; one 90 s deadline covers both. Each call is reserved under its own
* ledger id (label shadow), journaled shadow_dispatched before the send and settled at its actual usage. Then the
* lock is retaken with a bounded wait and the record published. Never throws: every failure is an outcome.
*/
export async function runShadow(out: string, claim: ShadowClaim, o: RunShadowOptions = {}): Promise<ShadowOutcome> {
const env = o.env ?? process.env;
const deadline = Date.now() + (o.deadlineMs ?? SHADOW_DEADLINE_MS);
const rev = claim.gate_rev;
const replies: Reply[] = [];
try {
const state = readState(out);
const list = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
if (list.gate_rev !== rev) throw new ShadowFailure('stale_gate', `gate.json is gate_rev ${list.gate_rev}, the claim is ${rev}`);
const model = state.shadow!.model!;
const page = buildPage(state, list, fs.readFileSync(shadowPromptFile(), 'utf8'));
writeAtomic(path.join(shadowDir(out), `r${rev}.prompt.md`), page.prompt);
const inputTokens = Math.ceil(Buffer.byteLength(page.prompt) / 3);
const estimate = (maxTokens: number) => estimateCostUsd({ input: inputTokens, output: maxTokens }, model);
const ledgerFile = path.join(out, FILES.spend);
const headroom = summarize(readLedger(ledgerFile, state.spend_cap_usd)).headroom_usd;
const worst = estimate(SHADOW_MAX_TOKENS) + estimate(SHADOW_RAISED_MAX_TOKENS);
if (headroom !== null && headroom < worst) throw new ShadowFailure('budget', `headroom ${headroom.toFixed(4)} < ${worst.toFixed(4)} (one call plus its retry)`);
const ask = async (maxTokens: number): Promise<Reply> => {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new ShadowFailure('timeout', `the ${(o.deadlineMs ?? SHADOW_DEADLINE_MS) / 1000} s deadline passed before the call`);
const attempt = `shadow-r${rev}-${callIds(readJournal(out), rev).length + 1}`;
try { reserve(ledgerFile, attempt, estimate(maxTokens), { cap: readState(out).spend_cap_usd, label: 'shadow' }); }
catch (e: any) { throw new ShadowFailure('budget', e.message); }
journal(out, { event: 'shadow_dispatched', gate_rev: rev, attempt, model, max_tokens: maxTokens, estimate_usd: estimate(maxTokens), retrospective: claim.retrospective });
let result: ApiCallResult;
try {
result = await callModel({ model, prompt: page.prompt, timeoutMs: remaining, env, maxTokens, receipt: { sink: SHADOW_SINK, consent: SHADOW_CONSENT, payloadClass: 'plan-and-gate' } });
} catch (e: any) {
const f = failureOf(e);
settle(ledgerFile, attempt, f.usd);
throw new ShadowFailure(f.code, String(e?.message ?? e));
}
const { input_tokens: inTok, output_tokens: outTok } = result.usage;
const usd: number | 'unknown' = typeof inTok === 'number' && typeof outTok === 'number' ? estimateCostUsd({ input: inTok, output: outTok }, model) : 'unknown';
settle(ledgerFile, attempt, usd);
writeAtomic(path.join(shadowDir(out), `${attempt}.reply.txt`), result.text);
const reply = { result, usd, attempt };
replies.push(reply);
return reply;
};
const truncated = (r: Reply) => r.result.stop_reason === 'max_tokens' || r.result.stop_reason === 'length';
const first = await ask(SHADOW_MAX_TOKENS);
let parsed: ParsedPage[];
if (truncated(first)) {
const raised = await ask(SHADOW_RAISED_MAX_TOKENS);
if (truncated(raised)) throw new ShadowFailure('truncated', `the reply was cut at ${SHADOW_RAISED_MAX_TOKENS} output tokens`);
parsed = [parsePage(raised.result.text, page.items)];
} else {
parsed = [parsePage(first.result.text, page.items)];
if (!parsed[0]!.clean) {
try {
const retry = await ask(SHADOW_MAX_TOKENS);
if (!truncated(retry)) parsed.push(parsePage(retry.result.text, page.items));
} catch (e) { if (!(e instanceof ShadowFailure)) throw e; }
}
}
return publish(out, claim, { rows: shadowRows(out, state, list, page, parsed, replies), model, replies });
} catch (e: any) {
const failure = e instanceof ShadowFailure ? e : new ShadowFailure('internal', String(e?.message ?? e));
return publish(out, claim, { failure, replies });
}
}
const usdOf = (replies: Reply[]) => (replies.some(r => r.usd === 'unknown') ? 'unknown' as const : Math.round(replies.reduce((s, r) => s + (r.usd as number), 0) * 1e6) / 1e6);
/** One row per gate item: the frozen question, what the model predicted (the retry's verdict when valid, else the first's), the free rule's pick and the audit mark. */
function shadowRows(out: string, state: RunState, list: GateList, page: Page, parsed: ParsedPage[], replies: Reply[]): Array<Record<string, unknown>> {
const at = now();
const usage = replies.reduce((u, r) => ({ input_tokens: u.input_tokens + (r.result.usage.input_tokens ?? 0), output_tokens: u.output_tokens + (r.result.usage.output_tokens ?? 0) }), { input_tokens: 0, output_tokens: 0 });
return page.items.map(({ item, frozen }) => {
const verdicts = parsed.map(p => p.byId.get(item.id)!);
const k = verdicts.map((v, i) => [v, i] as const).reverse().find(([v]) => v.ok);
const decision = decisionId(list.run, list.gate_rev, item.id);
const base = {
schema_version: 1, run: list.run, gate_rev: list.gate_rev, decision, item: item.id, item_class: item.item_class ?? null, phase: item.phase ?? null,
ceo_eligible: item.item_class !== 'finding' && (item.id === 'p1' || item.phase === 'ceo'), item_frozen: frozen, rec_hash: recHash(list), rec_rev: list.rec_rev ?? 0,
plan_sha256: page.plan_sha256, prompt_version: SHADOW_PROMPT_VERSION, prompt_sha256: page.prompt_sha256, request_sha256: page.request_sha256,
model: replies[replies.length - 1]?.result.model ?? state.shadow?.model, gstack_version: GSTACK_VERSION, rule_pick: ruleKept(item),
retrospective: false, page_usage: usage, page_usd: usdOf(replies), recorded_at: at,
};
if (!k) return { ...base, status: 'malformed', malformed: (verdicts[verdicts.length - 1] as { why: string }).why, action: null, pick: null, confidence: null, cites: [], reason: null, audit: false };
const v = k[0] as Extract<Verdict, { ok: true }>;
return { ...base, status: 'recorded', action: v.action, pick: v.pick, confidence: v.confidence, cites: v.cites, reason: v.reason, reply: k[1] + 1,
audit: v.action === 'decide' && auditMark(saltOf(out), decision) };
});
}
/** The per-run audit salt, drawn once into the shadow folder (never run.json). */
function saltOf(out: string): string {
try { return fs.readFileSync(saltFile(out), 'utf8').trim(); } catch { /* first use */ }
try { fs.writeFileSync(saltFile(out), crypto.randomBytes(16).toString('hex') + '\n', { flag: 'wx', mode: 0o600 }); } catch (e: any) { if (e.code !== 'EEXIST') throw e; }
return fs.readFileSync(saltFile(out), 'utf8').trim();
}
function writeAtomic(file: string, text: string): void {
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, text);
fs.renameSync(tmp, file);
}
/** Under the run lock (a bounded wait, never RUN_LOCKED at once): link the record, journal the result, drop the lease. */
function publish(out: string, claim: ShadowClaim, r: { rows?: Array<Record<string, unknown>>; model?: string; failure?: ShadowFailure; replies: Reply[] }): ShadowOutcome {
const rev = claim.gate_rev;
const usd = usdOf(r.replies);
const attempts = r.replies.map(x => x.attempt);
try {
return withRunLock(out, () => {
const rows = readJournal(out);
if (fs.existsSync(recordFile(out, rev)) || resultRow(rows, rev)) { dropLease(out, rev); return { status: 'dropped' as const, line: `SHADOW: dropped (gate_rev ${rev} already has its result; this call's cost stays in the ledger)` }; }
if (r.failure) {
journal(out, { event: 'shadow_failed', gate_rev: rev, code: r.failure.code, detail: r.failure.message.slice(0, 500), attempts, usd, retrospective: claim.retrospective });
dropLease(out, rev);
return { status: 'failed' as const, code: r.failure.code, line: `SHADOW: failed ${r.failure.code}` };
}
const salted: Array<Record<string, unknown>> = r.rows!.map(row => ({ ...row, retrospective: claim.retrospective }));
const text = salted.map(row => JSON.stringify(row)).join('\n') + '\n';
const final = recordFile(out, rev);
for (const e of fs.readdirSync(shadowDir(out))) if (e.startsWith(`ceo-only.r${rev}.jsonl.`) && e.endsWith('.tmp')) fs.rmSync(path.join(shadowDir(out), e), { force: true });
const tmp = `${final}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, text, { flag: 'wx' });
try { fs.linkSync(tmp, final); } catch (e: any) { if (e.code !== 'EEXIST') throw e; dropLease(out, rev); return { status: 'dropped' as const, line: `SHADOW: dropped (gate_rev ${rev} was recorded by another writer)` }; } finally { fs.rmSync(tmp, { force: true }); }
const malformed = salted.filter(row => row.status === 'malformed').length;
journal(out, { event: 'shadow_recorded', gate_rev: rev, sha256: sha256(text), model: r.model, usd, items: salted.length, malformed, attempts, retrospective: claim.retrospective });
dropLease(out, rev);
const usdText = usd === 'unknown' ? 'unknown' : usd.toFixed(4);
return { status: 'recorded' as const, model: r.model, usd: usd === 'unknown' ? undefined : usd, malformed,
line: `SHADOW: recorded model=${r.model} usd=${usdText}${malformed ? ` malformed=${malformed}/${salted.length}` : ''}${claim.retrospective ? ' retrospective' : ''}` };
}, { waitMs: RELOCK_MS });
} catch (e: any) {
const code = e instanceof RunError && e.code === 'RUN_LOCKED' ? 'locked' : 'internal';
try {
withRunLock(out, () => {
if (!resultRow(readJournal(out), rev) && !fs.existsSync(recordFile(out, rev))) journal(out, { event: 'shadow_failed', gate_rev: rev, code, detail: String(e?.message ?? e).slice(0, 500), attempts, usd, retrospective: claim.retrospective });
dropLease(out, rev);
}, { waitMs: 2_000 });
} catch { /* still locked: the lease expires and the next command settles the revision */ }
return { status: 'failed', code, line: `SHADOW: failed ${code} (${e?.message ?? e}); the gate stands` };
}
}
/**
* Before `answer` takes the lock: wait for a live lease to finish (up to its deadline), then, when the revision has a
* shadow_pending and still no result (a crash), record it first with every row retrospective. Never throws.
*/
export async function shadowBeforeAnswer(out: string, o: RunShadowOptions = {}): Promise<string[]> {
try {
const state = readState(out);
const rev = state.gate_rev;
if (!state.shadow?.enabled || state.status !== 'gate_pending' || !rev) return [];
for (let l = readLease(out, rev); l && leaseLive(l); l = readLease(out, rev)) await sleep(200);
const rows = readJournal(out);
if (resultRow(rows, rev) || fs.existsSync(recordFile(out, rev)) || !rows.some(r => r.event === 'shadow_pending' && r.gate_rev === rev)) return [];
const { claim, line } = withRunLock(out, () => claimShadow(out, { retrospective: true }), { waitMs: RELOCK_MS });
if (!claim) return line ? [line] : [];
return [(await runShadow(out, claim, o)).line];
} catch (e: any) {
return [`SHADOW: failed internal (${e?.message ?? e}); the answer proceeds`];
}
}
+2
View File
@@ -74,6 +74,8 @@ export interface RunState extends RunManifest {
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
/** `next --ceo-only` (lib/autoplan-ceo-only.ts): the CEO phase alone, no gate, ends `reviewed`; never set by a scope marker. */
ceo_only?: boolean;
/** Shadow mode (lib/autoplan-shadow.ts): fixed at init from GSTACK_AUTOPLAN_SHADOW or autoplan_shadow and never re-read; absent reads off. */
shadow?: import('./autoplan-shadow').ShadowSwitch;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+17
View File
@@ -41,9 +41,12 @@ export interface GateItem {
carried?: { from: string; chosen: string; gate_rev: number };
/** A round-cap item (lib/autoplan-budget.ts): `b` grants the phase one more round and reopens it. */
cap?: { phase: string; kind: 'deferred' | 'blocker' | 'delta'; ref?: string };
/** The buildGate branch that wrote it (lib/autoplan-gate.ts); `kind` alone mislabels disagreements and single-voice challenges. */
item_class?: ItemClass;
/** An open Critical or High finding (its ids, a paired twin included): `a` applies the reviewer's fix to the plan. */
finding?: string[];
}
export type ItemClass = 'challenge2' | 'challenge1' | 'disagree' | 'agent' | 'cap_deferred' | 'cap_blocker' | 'cap_delta' | 'finding' | 'p1';
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;
@@ -219,6 +222,20 @@ export function keepDirection(item: GateItem): string {
return item.options.find(o => o.changes_plan === false)?.key ?? (item.id === 'p1' && item.options.some(o => o.key === 'a') ? 'a' : item.recommended);
}
/** The driver's own pick when nobody answers (a CEO-only run, and the shadow's free baseline): the direction kept; a cap approval stays undecided. */
export function ruleKept(item: GateItem): string | null {
return (item.cap && item.kind === 'approval') || item.finding ? null : keepDirection(item);
}
/** How the owner's own words answered: their reply mode, and the item ids they named with an `<id><option>` token. */
export function replyShape(words: string, list: GateList, parsed: ParsedReply): { mode: 'rec' | 'rec_except' | 'tokens' | 'as_is' | 'unparsed'; named: Set<string> } {
if (!parsed.valid) return { mode: 'unparsed', named: new Set() };
const tokens = tokenize(words, list);
const named = new Set(tokens.flatMap(t => { const m = /^([a-z]*[0-9]+)([a-z])$/.exec(t); return m && list.items.some(i => i.id === m[1]) ? [m[1]!] : []; }));
const mode = parsed.mode === 'rec' ? (tokens.includes('except') ? 'rec_except' : 'rec') : parsed.mode === 'as-is' ? 'as_is' : 'tokens';
return { mode, named };
}
/** What an unanswered `auto` item resolves to: its recommendation, unless that changes the plan (then the plan's direction). */
export function autoDefault(item: GateItem): string {
return item.options.find(o => o.key === item.recommended)?.changes_plan ? keepDirection(item) : item.recommended;
+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;
/** How the owner's own words answered (lib/gate-list.ts replyShape): their mode, whether they named this item, the rec_hash answered against, and who typed the reply. */
owner_mode?: 'rec' | 'rec_except' | 'tokens' | 'as_is' | 'unparsed'; owner_named?: boolean; rec_hash?: string; answered_role?: 'human' | 'agent';
/** An agent's own words (`--words-by agent:<id>`): never the owner's, so never carried or imported as the owner's. */
answer_words?: string; answer_words_parsed?: boolean; words_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. */
@@ -179,6 +181,7 @@ export const ARTIFACT_SCHEMAS = {
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected', 'auto_kept'] },
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
answered_at: str, reply: str, source: str, cost: str,
owner_mode: { enum: ['rec', 'rec_except', 'tokens', 'as_is', 'unparsed'] }, owner_named: { type: 'boolean' }, rec_hash: str, answered_role: { enum: ['human', 'agent'] },
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,
},
+56
View File
@@ -0,0 +1,56 @@
/**
* Model detection for loadouts (GSTA-161, PR 1b): which loadout an install
* serves, from explicit inputs only.
*
* Order: (1) an explicit loadout name (`--loadout`), served as asked and bound
* to no model; (2) an explicit model id (`--model`), else `GSTACK_MODEL`;
* (3) nothing, which is full. A model id is normalized (trim, lowercase, a
* trailing `[…]` and a `provider/` prefix dropped) and must match MODEL_ID_RE
* before it can reach generated shell or a launch file. An invalid, unmapped
* or unreadable input always resolves to full and never errors; only an
* unknown explicit loadout name is refused. No Claude settings, overlay
* preference or inherited loadout variable is read.
* Guide: docs/loadouts.md.
*/
import { FULL, MODEL_ID_RE, ModuleError, loadLoadouts, loadoutForModel, normalizeModelId, rungFor, type LoadoutsManifest } from '../scripts/modules';
export interface LoadoutResolution {
loadout: string;
source: 'loadout-flag' | 'model-flag' | 'GSTACK_MODEL' | 'default';
/** The model an automatic selection is bound to (the matched manifest.models key, so a dated snapshot binds to its base id); null for an explicit or default selection. */
model: string | null;
/** Why a given model resolved to full. */
reason?: 'unmapped' | 'invalid' | 'manifest-unreadable' | 'render-failed';
/** A LOADOUT_INVALID_MODEL line to print, when the model id was refused. */
notice?: string;
}
export function resolveLoadout(input: { loadout?: string; model?: string; env?: Record<string, string | undefined>; manifest?: LoadoutsManifest }): LoadoutResolution {
const env = input.env ?? process.env;
if (input.loadout !== undefined) {
const manifest = input.manifest ?? loadLoadouts();
if (!Object.hasOwn(manifest.loadouts, input.loadout)) {
throw new ModuleError('LOADOUT_UNKNOWN', `${JSON.stringify(input.loadout.slice(0, 40))} is not a loadout (valid: ${Object.keys(manifest.loadouts).sort().join(', ')})`, 'gstack-modules list');
}
return { loadout: input.loadout, source: 'loadout-flag', model: null };
}
const raw = input.model ?? env.GSTACK_MODEL;
if (raw === undefined || raw.trim() === '') return { loadout: FULL, source: 'default', model: null };
const source = input.model !== undefined ? 'model-flag' : 'GSTACK_MODEL';
const id = normalizeModelId(raw);
if (!MODEL_ID_RE.test(id)) {
const shown = JSON.stringify(raw.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 40));
return { loadout: FULL, source, model: null, reason: 'invalid', notice: `LOADOUT_INVALID_MODEL: ${shown} is not a plain model id; using full (docs/troubleshooting.md#loadout-invalid-model)` };
}
let manifest: LoadoutsManifest;
try { manifest = input.manifest ?? loadLoadouts(); } catch { return { loadout: FULL, source, model: null, reason: 'manifest-unreadable' }; }
const mapped = loadoutForModel(manifest, id);
const loadout = mapped?.loadout ?? manifest.default?.[rungFor(manifest, id)] ?? FULL;
if (loadout === FULL || !Object.hasOwn(manifest.loadouts, loadout)) return { loadout: FULL, source, model: null, reason: 'unmapped' };
return { loadout, source, model: mapped?.key ?? id };
}
/** One printable line: `LOADOUT: <name> model=<id|none> source=<s>[ reason=<r>]`. */
export function describeResolution(r: LoadoutResolution): string {
return `LOADOUT: ${r.loadout} model=${r.model ?? 'none'} source=${r.source}${r.reason ? ` reason=${r.reason}` : ''}`;
}
+15 -9
View File
@@ -11,10 +11,12 @@
import { sha256Hex, writeReceipt } from './egress-receipt';
import { modelFamily, type ModelFamily } from './outside-voice-runner';
export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record<string, string | undefined>; signal?: AbortSignal; maxTokens?: number }
/** Who sends and on whose consent: replaces the outside-voice receipt for a caller with its own sink (lib/autoplan-shadow.ts). */
export interface ApiReceipt { sink: string; consent: string; payloadClass?: string }
export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record<string, string | undefined>; signal?: AbortSignal; maxTokens?: number; receipt?: ApiReceipt }
export interface ApiUsage { input_tokens?: number; output_tokens?: number }
/** `truncated`: the provider stopped at the output token limit (Anthropic `max_tokens`, OpenAI `length`), so the text is cut off. */
export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; truncated: boolean }
/** `truncated`: the provider stopped at the output token limit (Anthropic `max_tokens`, OpenAI `length`), so the text is cut off; `stop_reason`: the provider's stop or finish reason when it sent one. */
export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; truncated: boolean; stop_reason?: string }
/** Anthropic requires max_tokens; a full review needs more than 8192. `run` sends this to OpenAI too, where reasoning tokens count toward it. */
export const DEFAULT_MAX_TOKENS = 16_384;
@@ -29,11 +31,13 @@ export function apiKeyFor(family: ModelFamily, env: Record<string, string | unde
return undefined;
}
function receipted(url: string, body: string, provider: string): void {
function receipted(url: string, body: string, provider: string, req: ApiCallRequest): void {
const receipt = req.receipt;
try {
writeReceipt({
sink: 'outside-voice-api', host: new URL(url).host, payloadClass: 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
consent: `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
env: req.env,
sink: receipt?.sink ?? 'outside-voice-api', host: new URL(url).host, payloadClass: receipt?.payloadClass ?? 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
consent: receipt?.consent ?? `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
});
} catch (err) {
process.stderr.write(`[outside-voice] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
@@ -41,7 +45,7 @@ function receipted(url: string, body: string, provider: string): void {
}
async function post(url: string, headers: Record<string, string>, body: string, req: ApiCallRequest, provider: string): Promise<{ status: number; json: any }> {
receipted(url, body, provider);
receipted(url, body, provider, req);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`timeout after ${req.timeoutMs} ms`)), req.timeoutMs);
req.signal?.addEventListener('abort', () => controller.abort(req.signal!.reason), { once: true });
@@ -67,7 +71,8 @@ export async function callModel(req: ApiCallRequest): Promise<ApiCallResult> {
const { status, json } = await post(`${base}/v1/messages`, { 'x-api-key': auth.key, 'anthropic-version': '2023-06-01' }, body, req, 'anthropic');
if (status < 200 || status >= 300) throw new Error(`anthropic ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`);
const text = Array.isArray(json?.content) ? json.content.filter((c: any) => c?.type === 'text').map((c: any) => c.text).join('\n') : '';
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, truncated: json?.stop_reason === 'max_tokens' };
const stop = typeof json?.stop_reason === 'string' ? json.stop_reason : undefined;
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, truncated: stop === 'max_tokens', ...(stop ? { stop_reason: stop } : {}) };
}
const base = (env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, '');
const body = JSON.stringify({ model, messages: [{ role: 'user', content: req.prompt }], ...(req.maxTokens ? { max_completion_tokens: req.maxTokens } : {}) });
@@ -75,5 +80,6 @@ export async function callModel(req: ApiCallRequest): Promise<ApiCallResult> {
if (status < 200 || status >= 300) throw new Error(`openai ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`);
const content = json?.choices?.[0]?.message?.content;
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.map((c: any) => c?.text ?? '').join('\n') : '';
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, truncated: json?.choices?.[0]?.finish_reason === 'length' };
const finish = json?.choices?.[0]?.finish_reason;
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, truncated: finish === 'length', ...(typeof finish === 'string' ? { stop_reason: finish } : {}) };
}
+429
View File
@@ -0,0 +1,429 @@
/**
* repo-state-clean — gstack-clean's plan and apply (#2627; plan GSTA-226 D8,
* D9). Reclaims a fixed allowlist of byproducts in one repository's
* `<git toplevel>/.gstack`, and nothing else:
*
* tmp/codex-* top-level regular files older than 7 days
* browse-*.log over 1 MB, cut in place to the last 256 KB from a
* line start (same inode, so O_APPEND writers go on)
* browse-audit.jsonl only with --audit-days N: entries older than N days
*
* browse.json, session-state.json, chromium-profile/, browser-skills/,
* browse-states/ and every other tmp/ entry (restamp journal, gate-ahead,
* pregate.json, ship receipt, autoplan/, ship-queue/, eval-bisect/) are never
* touched; test/gstack-clean-producers.test.ts classifies every tmp producer.
* Dry run unless --apply. Each change re-checks the file and its group's
* liveness first (live browse daemon, running codex, a write in the last
* hour); an unknown answer skips the group with its reason.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveStateRoot } from './state-root';
import { resultLine } from './result-codes';
export const CLEAN_SCHEMA_VERSION = 1;
const DAY_MS = 24 * 60 * 60 * 1000;
export const SCRATCH_PREFIX = 'codex-';
export const SCRATCH_MIN_AGE_MS = 7 * DAY_MS;
export const RECENT_MS = 60 * 60 * 1000;
const LIVENESS_RECHECK_MS = 2000;
export const LOG_CUT_OVER_BYTES = 1024 * 1024;
export const LOG_KEEP_BYTES = 256 * 1024;
export const BROWSE_LOGS = ['browse-console.log', 'browse-network.log', 'browse-dialog.log', 'browse-daemon.log', 'browse-startup-error.log'] as const;
export const AUDIT_LOG = 'browse-audit.jsonl';
export const USAGE = 'usage: gstack-clean [--apply] [--audit-days N] [--json]';
export const HELP = `gstack-clean — reclaim throwaway files in this repository's .gstack folder
${USAGE}
Reports what it would do and changes nothing unless you pass --apply.
Target: <git toplevel>/.gstack of the current directory. Refuses (exit 3)
outside git, when .gstack is a symlink, when BROWSE_STATE_FILE names another
folder, and when the folder is, holds or sits inside the gstack state root.
What it cleans (fixed thresholds):
tmp/codex-* codex scratch files older than 7 days: removed
browse-console.log, browse-network.log, browse-dialog.log,
browse-daemon.log, browse-startup-error.log
over 1 MB: cut in place to the last 256 KB,
starting after a line break
browse-audit.jsonl only with --audit-days N: entries older than N days
removed; lines without a readable ts are kept
Everything else (browse.json, session-state.json, chromium-profile/,
browser-skills/, browse-states/, and every other tmp/ entry) is never touched.
Files changed in the last hour are skipped, browse files are skipped while the
browse daemon runs, tmp/ is skipped while codex runs, and a group is skipped
when that cannot be told.
Options:
--apply make the changes
--audit-days N also trim browse-audit.jsonl to the last N days (N >= 1)
--json print one versioned JSON object instead of text
Exit codes: 0 done or nothing to do, 1 an action failed (rerunning is safe),
2 usage, 3 target refused.
`;
type Group = 'tmp' | 'browse';
type Kind = 'remove' | 'cut' | 'rotate';
export interface CleanOptions { apply: boolean; json: boolean; auditDays: number | null }
interface Candidate { file: string; group: Group; kind: Kind }
interface Seen { dev: number; ino: number; size: number; mtimeMs: number }
interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer; seen: Seen }
interface Skip { file: string; group: Group; reason: string }
interface Done { action: Action; errno?: string }
export interface CleanTarget { target: string; chosenBy: string }
interface Io { out: (line: string) => void; err: (line: string) => void }
class Refused extends Error {}
class UsageError extends Error {}
export function parseCleanArgs(argv: string[]): CleanOptions | 'help' {
const opts: CleanOptions = { apply: false, json: false, auditDays: null };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === '--help' || arg === '-h') return 'help';
if (arg === '--apply') { opts.apply = true; continue; }
if (arg === '--json') { opts.json = true; continue; }
if (arg === '--audit-days' || arg.startsWith('--audit-days=')) {
const value = arg === '--audit-days' ? argv[++i] : arg.slice('--audit-days='.length);
if (value === undefined || !/^[1-9]\d{0,4}$/.test(value)) throw new UsageError(`--audit-days needs a whole number of days from 1 to 99999; got ${JSON.stringify(value ?? '')}`);
opts.auditDays = Number(value);
continue;
}
throw new UsageError(`unknown flag ${JSON.stringify(arg)}`);
}
return opts;
}
/** realpath when the path exists, else its nearest existing parent's realpath plus the rest. */
function canonical(p: string): string {
const abs = path.resolve(p);
try { return fs.realpathSync(abs); } catch { /* missing: resolve the parent */ }
const parent = path.dirname(abs);
return parent === abs ? abs : path.join(canonical(parent), path.basename(abs));
}
const within = (child: string, parent: string) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
/** The verified target, or the missing path when .gstack does not exist. Throws Refused. */
export function resolveCleanTarget(env: NodeJS.ProcessEnv, cwd: string): CleanTarget | { missing: string } {
const git = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000, windowsHide: true });
const top = git.status === 0 ? git.stdout.trim() : '';
if (!top) throw new Refused(`${cwd} is not inside a git work tree`);
const toplevel = fs.realpathSync(top);
const candidate = path.join(toplevel, '.gstack');
if (env.BROWSE_STATE_FILE && canonical(path.resolve(cwd, env.BROWSE_STATE_FILE)) !== canonical(path.join(candidate, 'browse.json'))) {
throw new Refused(`BROWSE_STATE_FILE is ${env.BROWSE_STATE_FILE}, not ${path.join(candidate, 'browse.json')}`);
}
let st: fs.Stats;
try { st = fs.lstatSync(candidate); } catch (e: any) {
if (e?.code === 'ENOENT') return { missing: candidate };
throw e;
}
if (st.isSymbolicLink()) throw new Refused(`${candidate} is a symlink`);
if (!st.isDirectory()) throw new Refused(`${candidate} is not a directory`);
const target = fs.realpathSync(candidate);
const stateRoot = canonical(path.resolve(cwd, resolveStateRoot(env)));
if (within(target, stateRoot) || within(stateRoot, target)) {
throw new Refused(`${target} is, holds or sits inside the gstack state root ${stateRoot}`);
}
const from = canonical(cwd) === toplevel ? '' : `, found from ${cwd}`;
return { target, chosenBy: `the .gstack folder at the git toplevel ${toplevel}${from}` };
}
function pidAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch (e: any) { return e?.code !== 'ESRCH'; }
}
/** Why browse files must wait, or null when no browse daemon can be using them. */
function browseBusy(target: string): string | null {
if (process.platform === 'win32') return 'cannot tell whether the browse daemon is running on Windows';
for (const name of ['browse.json', 'browse.json.lock']) {
let text: string;
try { text = fs.readFileSync(path.join(target, name), 'utf8'); } catch (e: any) {
if (e?.code === 'ENOENT') continue;
return `cannot read ${name} (${e?.code ?? 'error'}) to tell whether the browse daemon is running`;
}
let pid: unknown;
try { pid = name === 'browse.json' ? JSON.parse(text)?.pid : Number(text.trim()); } catch { pid = undefined; }
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return `${name} names no pid, so the browse daemon may be running`;
if (pidAlive(pid)) return `the browse daemon (pid ${pid}, ${name}) is running`;
}
return null;
}
/** Why tmp/ must wait, or null when no codex process runs. */
function codexBusy(): string | null {
if (process.platform === 'win32') return 'cannot tell whether codex is running on Windows';
const ps = spawnSync('ps', ['-A', '-o', 'args='], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
if (ps.error || ps.status !== 0) return 'cannot list processes to tell whether codex is running';
for (const line of ps.stdout.split('\n')) {
const words = line.trim().split(/\s+/).slice(0, 2);
if (words.some(w => /^codex(?:\.js|-[^.]*)?$/.test(path.basename(w)))) return `codex is running (${line.trim().slice(0, 80)})`;
}
return null;
}
/** Audit lines to keep (newer than the cutoff, or without a readable ts), or why the file is malformed. */
function auditKeep(buf: Buffer, cutoffMs: number): Buffer | string {
const kept: string[] = [];
for (const [i, line] of buf.toString('utf8').split(/(?<=\n)/).entries()) {
const body = line.replace(/\r?\n$/, '');
if (body.trim() === '') { kept.push(line); continue; }
let entry: unknown;
try { entry = JSON.parse(body); } catch { return `line ${i + 1} is not JSON, so the file was left as it is`; }
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return `line ${i + 1} is not a JSON object, so the file was left as it is`;
const ts = Date.parse(String((entry as { ts?: unknown }).ts ?? ''));
if (Number.isNaN(ts) || ts >= cutoffMs) kept.push(line);
}
return Buffer.from(kept.join(''), 'utf8');
}
function readTail(file: string, size: number): Buffer {
const fd = fs.openSync(file, 'r');
try {
const length = Math.min(size, LOG_KEEP_BYTES);
const buf = Buffer.alloc(length);
return buf.subarray(0, fs.readSync(fd, buf, 0, length, size - length));
} finally { fs.closeSync(fd); }
}
/**
* One file's action, a skip, or null when the file is not a target. `busy` is
* the group's liveness answer; applyClean asks it again right before a change.
*/
function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOptions, now: number): Action | Skip | null {
let st: fs.Stats;
try { st = fs.lstatSync(c.file); } catch (e: any) {
return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot stat (${e?.code ?? 'error'})` };
}
if (st.isSymbolicLink()) return { ...c, reason: 'is a symlink; left alone' };
if (!st.isFile()) return c.kind === 'remove' ? { ...c, reason: 'is not a regular file; left alone' } : null;
let real: string;
try { real = fs.realpathSync(c.file); } catch (e: any) {
return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot resolve (${e?.code ?? 'error'})` };
}
if (path.dirname(real) !== dir) return { ...c, reason: 'resolves outside the folder being cleaned; left alone' };
if (c.kind === 'remove' && now - st.mtimeMs < SCRATCH_MIN_AGE_MS) return null;
if (c.kind === 'cut' && st.size <= LOG_CUT_OVER_BYTES) return null;
if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' };
if (busy) return { ...c, reason: busy };
const seen: Seen = { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs };
if (c.kind === 'remove') return { ...c, seen, bytes: st.size, reason: 'codex scratch older than 7 days' };
if (st.nlink !== 1) return { ...c, reason: `has ${st.nlink} hard links, so cutting it would change a file outside this folder; left alone` };
let content: Buffer;
try { content = c.kind === 'cut' ? readTail(c.file, st.size) : fs.readFileSync(c.file); } catch (e: any) {
return { ...c, reason: `cannot read (${e?.code ?? 'error'}); left alone` };
}
if (c.kind === 'cut') {
const tail = content;
const at = tail.indexOf(0x0a);
if (at < 0) return { ...c, reason: 'no line break in its last 256 KB; left alone' };
const keep = tail.subarray(at + 1);
return { ...c, seen, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' };
}
let keep: Buffer | string;
try { keep = auditKeep(content, now - opts.auditDays! * DAY_MS); } catch (e: any) {
return { ...c, reason: `cannot read (${e?.code ?? e?.name ?? 'error'}); left alone` };
}
if (typeof keep === 'string') return { ...c, reason: keep };
if (keep.length === st.size) return null;
return { ...c, seen, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` };
}
function candidates(target: string, opts: CleanOptions): { list: Candidate[]; skips: Skip[] } {
const list: Candidate[] = [];
const skips: Skip[] = [];
const tmp = path.join(target, 'tmp');
let tmpStat: fs.Stats | null = null;
try { tmpStat = fs.lstatSync(tmp); } catch (e: any) {
if (e?.code !== 'ENOENT') skips.push({ file: tmp, group: 'tmp', reason: `cannot stat (${e?.code ?? 'error'}); the tmp group is skipped` });
}
if (tmpStat?.isSymbolicLink()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp/ is a symlink; the tmp group is refused' });
else if (tmpStat && !tmpStat.isDirectory()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp is not a directory; the tmp group is skipped' });
else if (tmpStat) {
let names: string[] = [];
try { names = fs.readdirSync(tmp).sort(); } catch (e: any) {
skips.push({ file: tmp, group: 'tmp', reason: `cannot list (${e?.code ?? 'error'}); the tmp group is skipped` });
}
for (const name of names) if (name.startsWith(SCRATCH_PREFIX)) list.push({ file: path.join(tmp, name), group: 'tmp', kind: 'remove' });
}
for (const name of BROWSE_LOGS) list.push({ file: path.join(target, name), group: 'browse', kind: 'cut' });
if (opts.auditDays !== null) list.push({ file: path.join(target, AUDIT_LOG), group: 'browse', kind: 'rotate' });
return { list, skips };
}
const dirOf = (c: Candidate, target: string) => (c.group === 'tmp' ? path.join(target, 'tmp') : target);
const busyFor = (group: Group, target: string) => (group === 'tmp' ? codexBusy() : browseBusy(target));
export function planClean(target: string, opts: CleanOptions, now = Date.now()): { actions: Action[]; skips: Skip[] } {
const { list, skips } = candidates(target, opts);
const busy: Record<Group, string | null> = {
tmp: list.some(c => c.group === 'tmp') ? busyFor('tmp', target) : null,
browse: busyFor('browse', target),
};
const actions: Action[] = [];
for (const c of list) {
const result = evaluate(c, dirOf(c, target), busy[c.group], opts, now);
if (result && 'bytes' in result) actions.push(result);
else if (result) skips.push(result);
}
return { actions, skips };
}
const changed = () => Object.assign(new Error('the file changed after it was checked; rerunning is safe'), { code: 'ECHANGED' });
const same = (st: fs.Stats, seen: Seen) => st.dev === seen.dev && st.ino === seen.ino && st.size === seen.size && st.mtimeMs === seen.mtimeMs;
function writeFully(fd: number, buf: Buffer, position: number): void {
for (let off = 0; off < buf.length;) {
const n = fs.writeSync(fd, buf, off, buf.length - off, position + off);
if (n <= 0) throw Object.assign(new Error('short write'), { code: 'EIO' });
off += n;
}
}
/** The parent folder is still the verified real folder, so a swapped link cannot redirect the change. */
function parentIntact(file: string, dir: string): boolean {
const parent = path.dirname(file);
try { return !fs.lstatSync(parent).isSymbolicLink() && fs.realpathSync(parent) === dir; } catch { return false; }
}
/** A cut or trim keeps the inode, so O_APPEND writers go on; refused unless the file is exactly what evaluate saw. */
function cutInPlace(file: string, keep: Buffer, seen: Seen): void {
const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0));
try {
const st = fs.fstatSync(fd);
if (st.nlink !== 1 || !same(st, seen)) throw changed();
writeFully(fd, keep, 0);
fs.ftruncateSync(fd, keep.length);
} finally { fs.closeSync(fd); }
}
/** Re-checks each planned action against its file and its group's liveness right before the change. */
export function applyClean(target: string, planned: Action[], opts: CleanOptions): { done: Done[]; skips: Skip[] } {
const done: Done[] = [];
const skips: Skip[] = [];
const asked: Partial<Record<Group, { at: number; busy: string | null }>> = {};
const busyNow = (group: Group): string | null => {
const last = asked[group];
if (last && Date.now() - last.at < LIVENESS_RECHECK_MS) return last.busy;
const busy = busyFor(group, target);
asked[group] = { at: Date.now(), busy };
return busy;
};
for (const p of planned) {
const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyNow(p.group), opts, Date.now());
if (!fresh) continue;
if (!('bytes' in fresh)) { skips.push(fresh); continue; }
try {
if (!parentIntact(fresh.file, dirOf(fresh, target))) throw changed();
if (fresh.kind === 'remove') {
if (!same(fs.lstatSync(fresh.file), fresh.seen)) throw changed();
fs.unlinkSync(fresh.file);
} else cutInPlace(fresh.file, fresh.keep!, fresh.seen);
done.push({ action: fresh });
} catch (e: any) {
done.push({ action: fresh, errno: e?.code ?? 'UNKNOWN' });
}
}
return { done, skips };
}
const VERB: Record<Kind, [string, string]> = { remove: ['would remove', 'removed'], cut: ['would cut', 'cut'], rotate: ['would trim', 'trimmed'] };
function report(io: Io, opts: CleanOptions, t: CleanTarget, rows: Done[], skips: Skip[]): number {
const rel = (file: string) => path.relative(t.target, file) || '.';
const failed = rows.filter(r => r.errno);
const ok = rows.filter(r => !r.errno);
const bytes = ok.reduce((n, r) => n + r.action.bytes, 0);
const exit = failed.length ? 1 : 0;
const partial = resultLine('gstack-clean', 'GSTACK_CLEAN_PARTIAL', `${failed.length} of ${rows.length} actions failed`);
if (opts.json) {
io.out(JSON.stringify({
schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run',
status: failed.length ? 'partial' : rows.length === 0 ? 'nothing_to_do' : opts.apply ? 'done' : 'planned',
target: t.target, chosen_by: t.chosenBy, audit_days: opts.auditDays,
actions: rows.map(({ action: a, errno }) => ({
path: a.file, rel: rel(a.file), group: a.group, action: a.kind, bytes: a.bytes, reason: a.reason,
status: errno ? 'failed' : opts.apply ? 'done' : 'planned', ...(errno ? { errno } : {}),
})),
skips: skips.map(s => ({ path: s.file, rel: rel(s.file), group: s.group, reason: s.reason })),
total: { actions: ok.length, bytes, failed: failed.length, skipped: skips.length },
error: failed.length ? { code: 'GSTACK_CLEAN_PARTIAL', message: partial } : null,
exit_code: exit,
}, null, 2));
if (failed.length) io.err(partial);
return exit;
}
io.out(`gstack-clean: target ${t.target} (${t.chosenBy})`);
for (const { action: a, errno } of rows) {
if (errno) { io.out(`FAILED ${a.file} ${errno}`); continue; }
const size = a.kind === 'remove' ? `${a.bytes} bytes` : `${opts.apply ? 'freed' : 'freeing'} ${a.bytes} bytes`;
io.out(`${VERB[a.kind][opts.apply ? 1 : 0].padEnd(12)} ${rel(a.file)} (${size}): ${a.reason}`);
}
for (const s of skips) io.out(`${'skip'.padEnd(12)} ${rel(s.file)}: ${s.reason}`);
const failNote = failed.length ? `; ${failed.length} failed` : '';
io.out(`total: ${ok.length} action${ok.length === 1 ? '' : 's'} ${opts.apply ? 'freed' : 'would free'} ${bytes} bytes${failNote}; ${skips.length} skipped`);
if (failed.length) io.err(partial);
else if (!opts.apply) io.out(rows.length ? `nothing changed; to do this: gstack-clean --apply${opts.auditDays === null ? '' : ` --audit-days ${opts.auditDays}`}` : 'nothing changed; nothing to clean');
return exit;
}
function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): number {
let parsed: CleanOptions | 'help';
try { parsed = parseCleanArgs(argv); } catch (e) {
if (!(e instanceof UsageError)) throw e;
io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_USAGE', e.message));
io.err(USAGE);
return 2;
}
if (parsed === 'help') { io.out(HELP.trimEnd()); return 0; }
const opts = parsed;
let resolved: CleanTarget | { missing: string };
try { resolved = resolveCleanTarget(env, cwd); } catch (e) {
if (!(e instanceof Refused)) throw e;
const line = resultLine('gstack-clean', 'GSTACK_CLEAN_TARGET_REFUSED', e.message);
io.err(line);
if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED', message: line }, exit_code: 3 }, null, 2));
return 3;
}
if ('missing' in resolved) {
if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', status: 'nothing_to_do', target: resolved.missing, chosen_by: 'the .gstack folder at the git toplevel (missing)', audit_days: opts.auditDays, actions: [], skips: [], total: { actions: 0, bytes: 0, failed: 0, skipped: 0 }, error: null, exit_code: 0 }, null, 2));
else io.out(`nothing to clean: ${resolved.missing} does not exist`);
return 0;
}
const plan = planClean(resolved.target, opts);
if (!opts.apply) return report(io, opts, resolved, plan.actions.map(action => ({ action })), plan.skips);
const applied = applyClean(resolved.target, plan.actions, opts);
return report(io, opts, resolved, applied.done, [...plan.skips, ...applied.skips]);
}
/** Synchronous, so a pipe gets every byte before the bin calls process.exit. */
/** A reader that went away (EPIPE) ends the output quietly; the exit code still reports what was done. */
function writeAll(fd: number, text: string): void {
const buf = Buffer.from(text, 'utf8');
for (let off = 0; off < buf.length;) {
try { off += fs.writeSync(fd, buf, off); } catch (e: any) {
if (e?.code === 'EPIPE') return;
if (e?.code !== 'EAGAIN') throw e;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
}
}
}
/** The bin's entry. Never throws: an unexpected error prints one GSTACK_CLEAN_INTERNAL line and exits 1. */
export function main(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): number {
const io: Io = { out: line => writeAll(1, `${line}\n`), err: line => writeAll(2, `${line}\n`) };
try {
return run(argv, env, cwd, io);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
try { io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); } catch { /* stderr is gone too */ }
return 1;
}
}
+7 -1
View File
@@ -1,6 +1,7 @@
/**
* Result codes for the CEO-only driver run (`gstack-autoplan next --ceo-only`,
* lib/autoplan-ceo-only.ts). Kept beside lib/result-codes.ts, which spreads
* lib/autoplan-ceo-only.ts) and its shadow-mode reader (`gstack-autoplan shadow`,
* lib/autoplan-shadow-reader.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 run is
* documented in docs/unattended.md#ceo-only-runs.
@@ -23,4 +24,9 @@ export const AUTOPLAN_RESULT_CODES = {
summary: 'answer, recommend, revise and approvals import need a gate or an approvable run, and a CEO-only run has neither: it ends status=reviewed and is never approved',
fix: 'read <run dir>/ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out <new dir> --plan <file> (no --ceo-only)',
},
SHADOW_BEFORE_ANSWER: {
anchor: 'shadow-before-answer',
summary: 'gstack-autoplan shadow skipped a gate revision whose plan approval is unanswered or answered c (interrogate): the export pairs predictions with answers, so it waits for the owner',
fix: 'answer the gate (gstack-autoplan answer --gate-rev <n> ...), then run gstack-autoplan shadow again; answered revisions of the same run still export',
},
} as const satisfies Record<string, ResultCode>;
+54
View File
@@ -0,0 +1,54 @@
/**
* Result codes for the browse daemon's tabs: tab recovery, per-session tabs
* (#1961) and phone emulation with touch input (#3058). Kept beside
* lib/result-codes.ts (which spreads this table into RESULT_CODES) so the
* owner module stays within the module-size ratchet; the same rules apply:
* one stable docs/troubleshooting.md anchor, a summary and a fix clause each.
*/
import type { ResultCode } from './result-codes';
export const BROWSE_RESULT_CODES = {
// Fix wave 2026-10-15, PR 1a (items 7 and 11).
BROWSE_TAB_UNRECOVERABLE: {
anchor: 'browse-tab-unrecoverable',
summary: 'every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone',
fix: 'run `browse restart` (`$B restart` in a gstack skill); `browse status` and `browse stop` keep working',
},
// Per-session tabs (#1961).
BROWSE_SESSION_INVALID: {
anchor: 'browse-session-invalid',
summary: 'the session name is not 1-64 letters, digits, dots, colons, underscores or dashes',
fix: 'pick a name like agent-1 (BROWSE_SESSION=agent-1 or --session agent-1)',
},
BROWSE_SESSION_ROOT_ONLY: {
anchor: 'browse-session-root-only',
summary: 'only the local root token may name a session; paired and scoped agents get their own tab with newtab',
fix: 'drop the session field and run newtab, then pass its tabId on every command',
},
BROWSE_SESSION_TAB_RESET: {
anchor: 'browse-session-tab-reset',
summary: 'this session\'s tab was closed (by closetab, the window, or the idle sweep), so the page it was reading is gone',
fix: 'open the page again with `browse goto <url>` in the same session, then snapshot again before using @refs',
},
BROWSE_SESSION_LIMIT: {
anchor: 'browse-session-limit',
summary: 'every session slot is running a command, so no idle session could be closed to make room',
fix: 'retry when a command finishes, or raise BROWSE_MAX_SESSIONS (default 16)',
},
// Phone emulation and touch (#3058).
BROWSE_DEVICE_UNKNOWN: {
anchor: 'browse-device-unknown',
summary: 'no Playwright device has that name, so nothing on the tab changed',
fix: '$B device --list',
},
BROWSE_DEVICE_CDP_FAILED: {
anchor: 'browse-device-cdp-failed',
summary: 'Chromium refused a device emulation call on this tab',
fix: '$B device off',
},
BROWSE_TOUCH_NO_DEVICE: {
anchor: 'browse-touch-no-device',
summary: 'touch input needs a phone profile on this tab first',
fix: '$B device "iPhone 15"',
},
} as const satisfies Record<string, ResultCode>;
+30
View File
@@ -0,0 +1,30 @@
/**
* Result codes for gstack-clean (lib/repo-state-clean.ts, #2627). Kept beside
* lib/result-codes.ts (which spreads this table into RESULT_CODES) so the owner
* module stays within the module-size ratchet; the same rules apply: one stable
* docs/troubleshooting.md anchor, a summary and a fix clause each.
*/
import type { ResultCode } from './result-codes';
export const CLEAN_RESULT_CODES = {
GSTACK_CLEAN_USAGE: {
anchor: 'gstack-clean-usage',
summary: 'an unknown flag or a bad --audit-days value; nothing was read or changed',
fix: 'gstack-clean [--apply] [--audit-days N] [--json], where N is a whole number of days, 1 or more',
},
GSTACK_CLEAN_TARGET_REFUSED: {
anchor: 'gstack-clean-target-refused',
summary: 'the folder to clean is not a safe per-repo .gstack folder, so nothing was touched',
fix: 'run gstack-clean inside a git checkout whose .gstack is a real folder (not a symlink) outside the gstack state root, with BROWSE_STATE_FILE unset or naming that folder\'s browse.json',
},
GSTACK_CLEAN_PARTIAL: {
anchor: 'gstack-clean-partial',
summary: 'one or more removals or cuts failed; the others finished',
fix: 'rerunning is safe: fix the permissions on each FAILED path or wait for the program holding it, then rerun gstack-clean --apply',
},
GSTACK_CLEAN_INTERNAL: {
anchor: 'gstack-clean-internal',
summary: 'unexpected gstack-clean failure; the line names the error',
fix: 'rerun once; if it repeats, report the printed error to the gstack maintainers',
},
} as const satisfies Record<string, ResultCode>;
+4 -6
View File
@@ -16,6 +16,8 @@ import { BUDGET_RESULT_CODES } from './result-codes-budget';
import { EVAL_RESULT_CODES } from './result-codes-eval';
import { PLAN_RESULT_CODES } from './result-codes-plan';
import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan';
import { BROWSE_RESULT_CODES } from './result-codes-browse';
import { CLEAN_RESULT_CODES } from './result-codes-clean';
export interface ResultCode {
/** Stable `<a id>` in docs/troubleshooting.md. */
@@ -634,12 +636,6 @@ export const RESULT_CODES = {
summary: 'a Capy session (GSTACK_CAPY=1) has no passing install verdict for this checkout, HEAD and workflow, so the workflow refused to start',
fix: 'run the Capy skill installer with --for <workflow> on this machine and launch with the GSTACK_LAUNCH line it prints',
},
// Fix wave 2026-10-15, PR 1a (items 7 and 11).
BROWSE_TAB_UNRECOVERABLE: {
anchor: 'browse-tab-unrecoverable',
summary: 'every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone',
fix: 'run `browse restart` (`$B restart` in a gstack skill); `browse status` and `browse stop` keep working',
},
BUN_VERSION_UNREADABLE: {
anchor: 'bun-version-unreadable',
summary: 'bun --version did not print a version gstack can compare with the incoming release\'s Bun floor, so the update was held and nothing was changed',
@@ -772,6 +768,8 @@ export const RESULT_CODES = {
...EVAL_RESULT_CODES,
...PLAN_RESULT_CODES,
...AUTOPLAN_RESULT_CODES,
...BROWSE_RESULT_CODES,
...CLEAN_RESULT_CODES,
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+57 -13
View File
@@ -16,7 +16,7 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { isReviewedTreeLine, promptHash, type TreeCheck } from './review-brief';
import { checkReviewedTree, isReviewedTreeLine, promptHash, type TreeCheck } from './review-brief';
export interface LedgerEvent { event: 'rendered' | 'sent' | 'output' | 'judgment' | 'judge' | 'program'; [k: string]: unknown }
export const readerKey = (role: string, name?: string) => (name ? `${role}/${name}` : role);
@@ -236,6 +236,21 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud
return res;
}
/** A recorded output's file text while it still hashes to what collect recorded. */
function unchangedOutput(o: LedgerEvent): string | undefined {
try {
const text = typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined;
return text !== undefined && promptHash(text) === o.sha256 ? text : undefined;
} catch { return undefined; }
}
/** Whether a reply names the tree its render recorded (GST-482), checked on the reply itself, never on an output event's editable fields. */
const treeHolds = (render: LedgerEvent | undefined, reply: string | undefined) => !render || !('tree' in render)
|| (reply !== undefined && checkReviewedTree(reply, typeof render.tree === 'string' ? render.tree : null).ok);
/** One file however its path is spelled: collect and judge may reach a run directory through a symlink. */
const realPath = (f: string) => { try { return fs.realpathSync(f); } catch { return path.resolve(f); } };
/**
* The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL, raised to its judged
* severity when the newest judge accepted a judged set that is unchanged since; lead judgment's additions count and a folded
@@ -284,15 +299,16 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] {
}
/**
* Whether a recorded output was a review: it has findings, or its file (unchanged since collect) carries the reader's clean marker, so
* a ledger written before collect recorded the field is judged by the same rule; with no readable file, collect's own review field.
* Whether a recorded output was a review: its unchanged reply names its render's tree, and it has findings or carries the reader's
* clean marker, so a ledger written before collect recorded the field is judged by the same rule; with no readable file, collect's own
* review field.
*/
function reviewed(o: LedgerEvent): boolean {
function reviewed(o: LedgerEvent, render?: LedgerEvent): boolean {
if (o.tree_ok === false || unavailableOutput(o)) return false;
const text = unchangedOutput(o);
if (!treeHolds(render, text)) return false;
if (Array.isArray(o.ids) && o.ids.length) return true;
let text: string | undefined;
try { text = typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined; } catch { /* unreadable */ }
return text !== undefined && promptHash(text) === o.sha256 ? hasCleanMarker(String(o.reader), text) : o.review === true;
return text !== undefined ? hasCleanMarker(String(o.reader), text) : o.review === true;
}
/** Every id collect recorded, in order. */
@@ -314,6 +330,7 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
return { at, next, reader, label: nth > 1 ? `${reader}@${nth}` : reader };
});
let voided = 0;
const differs: string[] = [];
for (const p of episodes) {
const r = events[p.at]!;
const inside = events.map((e, i) => ({ e, i })).filter(({ e, i }) => i > p.at && i < p.next && e.reader === p.reader);
@@ -331,7 +348,7 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
voided += epVoided;
const lastMatch = [...sent].reverse().find(({ e }) => e.sha256 === r.sha256);
const outputs = lastMatch ? inside.filter(({ e, i }) => e.event === 'output' && i > lastMatch.i) : [];
const collected = outputs.some(({ e }) => reviewed(e));
const collected = outputs.some(({ e }) => reviewed(e, r));
const unavailable = !collected && outputs.some(({ e }) => unavailableOutput(e));
const status = !sent.length ? 'NOT_SENT' : mismatched ? 'MISMATCH' : collected ? 'MATCH' : unavailable ? 'UNAVAILABLE' : 'NOT_COLLECTED';
const tampered = fileNow && fileNow !== r.sha256 ? ' file_changed_since_render' : '';
@@ -346,20 +363,38 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
const lastSent = p ? events.slice(p.at + 1, i).filter(e => e.event === 'sent' && e.reader === reader).at(-1) : undefined;
if (!p) { status = 'MISMATCH'; notes.push('brief_not_rendered'); }
else if (!lastSent || lastSent.sha256 !== events[p.at]!.sha256) { status = 'MISMATCH'; notes.push('brief_not_sent_verbatim'); }
if (typeof o.file === 'string' && fs.existsSync(o.file) && promptHash(fs.readFileSync(o.file, 'utf8')) !== o.sha256) { status = 'MISMATCH'; notes.push('output_file_changed'); }
let text: string | undefined;
try { text = typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined; } catch { /* missing or unreadable */ }
const render = p ? events[p.at]! : undefined;
if (text !== undefined && promptHash(text) !== o.sha256) { status = 'MISMATCH'; notes.push('output_file_changed'); }
const ids = Array.isArray(o.ids) ? (o.ids as string[]) : [];
let lines: MergedLine[] = [];
try { lines = typeof o.merged === 'string' ? readJsonl<MergedLine>(o.merged).filter(l => ids.includes(l.id)) : []; } catch { notes.push('merged_unreadable'); status = 'MISMATCH'; }
if (lines.length !== ids.length) { status = 'MISMATCH'; notes.push(`merged_lines=${lines.length}/${ids.length}`); }
if (lines.some(l => l.output_sha256 !== o.sha256)) { status = 'MISMATCH'; notes.push('merged_line_from_other_output'); }
if (status === 'MATCH' && !reviewed(o)) {
// Each merged row must be the finding collect parses at that id's index from the unchanged output, label and raw text byte for byte.
if (text === undefined) {
status = 'MISMATCH';
notes.push(typeof o.file === 'string' ? 'output_file_missing' : 'output_file_unrecorded');
differs.push(...ids);
} else if (promptHash(text) === o.sha256) {
const parsed = parseFindings(reader, text);
const off = ids.filter((id, k) => { const m = lines.find(l => l.id === id); return !m || parsed[k]?.label !== m.label || parsed[k]?.raw !== m.raw; });
if (unavailableOutput(o) && parsed.length) { status = 'MISMATCH'; notes.push('unavailable_output_has_findings'); }
else if (!unavailableOutput(o)) {
if (off.length) { status = 'MISMATCH'; notes.push(`merged_line_differs=${JSON.stringify(off)}`); differs.push(...off); }
// A reply collect refused for its tree (GST-482) records no ids whatever it holds; the refusal is re-derived from the reply.
if (parsed.length !== ids.length && !(o.tree_ok === false && !ids.length && !treeHolds(render, text))) { status = 'MISMATCH'; notes.push(`merged_count_differs=${parsed.length}/${ids.length}`); }
}
}
if (status === 'MATCH' && !reviewed(o, render)) {
// A non-review the same dispatch later replaced with a review (the brief re-sent verbatim, then answered) is void, counted like a voided send.
const later = p ? events.slice(i + 1, p.next) : [];
const review = later.findIndex(e => e.event === 'output' && e.reader === reader && reviewed(e));
const review = later.findIndex(e => e.event === 'output' && e.reader === reader && reviewed(e, render));
const replaced = review > 0 && later.slice(0, review).some(e => e.event === 'sent' && e.reader === reader && e.sha256 === events[p!.at]!.sha256);
if (unavailableOutput(o)) { status = 'UNAVAILABLE'; notes.push(`reason=${JSON.stringify(o.unavailable)}`); }
else if (replaced) { status = 'VOIDED'; voided++; notes.push('replaced_by_a_review'); }
else if (o.tree_ok === false) { status = 'NOT_REVIEWED'; notes.push(`wrong_tree reported=${JSON.stringify(o.reported_tree)} expected=${JSON.stringify(o.tree)}`); }
else if (o.tree_ok === false || !treeHolds(render, text)) { status = 'NOT_REVIEWED'; notes.push(`wrong_tree reported=${JSON.stringify(o.reported_tree)} expected=${JSON.stringify(o.tree)}`); }
else { status = 'NOT_REVIEWED'; notes.push('no_finding_no_clean_marker'); }
}
rows.push({ kind: 'OUTPUT', reader: p?.label ?? reader, status, detail: `sha=${short(o.sha256)} findings=${ids.length}${notes.length ? ' ' + notes.join(' ') : ''}` });
@@ -373,13 +408,22 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
const judgedIds = new Set(Array.isArray(v.merged_ids) ? (v.merged_ids as string[]) : []);
const missing = collectedIds(events.slice(0, lastJudge)).filter(id => !judgedIds.has(id));
if (missing.length) notes.push(`merged_ids_missing=${missing.join(',')}`);
// The judged set must come from the merged file collect wrote, holding only rows collect recorded.
const outputs = events.slice(0, lastJudge).filter(e => e.event === 'output' && Array.isArray(e.ids) && e.ids.length);
if (outputs.some(e => typeof e.merged !== 'string' || realPath(e.merged) !== realPath(String(v.merged)))) notes.push('judge_merged_not_collected');
let rerun: Judgment | null = null;
try {
const judgedText = fs.readFileSync(String(v.judged), 'utf8');
if (promptHash(judgedText) !== v.judged_sha256) notes.push('judged_changed_since_judge');
rerun = judge(readJsonl<MergedLine>(String(v.merged)), readJsonl<JudgedLine>(String(v.judged)));
const merged = readJsonl<MergedLine>(String(v.merged));
const collected = new Set(collectedIds(events.slice(0, lastJudge)));
const extra = merged.filter(m => !collected.has(m.id)).map(m => m.id);
if (extra.length) notes.push(`merged_rows_not_collected=${JSON.stringify(extra)}`);
rerun = judge(merged, readJsonl<JudgedLine>(String(v.judged)));
} catch { notes.push('judged_or_merged_unreadable'); }
if (rerun && !rerun.ok) notes.push(`rerun_rejects dropped=${JSON.stringify(rerun.dropped)} downgraded=${JSON.stringify(rerun.downgraded)}`);
const forged = differs.filter(id => judgedIds.has(id));
if (forged.length) notes.push(`merged_line_differs=${JSON.stringify(forged)}`);
const status = !v.ok ? 'REJECTED' : notes.length ? 'MISMATCH' : 'OK';
rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${notes.length ? ' ' + notes.join(' ') : ''}` });
} else if (events.some(e => e.event === 'output')) rows.push({ kind: 'JUDGE', reader: '-', status: 'MISSING', detail: 'outputs were collected but no judged set was checked' });
+136 -11
View File
@@ -29,10 +29,21 @@
* (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.
* Findings name file, line, rule and commit, never the matched text.
*
* Skipping (on when a base resolves; `--no-skip` turns it off): a listed
* commit the destination already holds as an ancestor of `refs/pull/<n>/head`,
* for a squash `(#n)` on the base since the merge-base (at most 50 refs, a
* 20 s budget, fetched from the destination remote), is not rescanned, nor is
* one whose own `git patch-id --verbatim` and diff equal a base commit's
* since the merge-base (off above 2,000 base or 400 listed commits). A
* landed patch skips only the path and text rules: `sealed.label_join` judges
* the whole file at the commit, which the base may never have held. No
* combined-diff matching; any lookup failure scans everything.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { sha256Hex, writeReceipt } from './egress-receipt';
import { containedRelative, globToRegExp } from './ship-policy';
import { DEFAULT_IDENTIFIER_KEYS, identifierKeyError, joinFindings, labelKeyError } from './sealed-join';
import { resolveStateRoot } from './state-root';
@@ -77,8 +88,8 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
return errors;
}
function git(repoRoot: string, args: string[], input?: string): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
function git(repoRoot: string, args: string[], input?: string, encoding: BufferEncoding = 'utf8'): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding, input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
@@ -191,10 +202,11 @@ function textFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
return out;
}
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy): SealedFinding[] {
if (!commits.length) return [];
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy, joinCommits: string[] = commits): SealedFinding[] {
if (!commits.length && !joinCommits.length) return [];
const seen = new Set<string>();
return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy), ...joinFindings(repoRoot, commits, policy)].filter(f => {
const own = commits.length ? [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy)] : [];
return [...own, ...joinFindings(repoRoot, joinCommits, policy)].filter(f => {
const key = `${f.rule}\0${f.file}\0${f.line ?? ''}\0${f.commit}`;
if (seen.has(key)) return false;
seen.add(key);
@@ -231,20 +243,124 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
return { visibility, source: `gh repo view ${slug}` };
}
export interface SkippedCommit { commit: string; pr?: number; landed?: string }
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>; ignoredWaivers: Array<{ rule: string; path: string; sha: string; commit: string }>;
policyLabels: string[]; commits: number; skipped: SkippedCommit[]; notes: string[]; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>; ignoredWaivers: Array<{ rule: string; path: string; sha: string; commit: string }>;
visibility: Visibility; visibilitySource: string; target: string; error?: string;
}
export interface EvaluateOptions {
repoRoot: string; targets: Array<{ commits: string[]; pushedSha: string }>; baseRef: string | null; baseLabel?: string; remoteUrl: string | null; target: string;
/** The destination remote whose refs/pull/<n>/head prove a commit is already published; null skips that lookup. */
remoteName?: string | null; skip?: boolean;
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: [], ignoredWaivers: [], visibility: 'unknown', visibilitySource: '', target };
return { status: 'error', error, policyLabels: [], commits: 0, skipped: [], notes: [], findings: [], exempt: [], waived: [], ignoredWaivers: [], visibility: 'unknown', visibilitySource: '', target };
}
const MAX_PULL_REFS = 50;
const PULL_BUDGET_MS = 20_000;
const MAX_LANDED_BASE = 2000;
const MAX_LANDED_LISTED = 400;
const PULL_NS = 'refs/gstack/sealed-pull';
function remoteHost(url: string | null): string {
const m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i.exec(url ?? '') ?? /^[^@/:]+@([^:/]+):/.exec(url ?? '');
return m?.[1] ?? 'local';
}
/** Listed commits the destination already has: ancestors of refs/pull/<n>/head for the squashes `(#n)` on the base since the merge-base. */
function publishedViaPulls(o: EvaluateOptions, listed: Set<string>, mbs: string[], notes: string[]): Map<string, number> {
const out = new Map<string, number>();
if (!o.remoteName) return out;
const log = git(o.repoRoot, ['log', '--first-parent', '--format=%s', o.baseRef!, ...mbs.map(b => `^${b}`)]);
if (!log.ok) { notes.push(`could not read the base since the merge-base (${log.err.slice(0, 120)}): published commits are scanned`); return out; }
const prs = [...new Set(log.out.split('\n').map(l => /\(#(\d+)\)\s*$/.exec(l)?.[1]).filter((n): n is string => !!n))];
if (!prs.length) return out;
if (prs.length > MAX_PULL_REFS) { notes.push(`${prs.length} squashed pull requests on the base since the merge-base, over ${MAX_PULL_REFS}: published commits are scanned`); return out; }
const url = git(o.repoRoot, ['remote', 'get-url', o.remoteName]).out.trim() || null;
const deadline = Date.now() + PULL_BUDGET_MS;
const remote = (args: string[]) => {
const left = deadline - Date.now();
if (left <= 0) return { ok: false, out: '', err: `the ${PULL_BUDGET_MS / 1000} s budget ran out` };
try { writeReceipt({ sink: 'sealed-guard', host: remoteHost(url), payloadClass: 'pull-ref-read', bytes: 0, sha256: sha256Hex(args.join(' ')), consent: 'the sealed guard reads the destination\'s own refs/pull/<n>/head for the squashes on its base, to skip commits it already holds', env: o.env }); }
catch (err) { process.stderr.write(`[sealed-guard] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`); }
const r = spawnSync('git', args, { cwd: o.repoRoot, encoding: 'utf8', timeout: left });
return { ok: r.status === 0 && !r.error, out: r.stdout ?? '', err: (r.stderr || String(r.error ?? '')).trim() };
};
const ls = remote(['ls-remote', o.remoteName, ...prs.map(n => `refs/pull/${n}/head`)]);
if (!ls.ok) { notes.push(`could not list refs/pull/<n>/head on ${o.remoteName} (${ls.err.slice(0, 120)}): published commits are scanned`); return out; }
const heads = ls.out.split('\n').map(l => /^([0-9a-f]{40,64})\trefs\/pull\/(\d+)\/head$/.exec(l)).filter((m): m is RegExpExecArray => !!m);
if (!heads.length) return out;
try {
const fetched = remote(['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', o.remoteName, ...heads.map(m => `+refs/pull/${m[2]}/head:${PULL_NS}/${m[2]}`)]);
if (!fetched.ok) { notes.push(`could not fetch refs/pull/<n>/head from ${o.remoteName} (${fetched.err.slice(0, 120)}): published commits are scanned`); return out; }
for (const m of heads) {
const r = git(o.repoRoot, ['rev-list', m[1]!, ...mbs.map(b => `^${b}`)]);
if (!r.ok) continue;
for (const c of r.out.split('\n')) if (listed.has(c) && !out.has(c)) out.set(c, Number(m[2]));
}
return out;
} finally {
git(o.repoRoot, ['update-ref', '--stdin'], heads.map(m => `delete ${PULL_NS}/${m[2]}`).join('\n') + '\n');
}
}
/** Diff text compared after a patch-id match: no commit line, blob ids or hunk line numbers. */
const patchBody = (diff: string) => diff.split('\n').filter(l => !HEX.test(l) && !l.startsWith('index ')).map(l => l.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/, '@@')).join('\n');
/** Listed commits whose own patch (`--verbatim` patch-id, then equal diff text) is a base commit since the merge-base; merges never match. Diffs are read as latin1 so every byte counts: UTF-8 decoding folds invalid bytes into one replacement character. */
function landedOnBase(o: EvaluateOptions, rest: string[], mbs: string[], notes: string[]): Map<string, string> {
const out = new Map<string, string>();
if (!rest.length) return out;
const base = git(o.repoRoot, ['rev-list', '--no-merges', o.baseRef!, ...mbs.map(b => `^${b}`)]);
if (!base.ok) { notes.push(`could not list the base since the merge-base (${base.err.slice(0, 120)}): commits whose patch landed are scanned`); return out; }
const baseCommits = base.out.split('\n').filter(Boolean);
if (!baseCommits.length) return out;
if (baseCommits.length > MAX_LANDED_BASE || rest.length > MAX_LANDED_LISTED) { notes.push(`${baseCommits.length} base and ${rest.length} listed commits since the merge-base, over ${MAX_LANDED_BASE} or ${MAX_LANDED_LISTED}: commits whose patch landed are scanned`); return out; }
const diffArgs = ['diff-tree', '-r', '--root', '-p', '--no-color', '--no-ext-diff', '--no-textconv'];
const ids = (list: string[]): string[][] => {
const d = git(o.repoRoot, [...diffArgs, '--stdin'], list.join('\n') + '\n', 'latin1');
if (!d.ok) throw new Error(`git diff-tree -p failed: ${d.err.slice(0, 200)}`);
const p = git(o.repoRoot, ['patch-id', '--verbatim'], d.out, 'latin1');
if (!p.ok) throw new Error(`git patch-id failed: ${p.err.slice(0, 200)}`);
return p.out.split('\n').map(l => l.split(' ')).filter(f => f.length === 2);
};
const byId = new Map<string, string>();
for (const [id, c] of ids(baseCommits)) if (!byId.has(id!)) byId.set(id!, c!);
const body = (c: string) => { const r = git(o.repoRoot, [...diffArgs, c], undefined, 'latin1'); return r.ok && r.out.trim() ? patchBody(r.out) : null; };
for (const [id, c] of ids(rest)) {
const b = byId.get(id!);
if (!b) continue;
const mine = body(c!);
if (mine !== null && mine === body(b)) out.set(c!, b);
}
return out;
}
/** The listed commits the guard need not rescan, and why (docs/pregate.md#sealed-skip); any failure skips nothing. */
function alreadyPublished(o: EvaluateOptions, listed: string[]): { skipped: SkippedCommit[]; notes: string[] } {
const notes: string[] = [];
if (!o.baseRef || o.skip === false || !listed.length) return { skipped: [], notes };
try {
const mbs: string[] = [];
for (const t of o.targets) {
const r = git(o.repoRoot, ['merge-base', o.baseRef, t.pushedSha]);
if (!r.ok || !r.out.trim()) { notes.push(`no merge-base between ${o.baseRef} and ${t.pushedSha.slice(0, 12)}: every listed commit is scanned`); return { skipped: [], notes }; }
mbs.push(r.out.trim());
}
const pulls = publishedViaPulls(o, new Set(listed), mbs, notes);
const landed = landedOnBase(o, listed.filter(c => !pulls.has(c)), mbs, notes);
return { skipped: [...[...pulls].map(([commit, pr]) => ({ commit, pr })), ...[...landed].map(([commit, b]) => ({ commit, landed: b }))], notes };
} catch (e: any) {
notes.push(`skipping failed (${String(e?.message ?? e).slice(0, 160)}): every listed commit is scanned`);
return { skipped: [], notes };
}
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
@@ -261,10 +377,14 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
policy = unionPolicies(policy, pushed.policy);
}
if (!policy) return { ...blank, status: 'no-policy' };
const commits = [...new Set(o.targets.flatMap(t => t.commits))];
const listed = [...new Set(o.targets.flatMap(t => t.commits))];
const { skipped, notes } = alreadyPublished(o, listed);
const skip = new Set(skipped.map(k => k.commit));
const commits = listed.filter(c => !skip.has(c));
const held = new Set(skipped.filter(k => k.pr !== undefined).map(k => k.commit));
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}` }; }
try { found = scanCommits(o.repoRoot, commits, policy, listed.filter(c => !held.has(c))); }
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, skipped, notes, 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 => {
@@ -288,7 +408,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
const ignoredWaivers = [...ignored.values()];
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, exempt, waived, ignoredWaivers, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
return { status, policyLabels: labels, commits: commits.length, skipped, notes, findings, exempt, waived, ignoredWaivers, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
}
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
@@ -296,6 +416,11 @@ 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[] = [];
const byPr = new Map<number, number>();
for (const k of r.skipped) if (k.pr !== undefined) byPr.set(k.pr, (byPr.get(k.pr) ?? 0) + 1);
for (const [pr, n] of byPr) lines.push(`${context}: skipped ${n} commit(s) the destination already has as refs/pull/${pr}/head`);
for (const k of r.skipped) if (k.landed) lines.push(`${context}: skipped commit ${k.commit.slice(0, 12)} (its patch landed on the base as ${k.landed.slice(0, 12)})`);
for (const n of r.notes) lines.push(`${context}: ${n}`);
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})`);
for (const i of r.ignoredWaivers) {
+43 -14
View File
@@ -15,6 +15,7 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check';
import { versionAtRef } from './restamp';
import { classifyPath } from './review-depth';
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
import { resolveStateRoot } from './state-root';
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
@@ -27,10 +28,11 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s
--keep-current (or --version <current>) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written
finish --base <base> --reviewed-tree <tree> [--reviewed-base <sha>] --docs-post <post.json> [--lane <label>=<command>]... [--build <command>]...
[--trailer <text>] [--docs-rechecked] [--no-lanes-approved] [--context-reviewed <record.json>]
[--docs-requested <path>... --docs-result <the audit's result JSON>]
[--docs-requested <path>... --docs-result <the audit's result JSON>] [--review-loop-exception <json|@file>]
Step 15's release commit, Step 16 stages 1-4 (your builds, the reviewed-tree route, docs freshness,
each lane's receipt run once if stale) and the pregate on the stamped tree; --reviewed-base (the merge base
saved with the reviewed tree) routes paths only the base moved as base=, and an adjacent base edit as context=
saved with the reviewed tree) routes paths only the base moved as base=, and an adjacent base edit as context=;
--review-loop-exception (the record write takes) routes paths the branch touched at fix_check.head (narrowed to fix_check.files when listed) and changed since as accepted=
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved]
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u
@@ -159,8 +161,8 @@ function commitRelease(cwd: string, release: string[], trailer: string | undefin
}
/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context', string[]>;
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string }, r: Report): Routed | null {
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>;
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null {
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; }
const changedSet = touchedPaths(cwd, reviewed, now);
@@ -179,7 +181,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now };
touched = new Set([...a, ...b]);
}
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [] };
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] };
for (const p of [...changedSet].sort()) {
const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed';
by[verdict === 'same' ? 'base' : release.includes(p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
@@ -194,7 +196,22 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
by.context = by.context.filter(p => !packetBody.over.includes(p));
by.tests = by.tests.filter(p => !packetBody.over.includes(p));
}
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}`);
const loop = opts.loop;
if (loop) {
const head = loop.fix_check.head;
const since = touchedPaths(cwd, head, now);
const fork = git(cwd, ['merge-base', baseRef, head]).stdout.trim();
const atHead = fork ? touchedPaths(cwd, fork, head) : null;
if (!since || !atHead) { r.stop(`git could not list the paths changed since fix-check ${head.slice(0, 12)} or the paths the branch touched at it against ${baseRef}: stage 2 is blocked`); return null; }
const listed = loop.fix_check.files;
const unscanned = [...by.behavior, ...by.docs].filter(isTestCode);
const forced = new Set([...gated, ...(packetBody?.over ?? []), ...(gateEditedPaths(cwd, reviewed, now, unscanned) ?? unscanned)]);
by.accepted = [...by.behavior, ...by.docs].filter(p => since.has(p) && atHead.has(p) && (!listed || listed.includes(p)) && !forced.has(p)).sort();
by.behavior = by.behavior.filter(p => !by.accepted.includes(p));
by.docs = by.docs.filter(p => !by.accepted.includes(p));
}
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${loop ? ` accepted=${by.accepted.length}` : ''}`);
if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`);
if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return null; }
const scoped = [...by.context, ...by.tests].sort();
if (scoped.length && sides) {
@@ -225,7 +242,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
* version, each against the audited bytes; GST-906), and --docs-requested fixes the audit's own result asked for do not
* stale the audit (GST-391).
*/
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, r: Report): boolean {
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, loopAccepted: readonly string[] | undefined, r: Report): boolean {
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
let stale: string[] | null = null;
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
@@ -277,10 +294,11 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout);
return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now);
});
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[], accepted: [] as string[] };
carried.accepted = stale.filter(p => loopAccepted?.includes(p) && !carried.tests.includes(p) && !carried.base.includes(p));
let audit: any = null;
try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; }
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q))) {
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q) && !carried.accepted.includes(q))) {
const asked = [...(Array.isArray(audit?.blockers) ? audit.blockers : []), ...(Array.isArray(audit?.decisions) ? audit.decisions : [])].filter((s: unknown) => typeof s === 'string');
const token = new RegExp(`(^|[\\s\`'"(\\[])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s\`'")\\]]|[,.:;](?:$|\\s))`);
const stat = git(cwd, ['diff', '--numstat', '--no-renames', record.head, '--', `:(literal)${p}`]).stdout.trim().split(/\s+/);
@@ -295,8 +313,8 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
carried.requested.push(p);
}
if (carried.requested.length && bin(cwd, 'gstack-ship-receipt', ['docs-caps', '--base', base], 60_000).status !== 0) { r.stop('a --docs-requested fix breaks a doc cap (gstack-ship-receipt docs-caps): trim the file, then rerun finish'); return false; }
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p));
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length})`;
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p) && !carried.accepted.includes(p));
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length}${loopAccepted ? ` accepted=${carried.accepted.length}` : ''})`;
const exception = record.exception ? ` (blocked; documentation exception accepted by ${record.exception.accepted_by} ${record.exception.date} via ${record.exception.via})` : '';
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}${exception}`}`);
if (left.length) { r.stop('documentation inputs changed since the accepted audit: use stage 3 recovery (the remaining re-audit, a fix the audit\'s result asked for via --docs-requested <path> --docs-result <audit-id>-result.json, or a named risk); never refresh hashes'); return false; }
@@ -308,6 +326,17 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
const base = flag('--base')!;
const r = new Report();
const list = lanes(argv) as { label: string; command: string }[];
let loop: ReviewLoopException | undefined;
const loopArg = flag('--review-loop-exception');
if (loopArg !== undefined) {
let raw: unknown;
try { raw = JSON.parse(loopArg.startsWith('@') ? fs.readFileSync(path.resolve(cwd, loopArg.slice(1)), 'utf8') : loopArg); } catch (e) { return r.stop(`--review-loop-exception: ${firstLine((e as Error).message)}`); }
const parsed = parseReviewLoopException(raw);
if (!parsed.ok) return r.stop(`--review-loop-exception: ${parsed.message}`);
if (Array.isArray((raw as { fix_check?: { files?: unknown } }).fix_check?.files) && !parsed.exception.fix_check.files) return r.stop('--review-loop-exception: fix_check.files lists no paths; omit it to accept the paths the branch touched at fix_check.head');
if (git(cwd, ['merge-base', '--is-ancestor', parsed.exception.fix_check.head, 'HEAD']).status !== 0) return r.stop(`--review-loop-exception: fix_check.head ${parsed.exception.fix_check.head.slice(0, 12)} is not HEAD or an ancestor of it`);
loop = parsed.exception;
}
const policy = shipPolicy(cwd, base);
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD', base), flag('--trailer'), r)) return r;
for (const cmd of all('--build')) {
@@ -317,9 +346,9 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
}
if (!all('--build').length) r.add('BUILD: none passed (record the inspected sources, or pass --build per declared command)');
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!, base);
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed') }, r);
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop }, r);
if (!routed) return r;
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), r)) return r;
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), loop ? routed.accepted : undefined, r)) return r;
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
const allow = evidenceExempt(release).join(',');
const check = (l: { label: string; command: string }) => bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24', '--allow-paths', allow], 60_000).stdout;
@@ -343,7 +372,7 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
r.add(`PREGATE: ${verdict} (rerun on the stamped tree)`);
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the stamped tree: fix it${/uncleared/.test(verdict) ? ', or gstack-pregate clear with the remote run URL' : ''}, then rerun finish`);
}
return r.next('Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17\'s guard block and gstack-ship-release push');
return r.next(`Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17's guard block and gstack-ship-release push${loop ? '; Step 18 records the same exception with gstack-ship-receipt write --review-loop-exception' : ''}`);
}
export function push(argv: readonly string[], cwd: string): Report {