feat(autoplan): carry the owner's answer to a later gate that asks nothing new, gate.json eli5, stacked approvals import

A later gate revision with nothing new is answered with the owner's recorded words
(CARRIED); a 'do it' also covers new auto items that change no scope, cost or
direction; an unreviewed post-cap delta gets one cross-family reviewer first
(CARRY_CHECK, gstack-autoplan check). gate.json gains eli5 and the Captain brief
requires ## ELI5 on runs that carry it. gstack-approvals import --stack writes
every branch or none. The first next prints REVIEW_ESTIMATE.

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 04:22:12 +00:00
1 parent dfad6cf483
commit 8e06646775
17 files changed
+701 -39

No files matched your search

+35 -4
View File
@@ -5,7 +5,7 @@
* gstack-approvals add --ask <text> --words <exact words> --by <who> --scope <what it covers>
* [--pending <text>] [--outcome approved|rejected|pending|standing]
* [--date YYYY-MM-DD] [--source <ref>] [--branch <name>] [--json]
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name>] [--json]
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name> | --stack <b1,b2,...>] [--json]
* gstack-approvals render [--no-heading] [--branch <name>]
* gstack-approvals list [--json] [--branch <name>]
* gstack-approvals sync [--branch <name>]
@@ -20,14 +20,15 @@
*/
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { appendRows, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
add --ask <text> --words <exact words> --by <who> --scope <what it covers>
[--pending <text>] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source <ref>]
import --autoplan <run dir> [--by <whose words, e.g. Garry>]
import --autoplan <run dir> [--by <whose words, e.g. Garry>] [--stack <b1,b2,...>]
--stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
render [--no-heading] the ## Approvals section for a PR body or report
list [--json]
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
@@ -40,7 +41,7 @@ Example:
${exitTableHelp()}`;
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan']);
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']);
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
const flags: Record<string, string> = {};
@@ -64,11 +65,41 @@ function fail(cmd: string, e: RecordError): number {
return EXIT.refused;
}
/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */
function importStack(cmd: string, flags: Record<string, string>, json: boolean): number {
if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan <run dir>, without --branch\n`); return EXIT.usage; }
const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))];
if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs <b1,b2,...>\n`); return EXIT.usage; }
const cwd = process.cwd();
const missing = branches.filter(b => !branchExists(cwd, b));
if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` });
const runDir = path.resolve(flags['--autoplan']);
const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = [];
let firstError: RecordError | undefined;
for (const branch of branches) {
const loc = locate({ cwd, env: process.env, branch });
if ('code' in loc) return fail(cmd, loc);
const rows = importAutoplan(runDir, branch, flags['--by']);
if ('code' in rows) return fail(cmd, rows);
const res = appendRows(loc, rows);
if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; }
if (res.mirrorError) firstError ??= res.mirrorError;
results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) });
}
if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n');
else {
for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`);
process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`);
}
return firstError ? fail(cmd, firstError) : EXIT.ok;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
const { flags, bools, error } = parse(rest);
if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; }
if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json'));
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] });
if ('code' in loc) return fail(cmd, loc);
const json = bools.has('--json');
+28 -3
View File
@@ -19,13 +19,14 @@
* gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran
* gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>> close a phase accepted-with-findings
* gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by <human|agent:<id>> the owner's journaled cap change
* gstack-autoplan check --out <dir> --attempt <id> --result <file> --model <id> | --unavailable <reason> the delta check before a carry
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
*
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
* lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts.
* Workflow commands end with GSTACK_RESULT: skill=autoplan status=<s> run=<dir>; the run is never approved here.
* Mutating verbs (next, submit, answer, recommend, revise, close, budget, export, run) pass admission first (lib/autoplan-admission.ts)
* Mutating verbs (next, submit, answer, recommend, revise, close, budget, check, export, run) pass admission first (lib/autoplan-admission.ts)
* and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock.
*/
import * as fs from 'node:fs';
@@ -33,6 +34,7 @@ import * as path from 'node:path';
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
import { carryLines, submitCarryCheck } from '../lib/autoplan-carry';
import { ANSWERED_BY_RE } from '../lib/gate-list';
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
import { admit, touchInstallLease } from '../lib/autoplan-admission';
@@ -60,6 +62,9 @@ DRIVER: ${driverFile()}
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
--no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new
--carry-check-cap <usd> (first next): the most one delta check may reserve before a carry (default 5.00)
the first next prints REVIEW_ESTIMATE: the most the review can reserve at this scope and round cap
prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result
submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
--model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
@@ -84,6 +89,9 @@ DRIVER: ${driverFile()}
resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first
(a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command
leaving the run at rest publishes a generation under <dir>/<run>/ and prints CHECKPOINT: on stderr)
check --out <dir> --attempt <id> --result <file> --model <id> [--same-family "<why>"] | --unavailable "<reason>"
bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's
last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>>
close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their
@@ -180,7 +188,10 @@ function doNext(out: string, rest: string[], json: boolean): number {
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
const maxRounds = num(rest, '--max-rounds');
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
const carryCheckCapUsd = num(rest, '--carry-check-cap');
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
@@ -257,7 +268,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
moment = 'transition';
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state, out)); }
if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
@@ -265,6 +276,19 @@ function doClose(out: string, rest: string[], json: boolean): number {
return EXIT.ok;
}
/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
function doCheck(out: string, rest: string[], json: boolean): number {
const unavailable = flag(rest, '--unavailable');
const result = flag(rest, '--result');
if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt <id> and --result <file> --model <id>, or --unavailable "<reason>"');
const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') });
moment = 'transition';
const status = r.state.status;
const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)];
emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out });
return EXIT.ok;
}
function doBudget(out: string, rest: string[], json: boolean): number {
const state = readState(out);
const lines = changeBudget(state, { spendCap: num(rest, '--spend-cap'), maxRounds: num(rest, '--max-rounds'), by: flag(rest, '--by') });
@@ -338,6 +362,7 @@ async function main(argv: string[]): Promise<number> {
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json));
if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json));
if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json));
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); }
if (cmd === 'status') {
const state = readState(out);
@@ -416,7 +441,7 @@ async function main(argv: string[]): Promise<number> {
if (e instanceof RunError) {
process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n');
if (e.after.lines?.length) process.stdout.write(e.after.lines.join('\n') + '\n');
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget', 'check'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
return e.exit === 3 ? EXIT.refused : EXIT.fail;
}
process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`);
+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),
+223
View File
@@ -0,0 +1,223 @@
/**
* 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, 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*(?:(?!captain\b)[a-z][\w .'-]{0,40}:\s*)?do it[\s.!]*$/i;
/** The owner's verbatim words are a "do it" (optionally after their name), not a Captain's words 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})`] });
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 };
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 };
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 && !['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 {
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) {
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 before = atGateCopy(state.out, prev.gate_rev);
if (!fs.existsSync(before)) 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 = lineDiff(fs.readFileSync(before, 'utf8'), current).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n');
const deltaSha = sha256(diff);
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 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 };
}
+36 -9
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 } 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,8 +397,9 @@ 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');
@@ -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 };
+6 -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 } 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)`];
}
@@ -199,7 +203,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;
+19 -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;
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,9 @@ 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) {
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.`];
} 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 +107,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 {
+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 = ['ELI5', e.summary.trim() || 'ELI5: missing (add ## ELI5 to the plan: 5-10 plain sentences) (CAPTAIN_BRIEF_INCOMPLETE)'];
for (const [key, title] of ELI5_FIELDS) if (e[key].trim()) lines.push('', `${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[];
@@ -161,6 +163,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
@@ -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: {
+40
View File
@@ -196,3 +196,43 @@ describe('Capy mirror', () => {
expect(read.state).toBe('present');
});
});
describe('import --autoplan --stack', () => {
function stackRepo(): string {
const d = repo('feature/one');
const g = (args: string[]) => expect(spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: d, timeout: 30_000 }).status).toBe(0);
g(['commit', '-q', '--allow-empty', '-m', 'seed']);
g(['branch', 'feature/two']);
return d;
}
test('writes the same rows to every branch, each in its own record; a retry adds nothing', () => {
const run = tmp('appr-run-');
autoplanRun(run, [{ by: 'agent:GSTA-36', words: 'Garry: Do it', at: '2026-10-29T03:40:00.000Z', p1: 'a' }]);
const cwd = stackRepo(); const state = tmp('appr-state-');
const env = { GSTACK_STATE_ROOT: state };
const r = bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env });
expect(r.status, r.err).toBe(0);
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/one n=1 skipped=0 /m);
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/two n=1 skipped=0 /m);
expect(r.out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=2$/m);
for (const b of ['feature/one', 'feature/two']) expect(bin(['render', '--branch', b], { cwd, env }).out).toContain('Garry: Do it');
expect(bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env }).out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=0$/m);
});
test('one unknown branch writes nothing to any branch', () => {
const run = tmp('appr-run-');
autoplanRun(run, [{ by: 'agent:GSTA-36', words: 'rec', at: '2026-10-29T03:40:00.000Z', p1: 'a' }]);
const cwd = stackRepo(); const state = tmp('appr-state-');
const r = bin(['import', '--autoplan', run, '--stack', 'feature/one,feature/nope'], { cwd, env: { GSTACK_STATE_ROOT: state } });
expect(r.status).toBe(3);
expect(r.err).toContain('feature/nope');
expect(r.err).toContain('(APPROVAL_RECORD_NO_BRANCH)');
expect(bin(['render', '--branch', 'feature/one'], { cwd, env: { GSTACK_STATE_ROOT: state } }).out).not.toContain('Approve the plan');
});
test('--stack goes with import --autoplan and without --branch', () => {
const cwd = stackRepo();
expect(bin(['import', '--autoplan', tmp('appr-run-'), '--branch', 'feature/one', '--stack', 'feature/one'], { cwd }).status).toBe(2);
});
});
+2 -2
View File
@@ -113,7 +113,7 @@ describe('answer', () => {
}, 30_000);
test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => {
const out = gatedRun('reopen');
const out = gatedRun('reopen', ['--no-carry-forward']);
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
expect(r.status, r.stderr).toBe(0);
const runId = readRun(out).run;
@@ -166,7 +166,7 @@ describe('answer', () => {
}, 30_000);
test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
const out = gatedRun('eng-only', ['--max-rounds', '3']);
const out = gatedRun('eng-only', ['--max-rounds', '3', '--no-carry-forward']);
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
const runId = readRun(out).run;
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
+240
View File
@@ -0,0 +1,240 @@
/**
* Carry-forward (GSTA-155, lib/autoplan-carry.ts): a later gate revision that asks nothing new is answered with the
* owner's recorded words from the revision before it, and recorded as a carry; every other case prints why not and
* stays at the gate. A "do it" also covers new auto items that change no scope, cost or direction. A capped,
* unreviewed delta gets one reviewer on the delta first (CARRY_CHECK, gstack-autoplan check): High or above means no
* carry. Runs on the simulated Capy host.
*/
import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { isDoIt } from '../lib/autoplan-carry';
import { importAutoplan } from '../lib/approval-record';
import { renderCaptainBrief } from '../lib/captain-brief';
import { recHash, renderGate } from '../lib/gate-list';
import { loadBriefInputs } from '../lib/owner-brief';
import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
setDefaultTimeout(30_000);
const r = capyRunner();
afterAll(() => r.cleanup());
let n = 0;
const NONE = { native: [], outside: [] };
const AGREE = {
native: [{ id: 'F1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
outside: [{ id: 'O1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
};
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1');
const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
const decisions = (out: string) => readJsonl(path.join(out, 'decisions.jsonl'));
const journal = (out: string) => readJsonl(path.join(out, 'attempts.jsonl'));
/** Gate 1 with one recommended plan change (uc1b), answered with the owner's words, then revised to take it. */
function answeredAndRevised(args: string[] = [], words = 'Garry: Do it', ceo: { native?: unknown[]; outside?: unknown[] } = AGREE) {
const dir = path.join(r.host.base, `carry${++n}`);
const plan = writePlan(dir);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]).status).toBe(0);
phaseVia(r, out, 'ceo', ceo);
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
const run = readRunJson(out).run;
const ans = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', recHashOf(out), '--by', 'agent:gstack-minder', '--owner-reply', words]);
expect(ans.status, ans.stderr).toBe(0);
expect(ans.stdout).toMatch(/^APPLY: /m);
const v2 = path.join(dir, 'plan-v2.md');
fs.writeFileSync(v2, V2);
const rev = r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', 'uc1']);
expect(rev.status, rev.stderr).toBe(0);
return { dir, out, run, v2, rev };
}
const recHashOf = (out: string) => recHash(gateOf(out));
describe('carry-forward', () => {
test('a second gate with nothing new carries the owner\'s words: CARRIED, APPROVED, the record names its source', () => {
const s = answeredAndRevised();
phaseVia(r, s.out, 'ceo', NONE);
const eng = phaseVia(r, s.out, 'eng', NONE).outside;
expect(eng.status, eng.stderr).toBe(0);
expect(eng.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: Do it" by=agent:gstack-minder reason=no new choice; plan unchanged since the accepted changes$/m);
expect(eng.stdout).toMatch(/^APPROVED: gate_rev=2/m);
expect(eng.stdout).toContain('status=complete');
const p1 = decisions(s.out).find(d => d.gate_rev === 2 && d.label === 'P1');
expect(p1).toMatchObject({ chosen: 'a', owner_reply: 'Garry: Do it', answered_by: 'agent:gstack-autoplan', carried_gate_from: `${s.run}-p1`, carried_gate_rev: 1, carried_answered_by: 'agent:gstack-minder' });
expect(journal(s.out).find(e => e.event === 'gate_carried')).toMatchObject({ gate_rev: 2, from_gate_rev: 1 });
expect(journal(s.out).find(e => e.event === 'answered' && e.gate_rev === 1)).toMatchObject({ answer_class: 'plan-changes' });
expect(fs.readFileSync(path.join(s.out, 'decisions.tsv'), 'utf8')).toContain('carried gate_rev 2 from gate_rev 1');
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
expect(r.validate(s.out).status).toBe(0);
const rows = importAutoplan(s.out, 'feature/x', 'Garry');
if ('code' in rows) throw new Error(rows.message);
const carried = rows.find(x => x.source.endsWith('gate_rev=2'))!;
expect(carried).toMatchObject({ words: 'Garry: Do it', outcome: 'approved' });
expect(carried.ask).toContain('gate_rev 2 carried forward from gate_rev 1');
expect(carried.by).toBe('Garry (words from gate_rev 1, relayed by agent:gstack-minder), carried forward by gstack-autoplan');
const loaded = loadBriefInputs(s.out);
if ('error' in loaded) throw new Error(String(loaded.error));
expect(renderCaptainBrief(loaded.inputs, { outDir: s.out }).text).toContain('**Nothing pending.** Carried forward from gate_rev 1: approved as reviewed under the owner\'s words “Garry: Do it”');
});
test('--no-carry-forward: the second gate goes to the owner', () => {
const off = answeredAndRevised(['--no-carry-forward']);
phaseVia(r, off.out, 'ceo', NONE);
const o = phaseVia(r, off.out, 'eng', NONE).outside;
expect(o.stdout).toMatch(/^CARRY: no \(carry-forward is off for this run \(--no-carry-forward\)\)$/m);
expect(o.stdout).toMatch(/^GATE: gate_rev=2 /m);
expect(o.stdout).toContain('status=gate_pending');
});
test('an edit to the plan file after the revise (an ELI5-only line) refuses the carry', () => {
const edited = answeredAndRevised();
fs.appendFileSync(edited.v2, '\nAn ELI5 sentence added after the revise.\n');
phaseVia(r, edited.out, 'ceo', NONE);
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it\)$/m);
expect(readRunJson(edited.out).status).toBe('gate_pending');
});
test('a new scope challenge or an urgent finding refuses the carry', () => {
const scope = answeredAndRevised();
phaseVia(r, scope.out, 'ceo', NONE);
const s2 = phaseVia(r, scope.out, 'eng', { native: [{ id: 'F9', severity: 'High', title: 'Add a dead-letter queue', user_challenge: true, changes: 'scope', fix: 'dlq' }], outside: [] }).outside;
expect(s2.stdout).toMatch(/^CARRY: no \(new item \w+: Add a dead-letter queue/m);
const urgent = answeredAndRevised();
phaseVia(r, urgent.out, 'ceo', NONE);
const u = phaseVia(r, urgent.out, 'eng', { native: [{ id: 'F8', severity: 'Medium', title: 'Tokens logged in plain text', urgent: true, suggested_owner: 'security' }], outside: [] }).outside;
expect(u.stdout).toMatch(/^CARRY: no \(urgent finding open: Tokens logged in plain text\)$/m);
});
test('"do it" also covers a new packaging challenge; "rec" does not', () => {
const PKG = { native: [{ id: 'F7', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'own PR' }], outside: [] };
const doIt = answeredAndRevised();
phaseVia(r, doIt.out, 'ceo', NONE);
const d = phaseVia(r, doIt.out, 'eng', PKG).outside;
expect(d.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason="do it" covers \w+; plan unchanged/m);
expect(d.stdout).toMatch(/^CARRIED_UNDER_DO_IT: \w+ Split the backoff into its own PR .*\(the owner has not seen this item\)$/m);
expect(decisions(doIt.out).find(x => x.gate_rev === 2 && /Split the backoff/.test(x.title))).toMatchObject({ carried_under: 'do it' });
const rec = answeredAndRevised([], 'rec');
phaseVia(r, rec.out, 'ceo', NONE);
expect(phaseVia(r, rec.out, 'eng', PKG).outside.stdout).toMatch(/^CARRY: no \(new item \w+: Split the backoff/m);
});
test('isDoIt reads the owner\'s words, not a Captain\'s', () => {
for (const w of ['Do it', 'do it.', 'Garry: Do it', 'Garry Tan: do it!']) expect(isDoIt(w)).toBe(true);
for (const w of ['rec', 'accept all', 'Captain: Do it', "Captain: rec, Highs as build tasks under Garry's 'Do it'", 'Do it and also improve it']) expect(isDoIt(w)).toBe(false);
});
});
describe('the delta check before a carry', () => {
function capped() {
const s = answeredAndRevised(['--max-rounds', '1']);
expect(s.rev.stdout).toMatch(/^CAPPED: ceo used round 1 of 1/m);
expect(s.rev.stdout).toMatch(/^CARRY_CHECK: attempt=\S+-c2 items=uc\d+,uc\d+ prompt=\S+ result=\S+ model_family=not anthropic estimate=5\.00 cap=5\.00$/m);
expect(s.rev.stdout).toMatch(/^GATE: gate_rev=2 /m);
const run = readRunJson(s.out);
expect(run.status).toBe('gate_pending');
const check = run.carry.check;
const prompt = fs.readFileSync(check.prompt, 'utf8');
expect(prompt).toContain('+ ');
expect(prompt).toContain(`INPUT: carry-check ${check.delta_sha256}`);
return { ...s, check };
}
const result = (file: string, sha: string, findings: unknown[]) => fs.writeFileSync(file, `INPUT: carry-check ${sha}\n\nReviewed the delta.\n\n` + '```gstack-findings\n' + findings.map(f => JSON.stringify(f)).join('\n') + '\n```\n');
test('a clean delta check carries; the verdict is on the gate, the decisions and the trail', () => {
const s = capped();
result(s.check.result, s.check.delta_sha256, [{ id: 'F1', severity: 'Low', title: 'Wording' }]);
const same = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'anthropic/claude-opus-5-5']);
expect(same.status).toBe(3);
expect(same.stderr).toContain('MODEL_FAMILY_CONFLICT');
const ok = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^CARRY_CHECK: passed attempt=\S+ model=openai\/gpt-6\.1-sol findings=1 highest=Low$/m);
expect(ok.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason=delta check passed on uc\d+,uc\d+; plan unchanged/m);
expect(ok.stdout).toContain('status=complete');
expect(gateOf(s.out).carry_check).toMatchObject({ verdict: 'passed', model: 'openai/gpt-6.1-sol', max_severity: 'Low', findings: 1 });
expect(decisions(s.out).filter(x => x.gate_rev === 2 && x.carry_check).length).toBe(2);
expect(fs.readFileSync(path.join(s.out, 'decisions.tsv'), 'utf8')).toContain('delta check before carrying gate_rev 2');
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
expect(r.validate(s.out).status).toBe(0);
});
test('a High finding, or no reviewer, leaves the gate for the owner', () => {
const s = capped();
result(s.check.result, s.check.delta_sha256, [{ id: 'F1', severity: 'High', title: 'The edit also drops the jitter' }]);
const bad = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(bad.status, bad.stderr).toBe(0);
expect(bad.stdout).toMatch(/^CARRY: no \(delta check found High: The edit also drops the jitter\)$/m);
expect(bad.stdout).toMatch(/^GATE: gate_rev=2 /m);
const g = gateOf(s.out);
expect(g.carry_check).toMatchObject({ verdict: 'failed', max_severity: 'High' });
expect(g.items.filter((i: any) => i.cap?.kind === 'delta').every((i: any) => i.flags.some((f: string) => f.includes('The edit also drops the jitter')))).toBe(true);
const again = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--unavailable', 'x']);
expect(again.status).toBe(3);
const u = capped();
const none = r.ap(['check', '--out', u.out, '--attempt', u.check.attempt, '--unavailable', 'no other family online']);
expect(none.status, none.stderr).toBe(0);
expect(none.stdout).toMatch(/^CARRY: no \(delta check unavailable: no other family online\)$/m);
expect(JSON.parse(fs.readFileSync(path.join(u.out, 'spend.json'), 'utf8')).released.map((x: any) => x.attempt)).toContain(u.check.attempt);
});
});
describe('the review estimate', () => {
test('the first next prints the most the review can reserve', () => {
const dir = path.join(r.host.base, 'estimate');
const out = path.join(dir, 'run');
const first = r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']);
expect(first.stdout).toMatch(/^REVIEW_ESTIMATE: phases=ceo,eng voices=2\+2 rounds<=2 attempts<=8 per_voice=5\.00 reservations<=40\.00 \(a reservation total, not a spend ceiling/m);
expect(r.ap(['next', '--out', out]).stdout).not.toContain('REVIEW_ESTIMATE');
});
});
describe('gate eli5', () => {
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
function gated(name: string, text: string) {
const dir = path.join(r.host.base, name);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir, text), '--no-ui']).status).toBe(0);
phaseVia(r, out, 'ceo', NONE);
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
return out;
}
test('gate.json carries the plan\'s ELI5 and four sections, and page 1 of the render opens with them', () => {
const out = gated('eli5', SMALL_PLAN + '\n## ELI5\n\nRetries wait longer each time.\n' + SECTIONS);
const gate = gateOf(out);
expect(gate.eli5).toMatchObject({ summary: 'Retries wait longer each time.', before_after: 'Before: retries hammer. After: they back off.', why: 'Outages.', cause: 'Incident 7.', decisions: '1. Exponential backoff.' });
expect(gate.eli5.plan_file_sha256).toMatch(/^[0-9a-f]{64}$/);
const page = renderGate(gate).split('\n');
expect(page.slice(2, 4)).toEqual(['ELI5', 'Retries wait longer each time.']);
expect(page.indexOf('Before → after:')).toBeGreaterThan(3);
expect(page.indexOf('Before → after:')).toBeLessThan(page.findIndex(l => /^1\. \[/.test(l)));
const loaded = loadBriefInputs(out);
if ('error' in loaded) throw new Error(String(loaded.error));
const brief = renderCaptainBrief(loaded.inputs, { outDir: out });
expect(brief.missing).toEqual([]);
expect(brief.text.indexOf('### ELI5')).toBeLessThan(brief.text.indexOf('### Before → after'));
});
test('a plan without ## ELI5: the Captain brief is not ready and names it; the gate render says it is missing', () => {
const out = gated('no-eli5', SMALL_PLAN + SECTIONS);
expect(renderGate(gateOf(out))).toContain('ELI5: missing (add ## ELI5 to the plan: 5-10 plain sentences) (CAPTAIN_BRIEF_INCOMPLETE)');
const loaded = loadBriefInputs(out);
if ('error' in loaded) throw new Error(String(loaded.error));
const brief = renderCaptainBrief(loaded.inputs, { outDir: out });
expect(brief.missing).toEqual(['ELI5']);
expect(brief.text).toStartWith('**Not ready for approval:**');
});
test('a gate written before the eli5 field renders and briefs as before', () => {
const out = gated('legacy', SMALL_PLAN + SECTIONS);
const gate = gateOf(out);
delete gate.eli5;
fs.writeFileSync(path.join(out, 'gate.json'), JSON.stringify(gate, null, 2) + '\n');
expect(renderGate(gate)).not.toContain('ELI5');
const loaded = loadBriefInputs(out);
if ('error' in loaded) throw new Error(String(loaded.error));
expect(renderCaptainBrief(loaded.inputs, { outDir: out }).missing).toEqual([]);
});
});
+1 -1
View File
@@ -157,7 +157,7 @@ describe('gstack-owner-brief render --captain', () => {
describe('carried decisions at a cap gate (#3546)', () => {
const r = capyRunner();
afterAll(() => r.cleanup());
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
const SECTIONS = '\n## ELI5\n\nRetries wait longer each time.\n\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
const PLAN = SMALL_PLAN + SECTIONS;
const WORDS: Record<string, string> = { 'keep your direction': 'keep the plan as written', 'accept the change': "make the reviewer's change" };
+4
View File
@@ -19,6 +19,10 @@ afterAll(() => r.cleanup());
const PLAN = `# Retry backoff for the webhook worker
## ELI5
A failed webhook now waits longer before each retry, so the partner stops dropping them.
## Before → after
Before: a failed webhook retries every second until it is dropped. After: retries back off from 500 ms to 30 s.