Merge origin/main and stamp v1.91.316.0 (train 46 slot 3, #3593)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 10:13:28 +00:00
commit 99730eaf68
48 files changed
+1749 -174

No files matched your search

+18 -5
View File
@@ -52,6 +52,11 @@ export type Env = NodeJS.ProcessEnv;
const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
/** A branch this checkout knows: a local head or an origin remote-tracking ref. */
export function branchExists(cwd: string, branch: string): boolean {
return [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`].some(ref => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).status === 0);
}
/** One contained path segment for a branch: readable prefix plus a hash of the exact ref. */
export function branchKey(branch: string): string {
const readable = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, 60) || 'branch';
@@ -226,13 +231,15 @@ export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
}
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string }
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string;
carried_gate_from?: string; carried_gate_rev?: number; carried_answered_by?: string; carried_under?: string; carry_check?: string }
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
/**
* Rows for every answered gate in an autoplan run: one row per answer event (gate_rev, time, answerer), covering
* the items it settled. Carried-forward answers were recorded when first given and are skipped. Ids are stable,
* the items it settled. Item answers carried from an earlier revision were recorded when first given and are skipped;
* a whole gate carried forward (lib/autoplan-carry.ts) gets its own row naming the revision its words came from. Ids are stable,
* so a rerun (or a retry after an interrupted import) adds nothing twice.
*/
export function importAutoplan(runDir: string, branch: string, by?: string): ApprovalRow[] | RecordError {
@@ -265,10 +272,16 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended);
const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending');
const answerer = first.answered_by ?? 'unknown';
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`;
const carried = p1?.carried_gate_from ? p1 : undefined;
const relayed = carried?.carried_answered_by && carried.carried_answered_by !== 'human' ? `, relayed by ${carried.carried_answered_by}` : '';
const who = carried ? `${by ?? 'the owner'} (words from gate_rev ${carried.carried_gate_rev}${relayed}), carried forward by gstack-autoplan`
: answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
const unseen = items.filter(r => r.carried_under).map(r => stripRef(r.title ?? ''));
const checked = [...new Set(items.map(r => r.carry_check).filter((c): c is string => !!c))];
const scope = [`the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`,
unseen.length ? `taken under "do it", not seen by the owner: ${unseen.join('; ')}` : '', checked.length ? `post-cap delta checked by ${checked.join(', ')}` : ''].filter(Boolean).join('; ');
const built = buildRow({
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${carried ? `gate_rev ${rev} carried forward from gate_rev ${carried.carried_gate_rev}, ` : ''}${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
words: first.owner_reply ?? first.reply ?? '(no words recorded)',
by: who.slice(0, CAPS.by),
scope: scope.slice(0, CAPS.scope),
+252
View File
@@ -0,0 +1,252 @@
/**
* autoplan-carry — carry the owner's last answer to a later gate revision (GSTA-155,
* docs/unattended.md#carry-forward). When a gate_rev N+1 asks nothing the owner has not
* already decided, the runner answers it with the owner's recorded gate_rev N words instead
* of a second round trip, and records that it did (decisions.jsonl `carried_gate_from`,
* journal `gate_carried`, `<out>/decisions.tsv`). It never invents an answer: every
* carried answer is the owner's own earlier words, and every refusal says which condition failed.
*
* A carry needs all of: carry not turned off (`--no-carry-forward`); gate_rev N approved (p1 a or b)
* with the owner's words recorded, and not itself a carry; every change accepted at N
* incorporated or settled; the plan file unchanged since the gate or the revise that took those
* changes, and the reviewed plan equal to it; no open urgent finding; and every new item already
* carried. Two kinds of new item do not block: items a "do it" covers (the owner's words are
* "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
* capped-delta item recommending "accept", which first gets one reviewer on just the delta
* (`CARRY_CHECK:`, answered with `gstack-autoplan check`); a High or Critical finding there
* means no carry, and the gate goes to the owner as before.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { attemptEstimate } from './autoplan-budget';
import { lineDiff } from './autoplan-confirm';
import { answerGate } from './autoplan-gate';
import { driftLine } from './autoplan-plan-text';
import { BOUNDARY, RESULT_FORMAT, REVIEWER_TRAILER } from './autoplan-prompts';
import { parseReviewResult } from './autoplan-reconcile';
import { FILES, RunError, journal, now, readDecisions, readJournal, readState, writeDecisions, writeState, type RunState } from './autoplan-state';
import { appendTrailRow } from './decision-trail';
import { decisionId, parseReply, type GateItem, type GateList } from './gate-list';
import { readJsonl } from './headless-artifacts';
import { modelFamily } from './outside-voice-runner';
import { release, reserve, settle } from './spend-ledger';
export const DEFAULT_CARRY_CHECK_CAP_USD = 5;
const HIGH = ['Critical', 'High'];
const DO_IT = /^\s*(?:(?![^:]*\bcaptain\b)[a-z]+(?: [a-z]+){0,2}:\s*)?do it[\s.!]*$/i;
/** The owner's verbatim words are a "do it" (optionally after their name), not a Captain's words or an item token (`p3b: do it`) naming one. */
export const isDoIt = (words: string | undefined): boolean => !!words && DO_IT.test(words);
export interface CarryCheck {
attempt: string; prompt: string; result: string; delta_sha256: string; items: string[]; estimate_usd: number;
status: 'dispatched' | 'passed' | 'failed' | 'unavailable'; model?: string; max_severity?: string; findings?: number; at: string;
}
export interface CarryRecord {
gate_rev: number; from_gate_rev?: number; verdict: 'carried' | 'refused' | 'check_pending';
reason: string; words?: string; under_do_it?: string[]; check?: CarryCheck; lines: string[]; at: string;
}
const sha256 = (text: string | Buffer) => createHash('sha256').update(text).digest('hex');
const planFile = (state: RunState) => state.plan_input ?? state.plan_source;
const fileSha = (file: string) => (fs.existsSync(file) ? sha256(fs.readFileSync(file)) : undefined);
export const atGateCopy = (out: string, rev: number) => path.join(out, `plan-at-gate.r${rev}.md`);
/** What finishGate journals beside gate_written: the plan file's bytes and a copy of the reviewed plan the owner is asked about. */
export function gateEvidence(state: RunState, rev: number): { source_file_sha256?: string } {
if (fs.existsSync(state.active_plan)) fs.copyFileSync(state.active_plan, atGateCopy(state.out, rev));
return { source_file_sha256: fileSha(planFile(state)) };
}
/** The lines the latest carry decision printed, for the command that wrote this gate revision. */
export function carryLines(state: RunState): string[] {
const c = state.carry;
return c && c.gate_rev === state.gate_rev ? c.lines : [];
}
function record(state: RunState, c: Omit<CarryRecord, 'at'>): CarryRecord {
const full = { ...c, at: now() };
state.carry = full;
journal(state.out, { event: c.verdict === 'carried' ? 'gate_carried' : c.verdict === 'check_pending' ? 'carry_check_dispatched' : 'carry_refused', gate_rev: c.gate_rev, from_gate_rev: c.from_gate_rev ?? null, reason: c.reason, ...(c.under_do_it?.length ? { under_do_it: c.under_do_it } : {}), ...(c.check ? { check: c.check.attempt, check_status: c.check.status } : {}) });
appendTrailRow(path.join(state.out, 'decisions.tsv'), ['gate', c.verdict === 'carried' ? `carried gate_rev ${c.gate_rev} from gate_rev ${c.from_gate_rev}` : c.verdict === 'check_pending' ? `delta check before carrying gate_rev ${c.gate_rev}` : `no carry for gate_rev ${c.gate_rev}`,
c.reason, c.check ? path.basename(c.check.result) : 'decisions.jsonl', c.check?.status ?? c.verdict]);
return full;
}
const refuse = (state: RunState, rev: number, from: number | undefined, reason: string) => record(state, { gate_rev: rev, from_gate_rev: from, verdict: 'refused', reason, lines: [`CARRY: no (${reason})`] });
/** Drop a delta check still waiting for its reviewer (the owner answered, the plan was revised, a new gate was written) and release its reservation. */
export function voidPendingCheck(state: RunState, why: string): string[] {
const c = state.carry;
if (c?.verdict !== 'check_pending' || !c.check) return [];
release(path.join(state.out, FILES.spend), c.check.attempt);
const out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check ${c.check.attempt} void: ${why}`);
out.check = { ...c.check, status: 'unavailable' };
return out.lines;
}
/** The reviewed plan's changes since the copy taken when the owner was asked at `rev`, and their hash. */
function deltaSince(state: RunState, rev: number): { diff: string; sha: string } | undefined {
const before = atGateCopy(state.out, rev);
if (!fs.existsSync(before)) return undefined;
const diff = lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(state.active_plan, 'utf8')).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n');
return { diff, sha: sha256(diff) };
}
interface Source { id: string; gate_rev: number; words: string; by?: string }
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number };
/** Every condition but the delta check; returns why not, or what the carry takes. */
function assess(state: RunState, prev: GateList, list: GateList): Plan {
const from = prev.gate_rev;
if (state.carry_forward === false) return { reason: 'carry-forward is off for this run (--no-carry-forward)', from };
const p1 = readDecisions(state.out).find(r => r.gate_rev === from && (r.label ?? '').toLowerCase() === 'p1');
if (!p1 || p1.status === 'pending' || !p1.answered_at) return { reason: `gate_rev ${from} was not answered`, from };
const unanswered = readDecisions(state.out).find(r => r.gate_rev === from && r.status === 'pending');
if (unanswered) return { reason: `gate_rev ${from} left ${unanswered.label ?? unanswered.id} unanswered`, from };
if (p1.chosen !== 'a' && p1.chosen !== 'b') return { reason: `gate_rev ${from} answer was p1${p1.chosen}, not an approval`, from };
if (p1.carried_gate_from) return { reason: `gate_rev ${from} was itself carried; the owner answers this one`, from };
if (parseReply(p1.reply ?? '', prev).mode !== 'rec') return { reason: `gate_rev ${from} was answered with item tokens, not rec or "do it"`, from };
const words = p1.owner_reply ?? (p1.answered_by === 'human' ? p1.reply : undefined);
if (!words?.trim()) return { reason: `no owner words recorded at gate_rev ${from}`, from };
const amendments = (state.amendments ?? []).filter(a => a.gate_rev === from);
const open = amendments.find(a => a.status !== 'incorporated' && a.status !== 'settled');
if (open) return { reason: `accepted change ${open.decision} is ${open.status}`, from };
const journalRows = readJournal(state.out);
const at = journalRows.map(r => r.event === 'gate_written' && r.gate_rev === from).lastIndexOf(true);
let expected: string | undefined = at >= 0 ? journalRows[at]!.source_file_sha256 : undefined;
for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') expected = r.source_file_sha256;
if (!expected) return { reason: 'no plan evidence recorded for the gate or the revise (a run written before carry-forward)', from };
if (fileSha(planFile(state)) !== expected) return { reason: `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`, from };
if (driftLine(state, 'PLAN_SOURCE_CHANGED')) return { reason: 'the reviewed plan differs from the plan file', from };
const urgent = readJsonl(path.join(state.out, 'findings.jsonl')).rows.find(r => (r.urgent === true || r.outside_plan === true) && !r.settled_by);
if (urgent) return { reason: `urgent finding open: ${String(urgent.title ?? urgent.id)}`, from };
const doIt: GateItem[] = []; const deltas: GateItem[] = [];
for (const item of list.items) {
if (item.id === 'p1' || item.carried) continue;
const option = item.options.find(o => o.key === item.recommended);
if (item.cap?.kind === 'delta' && !option?.changes_plan && item.recommended === 'a') { deltas.push(item); continue; }
if (isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
return { reason: `new item ${item.id}: ${item.title}`, from };
}
return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by }, doIt, deltas };
}
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
export function evaluateCarry(state: RunState, prev: GateList | undefined, list: GateList): CarryRecord | undefined {
voidPendingCheck(state, `gate_rev ${list.gate_rev} was written`);
if (!prev || list.gate_rev < 2) return undefined;
try {
const plan = assess(state, prev, list);
if ('reason' in plan) return refuse(state, list.gate_rev, plan.from, plan.reason);
if (plan.deltas.length) return dispatchCheck(state, prev, list, plan);
return carry(state, list, plan.source, plan.doIt, plan.deltas);
} catch (e: any) {
if (readDecisions(state.out).some(r => r.gate_rev === list.gate_rev && r.answered_at && !r.carried_from)) throw e;
return refuse(state, list.gate_rev, prev.gate_rev, `error: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}`);
}
}
function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Extract<Plan, { source: Source }>): CarryRecord {
const rev = list.gate_rev;
const cap = state.carry_check_cap_usd ?? DEFAULT_CARRY_CHECK_CAP_USD;
const estimate = attemptEstimate(state, undefined, process.env).usd;
if (estimate > cap) return refuse(state, rev, prev.gate_rev, `delta check estimate ${estimate.toFixed(2)} is over the per-gate cap ${cap.toFixed(2)} (--carry-check-cap)`);
const delta = deltaSince(state, prev.gate_rev);
if (!delta) return refuse(state, rev, prev.gate_rev, `no copy of the plan the owner answered at gate_rev ${prev.gate_rev}`);
const current = fs.readFileSync(state.active_plan, 'utf8');
const { diff, sha: deltaSha } = delta;
const attempt = `${state.run}-c${rev}`;
const prompt = path.join(state.out, `carry-check-g${rev}-prompt.md`);
const result = path.join(state.out, `carry-check-g${rev}.md`);
try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: 'carry-check' }); }
catch (e: any) { return refuse(state, rev, prev.gate_rev, `delta check does not fit the run's spend cap: ${e?.message ?? e}`); }
const accepted = (state.amendments ?? []).filter(a => a.gate_rev === prev.gate_rev).map(a => `- ${a.title} -> ${a.text}`).join('\n') || '- (none recorded)';
fs.writeFileSync(prompt, [BOUNDARY, '',
`You are checking one edit to a reviewed plan before gstack reuses the owner's approval of gate_rev ${prev.gate_rev} for gate_rev ${rev}. The owner accepted these changes:`, accepted, '',
'The plan was edited after that answer, but the phases that would re-review the edit were at their round cap, so no reviewer has read it. Read the delta and say whether it implements the accepted changes, adds nothing the owner did not accept (scope, cost or direction), and leaves the plan correct. Rate a finding High or Critical only when the edit adds an unaccepted change, contradicts an accepted one, or would make the plan fail a test, break production, or lose data or secrets; anything else is Medium or lower.', '',
`Delta (reviewed plan at gate_rev ${prev.gate_rev} -> now; + added, - removed; SHA-256 ${deltaSha}):`, '```diff', diff, '```', '',
'The complete current plan follows as context.', '', current,
REVIEWER_TRAILER, RESULT_FORMAT('carry-check', deltaSha, result)].join('\n'));
const check: CarryCheck = { attempt, prompt, result, delta_sha256: deltaSha, items: plan.deltas.map(i => i.id), estimate_usd: estimate, status: 'dispatched', at: now() };
const family = state.native_model ? `not ${modelFamily(state.native_model)}` : 'differs from the plan reviewers where available';
return record(state, { gate_rev: rev, from_gate_rev: prev.gate_rev, verdict: 'check_pending', words: plan.source.words, under_do_it: plan.doIt.map(i => i.id), check,
reason: `only an unreviewed post-cap delta is new (${check.items.join(',')}); one reviewer reads it first`,
lines: [`CARRY_CHECK: attempt=${attempt} items=${check.items.join(',')} prompt=${prompt} result=${result} model_family=${family} estimate=${estimate.toFixed(2)} cap=${cap.toFixed(2)}`,
`NEXT: dispatch one reviewer on the prompt, then gstack-autoplan check --out ${state.out} --attempt ${attempt} --result ${result} --model <id> (or --unavailable "<reason>" to relay the gate as today)`] });
}
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord {
const rev = list.gate_rev;
const p1 = list.items.find(i => i.id === 'p1')!;
const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } });
const marks = new Map<string, Record<string, string>>([...doIt.map(i => [decisionId(list.run, rev, i.id), { carried_under: 'do it' }] as const), ...deltas.map(i => [decisionId(list.run, rev, i.id), { carry_check: check?.attempt ?? '' }] as const)]);
if (marks.size) writeDecisions(state.out, readDecisions(state.out).map(r => (marks.has(r.id) ? { ...r, ...marks.get(r.id) } : r)));
const lines = [`CARRIED: gate_rev=${rev} <- gate_rev=${source.gate_rev} words=${JSON.stringify(source.words)} by=${source.by ?? 'unknown'} reason=${reason}`,
...doIt.map(i => `CARRIED_UNDER_DO_IT: ${i.id} ${i.title} -> ${i.options.find(o => o.key === i.recommended)?.text ?? i.recommended} (the owner has not seen this item)`),
...answered.lines];
return record(state, { gate_rev: rev, from_gate_rev: source.gate_rev, verdict: 'carried', reason, words: source.words, under_do_it: doIt.map(i => i.id), ...(check ? { check } : {}), lines });
}
export interface CheckOptions { out: string; attempt?: string; result?: string; model?: string; unavailable?: string; sameFamily?: string }
/** `gstack-autoplan check`: bind the delta reviewer's result (or its absence) and carry, or leave the gate for the owner. */
export function submitCarryCheck(o: CheckOptions): { state: RunState; lines: string[] } {
const state = readState(path.resolve(o.out));
const c = state.carry;
if (!c || c.verdict !== 'check_pending' || !c.check || state.status !== 'gate_pending' || c.gate_rev !== state.gate_rev) throw new RunError('PHASE_NOT_AWAITING', `no delta check is pending (run is ${state.status}, gate_rev=${state.gate_rev ?? 'none'})`, 3);
const check = c.check;
if (o.attempt !== check.attempt) throw new RunError('ATTEMPT_MISMATCH', `pass --attempt ${check.attempt}`, 3);
const stale = readDecisions(state.out).some(r => r.gate_rev === c.gate_rev && r.answered_at && !r.carried_from) ? `the owner answered gate_rev ${c.gate_rev}`
: deltaSince(state, c.from_gate_rev ?? 0)?.sha !== check.delta_sha256 ? 'the plan changed after the delta check was dispatched' : undefined;
if (stale) {
const lines = voidPendingCheck(state, stale);
writeState(state);
return { state, lines };
}
const gateFile = path.join(state.out, 'gate.json');
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
const spend = path.join(state.out, FILES.spend);
let out: CarryRecord;
if (o.unavailable !== undefined) {
release(spend, check.attempt);
list.carry_check = { attempt: check.attempt, model: 'none', verdict: 'unavailable', max_severity: 'none', findings: 0, at: now() };
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check unavailable: ${o.unavailable}`);
out.check = { ...check, status: 'unavailable' };
} else {
if (!o.result || !o.model) throw new RunError('RESULT_RECEIPT_MISSING', 'check needs --result <file> and --model <id>, or --unavailable "<reason>"', 3);
if (!fs.existsSync(o.result)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${o.result}`);
const family = modelFamily(o.model);
const author = state.native_model ? modelFamily(state.native_model) : undefined;
if (!o.sameFamily && (family === 'unknown' || family === author)) throw new RunError('MODEL_FAMILY_CONFLICT', `${o.model} is ${family === 'unknown' ? 'of no recognized family' : `the plan reviewers' family (${family})`}; use another family, or pass --same-family "<why none is available>"`, 3);
const text = fs.readFileSync(o.result, 'utf8');
const parsed = parseReviewResult(text, { phase: 'carry-check', sha256: check.delta_sha256 });
if (!parsed.receipt.ok) throw new RunError('RESULT_RECEIPT_MISSING', parsed.errors[0]!);
if (!parsed.fenceFound || parsed.errors.length) throw new RunError('RESULT_FINDINGS_MISSING', parsed.errors.join('; '));
fs.writeFileSync(path.join(state.out, `carry-check-g${c.gate_rev}.bound.md`), text, process.platform === 'win32' ? {} : { mode: 0o444 });
settle(spend, check.attempt, 'unknown');
const order = ['Informational', 'Low', 'Medium', 'High', 'Critical'];
const max = parsed.findings.map(f => String(f.severity)).reduce((a, b) => (order.indexOf(b) > order.indexOf(a) ? b : a), 'none');
const blocking = parsed.findings.find(f => HIGH.includes(String(f.severity)));
const done: CarryCheck = { ...check, status: blocking ? 'failed' : 'passed', model: o.model, max_severity: max, findings: parsed.findings.length, at: now() };
list.carry_check = { attempt: check.attempt, model: o.model + (o.sameFamily ? ` (same family: ${o.sameFamily})` : ''), verdict: blocking ? 'failed' : 'passed', max_severity: max, findings: parsed.findings.length, at: done.at };
if (blocking) for (const item of list.items) if (check.items.includes(item.id)) (item.flags ??= []).push(`carry check ${o.model}: ${blocking.severity} ${blocking.title}`);
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
if (blocking) {
out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check found ${blocking.severity}: ${blocking.title}`);
out.check = done;
} else {
const plan = assess(state, { ...list, gate_rev: c.from_gate_rev! } as GateList, list);
if ('reason' in plan) { out = refuse(state, c.gate_rev, c.from_gate_rev, plan.reason); out.check = done; }
else {
out = carry(state, list, plan.source, plan.doIt, plan.deltas, done);
out.lines.unshift(`CARRY_CHECK: passed attempt=${check.attempt} model=${o.model} findings=${done.findings} highest=${max}`);
}
}
}
writeState(state);
return { state, lines: out.lines };
}
+37 -10
View File
@@ -24,7 +24,8 @@ import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHAN
import type { Phase } from './autoplan-prompts';
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, voiceTerminal, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state';
import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget';
import { capyRelayLine } from './captain-brief';
import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry';
import { buildEli5, capyRelayLine } from './captain-brief';
import { previousReview, reviewRows, settledIds } from './autoplan-confirm';
import { phaseClose } from './autoplan-timing';
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
@@ -237,7 +238,8 @@ export function buildGate(state: RunState, gateRev: number): GateList {
}
items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` }));
items.push(planApproval(state, items));
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 };
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0,
...(fs.existsSync(state.active_plan) ? { eli5: buildEli5(fs.readFileSync(state.active_plan, 'utf8'), state.active_plan) } : {}) };
const problems = validateGateList(list);
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
return list;
@@ -285,9 +287,29 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
...[capyRelayLine(state.out)].filter((l): l is string => !!l)];
}
/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */
/** The gate list the owner last answered: gate.json, or its archive (`gate.r<rev>[.v<k>].json`) once a reopen moved it. */
function previousGate(out: string, rev: number): GateList | undefined {
if (rev < 1) return undefined;
const re = new RegExp(`^gate\\.r${rev}(?:\\.v(\\d+))?\\.json$`);
const archived = fs.readdirSync(out).map(f => [f, re.exec(f)] as const).filter(([, m]) => m).sort((a, b) => Number(b[1]![1] ?? 1) - Number(a[1]![1] ?? 1)).map(([f]) => f);
for (const name of ['gate.json', ...archived]) {
const file = path.join(out, name);
if (!fs.existsSync(file)) continue;
const list = JSON.parse(fs.readFileSync(file, 'utf8')) as GateList;
if (list.gate_rev === rev) return list;
}
return undefined;
}
/**
* Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged
* answered proposals carry forward, and a later revision that asks nothing new carries the owner's last answer
* (lib/autoplan-carry.ts; its lines are on `carryLines(state)`).
*/
export function finishGate(state: RunState): GateList {
const gateRev = (state.gate_rev ?? 0) + 1;
const gateFile = path.join(state.out, 'gate.json');
const prev = previousGate(state.out, state.gate_rev ?? 0);
const list = buildGate(state, gateRev);
const kept = readDecisions(state.out).filter(r => r.gate_rev !== gateRev);
const answered = new Map(kept.filter(r => r.origin && r.chosen != null && r.status !== 'pending').map(r => [r.origin!, r]));
@@ -295,7 +317,7 @@ export function finishGate(state: RunState): GateList {
const prev = item.origin ? answered.get(item.origin) : undefined;
if (prev && item.options.some(o => o.key === prev.chosen)) item.carried = { from: prev.carried_from ?? prev.id, chosen: prev.chosen!, gate_rev: prev.gate_rev };
}
fs.writeFileSync(path.join(state.out, 'gate.json'), JSON.stringify(list, null, 2) + '\n');
fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n');
const rows = decisionRows(list).map(row => {
const item = list.items.find(i => decisionId(list.run, gateRev, i.id) === row.id)!;
if (!item.carried) return row;
@@ -308,7 +330,8 @@ export function finishGate(state: RunState): GateList {
state.status = 'gate_pending';
state.gate = { rev: gateRev, items: list.items.length, written_at: now() };
exportRun(state);
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id) });
journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id), ...gateEvidence(state, gateRev) });
evaluateCarry(state, prev, list);
return list;
}
@@ -321,6 +344,8 @@ export interface AnswerOptions {
recHash?: string;
/** The owner's verbatim words; required for every `--by agent:<id>` answer. */
ownerReply?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision and the owner's words it reuses. Replaces ownerReply and its re-parse. */
carry?: { from: string; gate_rev: number; words: string; by?: string; reason: string };
}
/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */
@@ -361,7 +386,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3);
if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3);
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined && !o.carry) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3);
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
const rows = readDecisions(state.out);
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
@@ -372,12 +397,13 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3);
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3);
if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
const ownerParsed = o.ownerReply !== undefined && !o.carry ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.carry ? { owner_reply: o.carry.words, carried_gate_from: o.carry.from, carried_gate_rev: o.carry.gate_rev, carry_reason: o.carry.reason, ...(o.carry.by ? { carried_answered_by: o.carry.by } : {}) }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
const plan = parsed.choices.find(c => c.id === 'p1');
const lines: string[] = [];
const lines: string[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
const answeredAt = now();
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => {
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
@@ -399,7 +425,8 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []);
record();
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance };
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
const answerClass = plan.chosen !== 'a' && plan.chosen !== 'b' ? `p1${plan.chosen}` : changing.length ? 'plan-changes' : overrides.length ? 'overrides' : 'as-recommended';
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, answer_class: answerClass, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance });
if (plan.chosen === 'c') {
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
+7 -2
View File
@@ -17,10 +17,12 @@
* written and `revise_intent` journaled before anything moves; `resume`
* (recoverRevise) completes an interrupted revise from the stage.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { carryLines, voidPendingCheck } from './autoplan-carry';
import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
@@ -109,8 +111,10 @@ export function decisionSeverity(state: RunState, decision: string): string {
function approveOrRegate(state: RunState, at: string): string[] {
if (pendingDeltas(state).length) {
finishGate(state);
const carried = carryLines(state);
if (state.status !== 'gate_pending') return carried;
const escalate = escalateLine(state);
return [`GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
return [...carried, `GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
@@ -176,6 +180,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
const delta = capped.length || quiet ? diffStat(lineDiff(fs.readFileSync(state.active_plan, 'utf8'), fs.readFileSync(path.join(staging, FILES.active), 'utf8'))) : { added: 0, removed: 0 };
const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle });
voidPendingCheck(state, 'the plan was revised');
const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : [];
for (const phase of capped) {
@@ -199,7 +204,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : pendingDeltas(state).length ? 'gate_pending' : 'complete';
// A quiet revise that ends at gate_pending still has an earlier capped delta waiting: the owner gets a new gate for it.
const regate = (capped.length > 0 || quiet) && state.status === 'gate_pending';
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null };
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, source_file_sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), reopened: reopen, added, at_gate_rev: atGate ?? null };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
publish(state, staging, renames, committed, settled);
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`,
+22 -6
View File
@@ -20,6 +20,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate';
import { carryLines } from './autoplan-carry';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
@@ -71,7 +72,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number }
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
export function initRun(o: InitOptions, lines: string[] = []): RunState {
@@ -121,6 +122,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
...(o.light ? { light: true } : {}),
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
};
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
@@ -176,6 +178,17 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) });
}
/** The first next's price line: the most the review can reserve at this scope and round cap (a reservation total, not a spend ceiling). */
export function reviewEstimateLine(state: RunState, perVoiceUsd: number): string {
const voices = state.phase_order.map(p => (!state.outside_enabled || (p === 'ceo' && state.scope.docs_only) ? 1 : 2));
const perRound = voices.reduce((a, b) => a + b, 0);
const rounds = state.max_rounds ?? null;
const per = perVoiceUsd > 0 ? perVoiceUsd.toFixed(2) : 'unknown';
const attempts = rounds === null ? `${perRound} per round, rounds uncapped` : `<=${perRound * rounds}`;
const total = rounds === null || perVoiceUsd <= 0 ? 'unknown' : `<=${(perRound * rounds * perVoiceUsd).toFixed(2)}`;
return `REVIEW_ESTIMATE: phases=${state.phase_order.join(',')} voices=${voices.join('+')} rounds${rounds === null ? '=uncapped' : `<=${rounds}`} attempts${attempts.startsWith('<=') ? attempts : `=${attempts}`} per_voice=${per} reservations${total === 'unknown' ? '=unknown' : total} (a reservation total, not a spend ceiling; actual host spend stays unknown; round grants at the gate add more)`;
}
export function nextStep(o: NextOptions): NextResult {
const state = readState(path.resolve(o.out));
const lines: string[] = [];
@@ -193,7 +206,7 @@ export function nextStep(o: NextOptions): NextResult {
lines.push(...closeDeferred(state, phase, { reason: `round cap: round ${roundOf(state, phase)} would pass max ${roundCap(state, phase)}`, by: 'runner', cause: 'cap' }).lines);
phase = currentPhase(state);
}
if (!phase) { finishGate(state); writeState(state); return result; }
if (!phase) { finishGate(state); lines.push(...carryLines(state)); writeState(state); return result; }
const first = !state.phase_order.some(p => state.phases[p]?.snapshot);
openPhase(state, phase, o.cwd);
const ps = state.phases[phase]!;
@@ -208,6 +221,7 @@ export function nextStep(o: NextOptions): NextResult {
const prompts = ps.prompts!;
const estimate = attemptEstimate(state, o.estimateUsd, process.env);
if (estimate.line) lines.push(estimate.line);
if (first) lines.push(reviewEstimateLine(state, estimate.usd));
const fresh = (['native', 'outside'] as const).filter(voice => { const v = voiceState(state, phase!, voice); const open = openAttempt(v); return !voiceTerminal(v) && (!open || (open.state === 'execution_unknown' && o.redispatch?.includes(open.attempt))); });
const before = summarize(readLedger(path.join(state.out, FILES.spend), state.spend_cap_usd));
if (state.spend_cap_usd !== null && fresh.length && before.worst_case_usd + fresh.length * estimate.usd > state.spend_cap_usd + 1e-9) {
@@ -369,8 +383,9 @@ export function submitResult(o: SubmitOptions): SubmitResult {
function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] {
const close = closePhase(state, phase, { overridesFile: reconcile });
if (!currentPhase(state)) finishGate(state); else state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`];
const gated = !currentPhase(state);
if (gated) finishGate(state); else state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...(gated ? carryLines(state) : [])];
}
/**
@@ -451,14 +466,15 @@ export function resumeRun(out: string): ResumeResult {
if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
}
const stopped = resumeStop(state);
if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state);
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status);
if (gated) finishGate(state);
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v))));
state.status = anyOpen ? 'awaiting_result' : 'running';
}
journal(out, { event: 'resumed', unknown, rebound, status: state.status });
writeState(state);
const lines = [...recovered, ...stopped, `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
const lines = [...recovered, ...stopped, ...(gated ? carryLines(state) : []), `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
for (const a of unknown) lines.push(`EXECUTION_UNKNOWN: ${a}`);
return { state, unknown, rebound, lines };
}
+2
View File
@@ -70,6 +70,8 @@ export interface RunState extends RunManifest {
stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[];
/** Written by export from attempts.jsonl; est_input_tokens is dispatched prompt bytes / 4, a relative estimate only. Absent on older runs. */
dispatches?: DispatchCounts; est_input_tokens?: number;
/** Carry-forward (lib/autoplan-carry.ts): false with --no-carry-forward; the per-gate cap on the delta check; the latest carry decision. */
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+21 -5
View File
@@ -14,7 +14,7 @@ import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gateCost, gateCostSentence, priorChoices } from './autoplan-gate-cost';
import { parseReply, recHash, type GateItem } from './gate-list';
import { parseReply, recHash, type GateEli5, type GateItem } from './gate-list';
import { renderUrgentBlock } from './headless-artifacts';
import { findSection, firstTitle } from './markdown-sections';
import type { BriefInputs } from './owner-brief';
@@ -25,6 +25,8 @@ export const BRIEF_SECTIONS = [
{ title: 'What caused this work', aliases: ['What caused this work', 'What caused us to work on this', 'What caused this', 'Trigger'] },
{ title: 'Big decisions', aliases: ['Big decisions', 'Big decisions we made', 'Key decisions'] },
] as const;
/** The 5-10 plain sentences a plan opens its ask with; required in a captain brief only when the gate carries `eli5` (written by gstack 1.91.305+). */
export const ELI5_SECTION = { title: 'ELI5', aliases: ['ELI5', 'In plain words'] } as const;
const SECTION_LINES = 24;
const SECTION_CHARS = 3000;
@@ -60,6 +62,13 @@ function capped(body: string, planPath: string): string {
return hidden > 0 || out.length < body.length ? `${out}\n\n_+ more in the plan (${path.basename(planPath)})._` : out;
}
/** gate.json `eli5`: the plan's ELI5 and the four approvals-shape sections, capped as the brief caps them. */
export function buildEli5(planText: string, planFile: string): GateEli5 {
const body = (aliases: readonly string[]) => { const s = findSection(planText, [...aliases]); return s?.body.trim() ? capped(s.body.trim(), planFile) : ''; };
const [before, why, cause, decisions] = BRIEF_SECTIONS.map(s => body(s.aliases));
return { summary: body(ELI5_SECTION.aliases), before_after: before!, why: why!, cause: cause!, decisions: decisions!, plan_file_sha256: createHash('sha256').update(planText).digest('hex') };
}
export interface CaptainBrief { text: string; missing: string[]; pending: boolean; recHash: string }
export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }): CaptainBrief {
@@ -71,8 +80,10 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
const rowOf = (id: string) => inputs.decisions.filter(d => d.gate_rev === list.gate_rev).find(d => (d.label ?? '').toLowerCase() === id);
const p1Row = p1 ? rowOf('p1') : undefined;
const pending = !p1Row || p1Row.status === 'pending';
const sections = BRIEF_SECTIONS.map(s => ({ title: s.title, found: findSection(plan.text, [...s.aliases]) }));
const missing = sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title);
const eli5 = list.eli5 ? buildEli5(plan.text, plan.file ?? 'plan.md') : undefined;
const fromGate = eli5 ? [eli5.before_after, eli5.why, eli5.cause, eli5.decisions] : [];
const sections = BRIEF_SECTIONS.map((s, i) => ({ title: s.title, found: eli5 ? { body: fromGate[i]! } : findSection(plan.text, [...s.aliases]) }));
const missing = [...(eli5 && !eli5.summary.trim() ? [ELI5_SECTION.title] : []), ...sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title)];
const urgent = inputs.findings.some(r => r.urgent === true || r.outside_plan === true) ? [renderUrgentBlock(inputs.findings).trimEnd(), ''] : [];
const items = list.items.filter(i => i.id !== 'p1');
const prior = priorChoices(o.outDir, list.gate_rev);
@@ -83,7 +94,11 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
const keptNote = (i: GateItem) => i.carried?.chosen === recKey(i) ? ` (your answer at gate_rev ${i.carried.gate_rev}, carried forward)` : ' (your answer at this gate)';
let header: string[];
if (!pending) {
if (!pending && p1Row?.carried_gate_from) {
const unseen = inputs.decisions.filter(d => d.gate_rev === list.gate_rev && d.carried_under).map(d => (d.title ?? '').replace(/\s*\[[^\]]*\]\s*$/, ''));
header = [`**Nothing pending.** Carried forward from gate_rev ${p1Row.carried_gate_rev}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'} under the owner's words “${p1Row?.owner_reply ?? ''}”, because nothing new needed deciding.`,
...(unseen.length ? ['', `Taken under "do it" without being shown to the owner: ${unseen.join('; ')}.`] : [])];
} else if (!pending) {
header = [`**Nothing pending.** Already decided${p1Row?.answered_at ? ` on ${p1Row.answered_at.slice(0, 10)}` : ''}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'}${p1Row?.owner_reply ?? p1Row?.reply ? ` (“${p1Row?.owner_reply ?? p1Row?.reply}”)` : ''}.`];
} else if (missing.length) {
header = [`**Not ready for approval:** the plan "${title}" is missing ${missing.join(', ')}. The thread will add them and send this again; do not approve from this draft.`];
@@ -94,7 +109,8 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
];
}
const body = sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? capped(s.found.body.trim(), plan.file ?? 'plan.md') : '_Not written for this plan yet._', '']);
const summary = eli5?.summary.trim() ? [`### ${ELI5_SECTION.title}`, '', eli5.summary.trim(), ''] : [];
const body = [...summary, ...sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? (eli5 ? s.found.body.trim() : capped(s.found.body.trim(), plan.file ?? 'plan.md')) : '_Not written for this plan yet._', ''])];
const need: string[] = ['### What we need from you', ''];
if (!pending) need.push('Nothing. This gate is answered.', '');
else {
+67
View File
@@ -0,0 +1,67 @@
/**
* decision-trail — the append rules of a run's decision trail (docs/unattended.md#the-decision-trail):
* a TSV with one line per decision (ts, phase, decision, why, evidence, result). The header is
* written once and only ever appended, cells lose their tabs and line breaks, and a cell whose first
* byte a spreadsheet would run as a formula (=, +, -, @) gets a leading apostrophe. Shared by
* bin/gstack-decision-trail and the autoplan runner (lib/autoplan-carry.ts records its carry verdicts).
* The rules are a port of pstack's show-me-your-work/scripts/log.sh (Copyright Lauren Tan, MIT);
* licenses/MIT-pstack.txt, NOTICE.md.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export const TRAIL_COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
export const TRAIL_HEADER = TRAIL_COLUMNS.join("\t") + "\n";
export type TrailRow = { line: number; cells: string[] };
function clean(cell: string): string {
const flat = cell.replace(/[\t\n\r]/g, " ");
return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
}
export function readTrailRows(file: string): TrailRow[] {
if (!fs.existsSync(file)) return [];
const lines = fs.readFileSync(file, "utf8").split("\n");
const rows: TrailRow[] = [];
lines.forEach((raw, i) => {
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
// A repeated header (the append-on-flaky-mount case) is never a row.
if (text === "" || text === TRAIL_HEADER.trimEnd()) return;
rows.push({ line: i + 1, cells: text.split("\t") });
});
return rows;
}
/** Append one row (ts stamped in UTC) and return the row count. */
export function appendTrailRow(file: string, cells: readonly string[]): number {
const dir = path.dirname(file);
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// Append the header too, never truncate: on a flaky mount the worst case is
// one stray header line, not lost rows.
// A file with no header line anywhere (empty, blank, or rows written by hand)
// gets one appended once; readTrailRows skips a header line wherever it sits.
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, TRAIL_HEADER);
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
return readTrailRows(file).length;
}
function hasHeader(file: string): boolean {
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
.some(line => line.replace(/\r$/, "") === TRAIL_HEADER.trimEnd());
}
function endsWithNewline(file: string, size: number): boolean {
const fd = fs.openSync(file, "r");
try {
const last = Buffer.alloc(1);
fs.readSync(fd, last, 0, 1, size - 1);
return last[0] === 0x0a;
} finally {
fs.closeSync(fd);
}
}
+21 -1
View File
@@ -42,7 +42,14 @@ export interface GateItem {
}
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
rec_rev?: number }
rec_rev?: number;
/** The plan's plain-language ask, read from the reviewed plan (lib/captain-brief.ts `buildEli5`); printed first. */
eli5?: GateEli5;
/** The one-reviewer check of an unreviewed post-cap delta before a carry (lib/autoplan-carry.ts). */
carry_check?: { attempt: string; model: string; verdict: 'passed' | 'failed' | 'unavailable'; max_severity: string; findings: number; at: string } }
/** The captain approvals shape (docs/captain.md § Approvals), section text taken from the reviewed plan; '' when absent. */
export interface GateEli5 { summary: string; before_after: string; why: string; cause: string; decisions: string; plan_file_sha256: string }
export const ELI5_FIELDS = [['before_after', 'Before → after'], ['why', 'Why it matters'], ['cause', 'What caused this work'], ['decisions', 'Big decisions']] as const;
export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean }
export interface ParsedReply {
@@ -64,6 +71,10 @@ export function validateGateList(list: unknown): string[] {
if (g.schema_version !== 1) problems.push('schema_version must be 1');
if (typeof g.run !== 'string' || !g.run) problems.push('run is required');
if (!Number.isInteger(g.gate_rev) || (g.gate_rev as number) < 1) problems.push('gate_rev must be an integer >= 1');
if (g.eli5 !== undefined) {
if (!g.eli5 || typeof g.eli5 !== 'object') problems.push('eli5 must be an object');
else for (const k of ['summary', 'before_after', 'why', 'cause', 'decisions', 'plan_file_sha256'] as const) if (typeof g.eli5[k] !== 'string') problems.push(`eli5.${k} must be a string`);
}
if (!Array.isArray(g.items) || g.items.length === 0) return [...problems, 'items must be a non-empty array'];
const seen = new Set<string>();
g.items.forEach((item, i) => {
@@ -103,6 +114,8 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
const lines: string[] = [];
lines.push(`GATE: run=${list.run} gate_rev=${list.gate_rev} items=${list.items.length} page=${current}/${pages}${list.title ? ` — ${list.title}` : ''}`);
lines.push('');
if (list.eli5 && current === 1) lines.push(...renderEli5(list.eli5), '');
if (list.carry_check) lines.push(`Carry check ${list.carry_check.attempt}: ${list.carry_check.verdict} (${list.carry_check.model}; ${list.carry_check.findings} finding(s), highest ${list.carry_check.max_severity}).`, '');
list.items.slice(start, start + pageSize).forEach((item, i) => {
const n = start + i + 1;
lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`);
@@ -121,6 +134,13 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
return lines.join('\n') + '\n';
}
/** The ELI5 block: the summary, then each approvals-shape section under its title. An empty summary says so (CAPTAIN_BRIEF_INCOMPLETE). */
export function renderEli5(e: GateEli5): string[] {
const lines = e.summary.trim() ? ['ELI5', e.summary.trim()] : [];
for (const [key, title] of ELI5_FIELDS) if (e[key].trim()) lines.push(...(lines.length ? [''] : []), `${title}:`, e[key].trim());
return lines;
}
function exampleTokens(list: GateList): string {
return list.items.slice(0, 2).map(i => `${i.id}${i.options.find(o => o.key !== i.recommended)?.key ?? i.recommended}`).join(' ');
}
+3
View File
@@ -41,6 +41,8 @@ export interface DecisionRow {
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
/** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */
owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision whose owner words this answer reuses, its gate_rev, why, who relayed them, items taken under "do it", and the delta check attempt. */
carried_gate_from?: string; carried_gate_rev?: number; carry_reason?: string; carried_answered_by?: string; carried_under?: string; carry_check?: string;
}
export interface TaskRow {
schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[];
@@ -170,6 +172,7 @@ export const ARTIFACT_SCHEMAS = {
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
answered_at: str, reply: str, source: str, cost: str,
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
carried_gate_from: str, carried_gate_rev: { type: 'integer', minimum: 1 }, carry_reason: str, carried_answered_by: str, carried_under: str, carry_check: str,
},
},
tasks: {
+1 -1
View File
@@ -321,7 +321,7 @@ export function explainConfig(): string {
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
+59 -2
View File
@@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean {
);
}
/**
* Bare integers that programs print as constants: 2^30 through 2^49 and each
* one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers,
* offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash,
* the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a,
* MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164
* reads each as a bare 10-15 digit number. Only the exact separator-free value
* is exempt: the same digits with a `+`, spaces, dashes or dots still report,
* so an E.164 number, which carries its `+`, is never exempt.
*/
const NUMERIC_CONSTANTS: ReadonlySet<string> = new Set([
...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]),
"2654435761",
"2654435769",
"1103515245",
"1013904223",
"2166136261",
"1540483477",
"3432918353",
"2246822507",
"3266489909",
"1812433253",
"4294967291",
]);
export function isNumericConstant(span: string): boolean {
return NUMERIC_CONSTANTS.has(span);
}
/** Context window for pairing a normalized parcel ID with its punctuated form. */
const PARCEL_CONTEXT_CHARS = 400;
@@ -446,6 +475,31 @@ export function isDotenvFilename(match: RegExpExecArray): boolean {
return spanStart > 0 && input[spanStart - 1] === ".";
}
/** A trailing run of file extensions that are not top-level domains (checked
* against the IANA root zone list, 2026-10-28). Extensions that are also TLDs
* (`.md`, `.py`, `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on
* purpose. */
const NON_TLD_EXTENSIONS = /(?:\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff))+$/i;
/**
* True when a pii.email span is a filename with an `@` in it rather than an
* address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`,
* `eslint-config@next.yaml`. The package@version convention puts a version or
* tag after the `@`, so either sign decides: the "domain" ends in file
* extensions no TLD uses and what precedes them cannot be a domain (one dotless
* label or a dotted version), or it is a dotted version (two or more all-digit
* labels) followed by an extension that doubles as a TLD (`a@1.2.md`). A file
* named after an address (`jane@acme.com.json`), `user@163.com`,
* `alice@1.2.com` and an address at a TLD that doubles as an extension
* (`dev@acme.sh`, `ops@acme.md`) still report.
*/
export function looksLikeAtFilename(span: string): boolean {
const domain = span.slice(span.lastIndexOf("@") + 1);
const stem = domain.replace(NON_TLD_EXTENSIONS, "");
if (stem !== domain) return !/\.[A-Za-z]{2,}$/.test(stem);
return /^\d+(?:\.\d+)+\.(?:md|py|sh|rs|so|zip|mov|map)$/i.test(domain);
}
/** Extensions that mark a `<name>.local.<ext>` span as a file. Closed on
* purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front
* of a public one, so after `.local` it can only be an extension. */
@@ -955,6 +1009,7 @@ export const PATTERNS: RedactPattern[] = [
regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/,
autoRedactable: true,
redactToken: "<REDACTED-EMAIL>",
validate: (span) => !looksLikeAtFilename(span),
// Engine layers the email allowlist (example.com, noreply@, user's own,
// repo-public authors) on top of this — see redact-engine.ts.
},
@@ -969,12 +1024,14 @@ export const PATTERNS: RedactPattern[] = [
// A digit-only UUID's hyphen groups read as national phone formatting, and
// so do a county tax-map parcel ID (see looksLikeParcelId), vector
// coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON
// values (isNumericMetadataValue), and GitHub Actions run and job ids in
// their id positions (isCiRunIdentifier).
// values (isNumericMetadataValue), GitHub Actions run and job ids in
// their id positions (isCiRunIdentifier), and bare powers of two and
// hash/PRNG constants (isNumericConstant).
validate: (span, match) =>
!insideUuid(match) &&
span.replace(/\D/g, "").length >= 10 &&
!looksLikeCompactTimestamp(span) &&
!isNumericConstant(span) &&
!looksLikeParcelId(span, match) &&
!looksLikeDecimalCoordinates(span) &&
!isNumericMetadataValue(span, match) &&
+1 -1
View File
@@ -41,7 +41,7 @@ export const CAPY_RESULT_CODES = {
},
CAPTAIN_BRIEF_INCOMPLETE: {
anchor: 'captain-brief-incomplete',
summary: 'the plan lacks one or more of the sections the Captain brief needs (Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
summary: 'the plan lacks one or more of the sections the Captain brief needs (ELI5 on a run whose gate carries it, Before → after, Why it matters, What caused this work, Big decisions), so the brief is not ready for approval',
fix: 'add the named sections to the plan, revise the run if the plan is under review, and render again; --allow-incomplete prints a preview that still says it is not ready',
},
PR_SHOTS_INPUT_NOT_FOUND: {
+1 -1
View File
@@ -598,7 +598,7 @@ export const RESULT_CODES = {
},
UPGRADE_BUSY: {
anchor: 'upgrade-busy',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, so moving the checkout would change gstack under it',
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
},
UPGRADE_FETCH_FAILED: {
+44 -18
View File
@@ -9,6 +9,8 @@
* answer_markers regexes for a quoted sealed answer in added text
* seed_patterns regexes for a sampling seed in added text
* local_paths true: a local home path (/home/<u>/, /Users/<u>/) in added text
* allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
* case_insensitive true: `paths` and `transcripts` match regardless of case
* visibility "public" | "private": the destination override (reviewed on the base branch)
*
* The publication boundary is every commit the push publishes: the local sha
@@ -18,8 +20,9 @@
* copies, independent of content (binary, empty, add-then-delete); text rules
* see each commit's added lines. The effective policy is the union of the
* destination base branch's `sealed` and the pushed one (a push can tighten,
* never relax); waivers come only from the base branch's
* `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* never relax); `allow_paths` exceptions come only from the base branch's
* policy and remove path findings before waivers, which come only from the
* base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* closed). Visibility: the base override, else a cache under the state root
* (a `public` answer for 24 h, a `private` one for 1 h before it may
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
@@ -28,19 +31,20 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
import { containedRelative, globToRegExp } from './ship-policy';
import { resolveStateRoot } from './state-root';
export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' }
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' }
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
export type ExemptFinding = SealedFinding & { glob: string };
export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
export type Visibility = 'public' | 'private' | 'unknown';
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility'];
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility'];
const MAX_ENTRIES = 64;
const MAX_REGEX = 256;
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
@@ -50,9 +54,9 @@ const PRIVATE_TTL_MS = 3600_000;
/** `<key>: <message>` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */
export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
const errors: Array<{ path: string; message: string }> = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }];
const o = raw as Record<string, unknown>;
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` });
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` });
const list = (key: string, check: (s: string) => string | null) => {
if (!(key in o)) return;
const v = o[key];
@@ -62,8 +66,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
};
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } };
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex);
if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' });
const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); };
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' });
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
return errors;
}
@@ -88,15 +93,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string)
const errors = sealedErrors(raw.sealed);
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
const s = raw.sealed;
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label };
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label };
}
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */
/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
if (!base && !pushed) return null;
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])];
const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility };
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
}
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
@@ -126,12 +131,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string
const HEX = /^[0-9a-f]{40,64}$/;
/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
}
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */
function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
if (!p.paths.length && !p.transcripts.length) return [];
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n');
if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
const out: SealedFinding[] = [];
const transcript = globMatcher(p.transcripts, p.case_insensitive);
const sealedPath = globMatcher(p.paths, p.case_insensitive);
const tokens = r.out.split('\0');
let commit = '';
for (let i = 0; i < tokens.length; i++) {
@@ -141,8 +154,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
const renamed = /^[RC]\d*$/.test(t);
const file = renamed ? tokens[i += 2]! : tokens[++i]!;
if (!renamed && !t.includes('A')) continue;
if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit });
else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit });
if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit });
}
return out;
}
@@ -216,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>;
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
visibility: Visibility; visibilitySource: string; target: string; error?: string;
}
@@ -225,9 +238,14 @@ export interface EvaluateOptions {
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
}
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
export function sealedErrorReport(error: string, target: string): SealedReport {
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
export function evaluateSealed(o: EvaluateOptions): SealedReport {
const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array<SealedFinding & { reason: string }>, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target };
const blank = { ...sealedErrorReport('', o.target), error: undefined };
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' };
if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` };
let policy: SealedPolicy | null = base.policy;
@@ -243,6 +261,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
let found: SealedFinding[];
try { found = scanCommits(o.repoRoot, commits, policy); }
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
const exception = globMatcher(policy.allow_paths, false);
const exempt: ExemptFinding[] = [];
found = found.filter(f => {
const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
if (glob) exempt.push({ ...f, glob });
return !glob;
});
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = [];
const findings = found.filter(f => {
@@ -252,7 +277,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
});
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
}
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
@@ -260,6 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$
/** stderr text for a report: findings one per line (`<rule> <file[:line]> commit <sha12>`), never the matched text. */
export function renderSealed(r: SealedReport, context: string): string {
const lines: string[] = [];
for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
@@ -268,7 +294,7 @@ export function renderSealed(r: SealedReport, context: string): string {
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'refused') {
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'warned') {
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
}
+3 -1
View File
@@ -166,6 +166,8 @@ export interface CarryPlanInput {
changedProduct: string[];
mergeCommits: number;
rangeDiff: string;
/** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */
retargetedFrom?: string;
}
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string };
@@ -232,7 +234,7 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
}
const reasons: string[] = [];
const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : [];
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata');