--phase --voice native|outside --result --model --attempt
- * [--runner ] [--usage-usd ] [--reconcile ] [--json]
+ * gstack-autoplan submit --out --phase --voice native|outside --result | --record [--attest ]
+ * --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json]
+ * gstack-autoplan submit --out --phase --voice outside --unavailable | --failed [--json]
+ * gstack-autoplan revise --out --plan [--incorporates ] [--withdraw ] [--json] review a revised plan in this run
* gstack-autoplan run --out --plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api]
* [--deadline ] [--spend-cap ] [--estimate-usd ] [--json] next in a loop; needs runners for both voices
* gstack-autoplan answer --out --gate-rev --reply "" [--json]
@@ -18,11 +20,17 @@
* 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= run=; the run is never approved here.
+ * Mutating verbs (next, submit, answer, revise, 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';
import * as path from 'node:path';
import { answerGate } from '../lib/autoplan-gate';
+import { admit } from '../lib/autoplan-admission';
import { exportRun } from '../lib/autoplan-export';
+import { migrateRun } from '../lib/autoplan-migrate';
+import { reviseRun } from '../lib/autoplan-revise';
+import { pendingAmendments, type RunState } from '../lib/autoplan-state';
import { FILES, RunError, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
import type { Phase } from '../lib/autoplan-prompts';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
@@ -33,13 +41,22 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
- next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary]
+ next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary]
[--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json]
+ --scaffold (first next): a plan missing one of its two sections is reviewed from /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
prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result
- submit --out --phase --voice native|outside --result --model --attempt
- [--runner ] [--usage-usd ] [--reconcile ] [--json]
- bind one reviewer result (receipt, canonical findings, model family) exactly once; closes the phase when both voices are in
+ submit --out --phase --voice native|outside --result | --record [--attest ]
+ --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json]
+ bind one reviewer result (receipt, canonical findings, model family) exactly once; closes the phase when both voices are in.
+ --attempt may be omitted when exactly one attempt is open. --record takes a hand-run review: the gstack-findings fence,
+ one findings table (Severity and Title columns), one severity-tagged list (- [High] title) or a "No findings." line;
+ without its INPUT line it binds only with --attest and --attempt (receipt: attested)
+ submit --out --phase --voice outside --unavailable | --failed
+ the outside voice could not run: closes the phase native_only once native is bound
+ revise --out --plan [--incorporates ] [--withdraw ]
+ review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (plus Eng);
+ --incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one
run --out --plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api] ...
next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
answer --out --gate-rev --reply "" [--json]
@@ -68,6 +85,38 @@ function num(args: string[], name: string): number | undefined {
return n;
}
const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=autoplan status=${status} run=${out}`;
+const listFlag = (args: string[], name: string) => args.flatMap((a, i) => (a === name && args[i + 1] ? args[i + 1]!.split(',').map(x => x.trim()).filter(Boolean) : []));
+/** The NEXT line after a reopen or revise: revise while accepted changes are pending, else next. */
+function nextLine(state: RunState, out: string): string {
+ const pending = pendingAmendments(state).map(a => a.decision);
+ return pending.length ? `NEXT: gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')}` : `NEXT: gstack-autoplan next --out ${out}`;
+}
+
+/**
+ * Admission, then under the run lock: bring an existing run forward (GSTACK_VERSION_CHANGED, legacy decision ids)
+ * and record an admission override as a deviation. Notices go to stdout before the command's own lines (stderr with --json).
+ */
+function mutating(out: string, json: boolean, fn: () => number): number {
+ const admission = admit(process.env);
+ return withRunLock(out, () => {
+ const notices = [...admission.lines];
+ const record = (state: RunState) => {
+ if (!admission.deviation || (state.deviations ?? []).includes(admission.deviation)) return false;
+ (state.deviations ??= []).push(admission.deviation);
+ return true;
+ };
+ if (fs.existsSync(path.join(out, FILES.run))) {
+ const state = readState(out);
+ const migrated = migrateRun(state);
+ if (record(state) || migrated.changed) writeState(state);
+ notices.push(...migrated.lines);
+ }
+ if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n');
+ const code = fn();
+ if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); }
+ return code;
+ });
+}
function emit(json: boolean, lines: string[], payload: Record): void {
if (json) process.stdout.write(JSON.stringify(payload) + '\n');
@@ -76,17 +125,18 @@ function emit(json: boolean, lines: string[], payload: Record):
function doNext(out: string, rest: string[], json: boolean): number {
const cwd = process.cwd();
+ const initLines: string[] = [];
if (!fs.existsSync(path.join(out, FILES.run))) {
const plan = flag(rest, '--plan');
if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan on the first next`, 3);
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null,
- deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary') } });
+ deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary') } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
const status = r.state.status;
- const lines = [...r.lines];
+ const lines = [...initLines, ...r.lines];
if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`);
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
@@ -95,16 +145,33 @@ function doNext(out: string, rest: string[], json: boolean): number {
}
function doSubmit(out: string, rest: string[], json: boolean): number {
- const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt');
- if (!phase || !voice || !result || !model || !attempt) return usage('submit needs --phase --voice --result --model --attempt');
+ const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const record = flag(rest, '--record');
+ const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed');
+ if (!phase || !voice) return usage('submit needs --phase and --voice');
if (voice !== 'native' && voice !== 'outside') return usage('--voice must be native or outside');
- const r = submitResult({ out, phase, voice, result: path.resolve(result), model, attempt, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() });
+ const outcome = unavailable !== undefined || failed !== undefined;
+ if (outcome && (voice !== 'outside' || result || record || (unavailable !== undefined && failed !== undefined))) return usage('--unavailable|--failed "" records the outside voice only, without --result or --record');
+ if (!outcome && (!!result === !!record || !model)) return usage('submit needs exactly one of --result | --record , and --model ');
+ const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'),
+ unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() });
const status = r.state.status;
const lines = [...r.lines];
if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`);
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
- emit(json, lines, { run: r.state.run, status, phase, voice, attempt, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out });
+ emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out });
+ return EXIT.ok;
+}
+
+function doRevise(out: string, rest: string[], json: boolean): number {
+ const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw');
+ if (!plan && !withdraw.length) return usage('revise needs --plan (and optionally --incorporates ), or --withdraw ');
+ if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan ');
+ const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, cwd: process.cwd() });
+ const lines = [...r.lines];
+ if (r.state.status === 'running') lines.push(nextLine(r.state, out));
+ lines.push(resultLineFor(r.state.status, out));
+ emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out });
return EXIT.ok;
}
@@ -159,9 +226,10 @@ async function main(argv: string[]): Promise {
if (!outFlag) return usage(`${cmd} needs --out `);
const out = path.resolve(outFlag);
try {
- if (cmd === 'next') return withRunLock(out, () => doNext(out, rest, json));
- if (cmd === 'submit') return withRunLock(out, () => doSubmit(out, rest, json));
- if (cmd === 'run') return await doRun(out, rest, json);
+ if (cmd === 'next') return mutating(out, json, () => doNext(out, rest, json));
+ if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json));
+ if (cmd === 'revise') return mutating(out, json, () => doRevise(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);
if (json) process.stdout.write(JSON.stringify(state) + '\n'); else process.stdout.write(statusLines(state).join('\n') + '\n');
@@ -179,20 +247,22 @@ async function main(argv: string[]): Promise {
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply');
if (reply === undefined) return usage('answer needs --reply ""');
if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
- return withRunLock(out, () => {
+ return mutating(out, json, () => {
const state = readState(out);
const r = answerGate(state, Number(rev), reply);
writeState(state);
const lines = [...r.lines];
- if (r.outcome === 'reopened') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
+ if (r.outcome === 'reopened') lines.push(nextLine(state, out));
lines.push(resultLineFor(state.status, out));
- emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, gate_rev: state.gate_rev, out });
+ emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}
if (cmd === 'export') {
- return withRunLock(out, () => {
+ return mutating(out, json, () => {
const state = readState(out);
+ const pending = pendingAmendments(state).map(a => a.decision);
+ if (state.status === 'complete' && pending.length) throw new RunError('PLAN_AMEND_PENDING', `run says complete with accepted changes not in the plan: ${pending.join(', ')}`, 3);
const r = exportRun(state);
writeState(state);
for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`);
@@ -211,7 +281,7 @@ async function main(argv: string[]): Promise {
} catch (e: any) {
if (e instanceof RunError) {
process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n');
- if (['next', 'submit', 'answer', 'resume', 'run'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n');
+ if (['next', 'submit', 'answer', 'resume', 'run', 'revise'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n');
return e.exit === 3 ? EXIT.refused : EXIT.fail;
}
process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`);
diff --git a/docs/unattended.md b/docs/unattended.md
index 2141f626e..23b6a2788 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -182,15 +182,25 @@ $AP next --out --plan [--ui|--no-ui] [--developer-tool] [--light
disagreements as auto items and `p1` plan approval; nothing is approved.
4. **Answer the gate** with the reply grammar and the revision you read:
```bash
- $AP answer --out --gate-rev 1 --reply "all" # APPROVED: status=complete, no new paid phase
- $AP answer --out --gate-rev 1 --reply "all except d1b" # REOPENED: ceo,eng (Eng last); next continues the loop
+ $AP answer --out --gate-rev 1 --reply "all" # every recommendation; agreeing plan changes print APPLY:
+ $AP answer --out --gate-rev 1 --reply "all as-is" # APPROVED: keeps every direction, no new paid phase
+ $AP answer --out --gate-rev 1 --reply "all except d1b" # APPLY: ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ...
```
+ A user challenge both voices raised on a confirmed finding with the same
+ `changes` value is recommended as `b`, and option b shows both voices'
+ remedies (`recommended: b (both voices; remedies shown)`); a `--reconcile`
+ record naming one shared `proposal` shows that instead. Disagreeing or
+ missing remedies keep `a`. Every accepted plan change prints
+ `APPLY: -> ` and becomes a pending
+ amendment: `next` refuses with `PLAN_AMEND_PENDING` and `export` refuses a
+ `complete` run until `revise --incorporates` names it (see below).
A stale revision (`GATE_REV_STALE`, exit 3), bare `yes`/`no`, or a
conflicting reply (`GATE_REPLY_UNPARSED`) writes nothing. A reply that
leaves approval items pending records only the answered auto items and
- keeps the gate. A non-recommended choice on a reviewed item reopens that
- phase plus Eng, archives their bound files as `*.r.*`, and the next
- gate is `gate_rev + 1`; `p1d` reopens every phase; `p1e` ends `incomplete`.
+ keeps the gate; a later reply never overwrites an earlier answer. A choice
+ that changes the plan reopens that phase plus Eng, archives their bound
+ files as `*.r.*`, and the next gate is `gate_rev + 1`; `p1d` reruns
+ every phase on the current plan; `p1e` ends `incomplete`.
5. **Export and validate.** `$AP export --out ` writes `plan.md` (with
the `## URGENT, outside this plan` block first), `review-record.md`,
`tasks.jsonl`, `decisions.jsonl`, `findings.jsonl`, `timing.json` and
@@ -219,7 +229,8 @@ Each prompt file ends with this contract; `submit` enforces it.
object per line: `{"id":"F1","severity":"High","title":"...","file":"...","line":12,"fix":"..."}`
inside a ```` ```gstack-findings ```` fence. `severity` is Critical | High |
Medium | Low | Informational; `"user_challenge": true` marks a recommended
- change to the plan's stated direction; `"urgent": true` with
+ change to the plan's stated direction and needs `"changes": "scope" |
+ "cost" | "direction"` (what accepting it changes); `"urgent": true` with
`"suggested_owner"` marks a security or data-loss finding the plan does not
own. An empty fence means no findings; a missing fence is refused.
3. The whole result is written to the printed `result=` path.
@@ -241,6 +252,41 @@ by its runner. Result codes: `EXECUTION_UNKNOWN`, `RUN_LOCKED`,
`SPEND_CAP_EXCEEDED`, `CONSENSUS_MISSING`, `OUTSIDE_RUNNER_UNAVAILABLE`
(anchors in [troubleshooting.md](troubleshooting.md)).
+### Hand-run records, revised plans and admission
+
+- **Hand-run reviews.** `submit --record ` binds a review that did not
+ follow the RESULT FORMAT: the `gstack-findings` fence, one findings table
+ (Severity and Title columns), one severity-tagged list (`- [High] title`) or
+ a `No findings.` line. Without its `INPUT:` line it binds only with
+ `--attest ""` and `--attempt ` (receipt `attested`; refused after
+ a later `revise`), and `gstack-artifact validate` prints `attested=`.
+ `--attempt` may be omitted when exactly one attempt is open.
+ `submit --voice outside --unavailable ""` (or `--failed`) closes the
+ phase `native_only` once native is bound; `next` and `resume` never
+ redispatch it.
+- **Revising the plan in the same run.** Edit the source plan, then
+ `$AP revise --out --plan [--incorporates ]`.
+ Every phase whose reviewed Implementation-plan hash differs reopens (plus
+ Eng); newly in-scope phases are added; archives keep `.r` names with
+ `.v` only on collision; a revise at `gate_pending` makes the next gate
+ `gate_rev + 1`. `--incorporates` discharges only the named amendments and
+ refuses an unchanged plan; `revise --out --withdraw `
+ drops one as a journaled owner decision. `next` prints `PLAN_CHANGED` and
+ `answer` prints `PLAN_SOURCE_CHANGED` when the source plan moved since the
+ snapshot. A plan without both `## Implementation plan` and `## Review record`
+ refuses with `PLAN_SECTIONS_INVALID`; `next --scaffold` reviews a copy with
+ the missing heading added. `resume` completes an interrupted revise.
+- **Admission and upgrades.** `next`, `submit`, `answer`, `revise`, `export`
+ and `run` refuse on a Capy host (`GSTACK_CAPY=1`) without an ok install
+ verdict for this checkout at `$GSTACK_INSTALL_VERDICT`
+ (`INSTALL_VERDICT_FAILED`), and in any unattended session when the Bun on
+ PATH is below `engines.bun` (`RUNTIME_BELOW_MINIMUM`);
+ `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` overrides and records a run deviation.
+ `status` and `resume` are never gated. A run directory written by an older
+ gstack prints `GSTACK_VERSION_CHANGED` once and moves forward (legacy
+ decision ids are renamed and journaled); one written by a newer gstack is
+ refused.
+
## Owner brief
`gstack-owner-brief render --out ` is what the owner sees at the final
diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts
index 4c2c05b11..fd617380e 100644
--- a/lib/autoplan-gate.ts
+++ b/lib/autoplan-gate.ts
@@ -7,21 +7,28 @@
* (lib/gate-list.ts: `d` auto items, `uc` approval items, `p1` the plan
* approval), writes decisions.jsonl, tasks.jsonl and the exported files, and
* leaves the run `gate_pending`: unattended never approves. `answerGate`
- * applies one reply atomically (a stale, conflicting or partial reply writes
- * nothing); a non-recommended choice that changes reviewed content reopens the
- * dependent phases with Eng last, and only a reply that changes nothing
- * reviewed finalizes `run.json.status`.
+ * applies one reply atomically (a stale or conflicting reply writes nothing;
+ * a partial one records its answers and keeps the gate); a chosen option that
+ * changes the plan (`changes_plan`) reopens the dependent phases with Eng last
+ * and is recorded as a pending amendment, and only a reply that changes
+ * nothing reviewed finalizes `run.json.status`. Rerun gates turn challenges
+ * that name no scope, cost or direction into agent decisions, and answers to
+ * unchanged proposals from phases that did not rerun carry forward.
*/
+import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
-import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type ReconciliationOverride } from './autoplan-reconcile';
+import { driftLine } from './autoplan-plan-text';
+import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
import type { Phase } from './autoplan-prompts';
-import { RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type RunState, type VoiceState } from './autoplan-state';
+import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type RunState, type VoiceState } from './autoplan-state';
import { phaseClose } from './autoplan-timing';
-import { applyReply, decisionRows, parseReply, validateGateList, type GateItem, type GateList } from './gate-list';
+import { applyReply, decisionId, decisionRows, keepDirection, parseReply, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
+import type { FindingRow } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
+import { settle } from './spend-ledger';
export function loadOverrides(file: string | undefined): ReconciliationOverride[] {
if (!file) return [];
@@ -60,6 +67,35 @@ export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: s
// the gate list
// ---------------------------------------------------------------------------
const CONTENT_SCOPE: Record = { ceo: 'scope', design: 'design', dx: 'dx', eng: 'arch' };
+const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16);
+const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false });
+
+/**
+ * A user challenge both voices raised recommends the change only when the
+ * finding reconciled `confirmed`, both name the same `changes` and the
+ * remedies are on the record (both voices', or the parent's one shared
+ * proposal from a reconcile override); option b shows them verbatim. Anything
+ * weaker keeps the plan's direction, and the basis says why.
+ */
+function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } {
+ const nf = n as (FindingRow & { changes?: string }) | undefined, of = o as FindingRow & { changes?: string };
+ const remedies = row.proposal ? ` (shared proposal: ${row.proposal})`
+ : nf?.resolution || of.resolution ? ` (native: ${nf?.resolution ?? 'no remedy stated'}; outside: ${of.resolution ?? 'no remedy stated'})` : '';
+ const text = `accept the change both reviewers recommend${remedies}`;
+ const keep = (basis: string) => ({ recommended: 'a' as const, basis: `plan direction; ${basis}`, text });
+ if (row.disposition !== 'confirmed') return keep('voices disagree');
+ if (!nf?.changes || !of.changes) return keep('no shared changes value');
+ if (nf.changes !== of.changes) return keep('voices name different changes');
+ if (row.proposal) return { recommended: 'b', basis: 'both voices; shared proposal', text };
+ if (!nf.resolution || !of.resolution) return keep('a remedy is missing');
+ return { recommended: 'b', basis: 'both voices; remedies shown', text };
+}
+
+/** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */
+function agentDecision(id: string, title: string, phase: string, why: string): GateItem {
+ return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
+ cost: 'agent decision; b is recorded, no phase reruns', options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change (agent decision; no phase reruns)', changes_plan: false }] };
+}
export function buildGate(state: RunState, gateRev: number): GateList {
const items: GateItem[] = [];
@@ -71,50 +107,73 @@ export function buildGate(state: RunState, gateRev: number): GateList {
const native = voiceRows(state, phase, 'native');
const outside = voiceRows(state, phase, 'outside');
const byId = new Map([...native, ...outside].map(f => [f.id, f]));
+ const reran = gateRev > 1 && state.history.some(h => h.event === 'reopened' && h.phase === phase);
+ const origin = (ids: string, options: GateOption[]) => [phase, ids, ps.closed_at, ps.snapshot, optionHash(options)].join('|');
+ const push = (item: GateItem, ids: string) => items.push({ ...item, origin: origin(ids, item.options) });
const challenged = new Set();
for (const row of consensus.rows) {
- const o = byId.get(row.outside_id)!;
- const n = row.native_id ? byId.get(row.native_id) : undefined;
- const bothChallenge = !!(o as any).user_challenge && !!(n as any)?.user_challenge && row.disposition !== 'new';
- if (bothChallenge) {
- challenged.add(o.id); if (n) challenged.add(n.id);
- items.push({ id: `uc${++uc}`, title: `${n?.title ?? o.title} [${row.native_id} + ${row.outside_id}]`, kind: 'approval', recommended: 'a', phase,
- why: `both voices recommend changing your stated direction; blind spot: ${o.title}; cost if wrong: ${o.resolution ?? n?.resolution ?? 'unstated'}`,
- options: [{ key: 'a', text: 'keep your direction (the plan as reviewed)' }, { key: 'b', text: 'accept the change both reviewers recommend' }] });
+ const o = byId.get(row.outside_id)! as FindingRow & { user_challenge?: boolean; changes?: string };
+ const n = (row.native_id ? byId.get(row.native_id) : undefined) as (FindingRow & { user_challenge?: boolean; changes?: string }) | undefined;
+ const ids = `${row.native_id}+${row.outside_id}`;
+ if (o.user_challenge && n?.user_challenge && row.disposition !== 'new') {
+ challenged.add(o.id); challenged.add(n.id);
+ const title = `${n.title} [${row.native_id} + ${row.outside_id}]`;
+ if (reran && !n.changes && !o.changes) { push(agentDecision(`d${++d}`, title, phase, 'both voices recommend changing your stated direction'), ids); continue; }
+ const def = challengeDefault(row, n, o);
+ push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
+ why: `both voices recommend changing your stated direction; blind spot: ${o.title}; cost if wrong: ${o.resolution ?? n.resolution ?? 'unstated'}`,
+ options: [KEEP('keep your direction (the plan as reviewed)'), { key: 'b', text: def.text, changes_plan: true }] }, ids);
continue;
}
if (row.disposition === 'disagree') {
- items.push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
+ push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
why: `voices disagree: ${row.resolution}`, cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`,
- options: [{ key: 'a', text: `native position (${row.severity.native})` }, { key: 'b', text: `outside position (${row.severity.outside})` }] });
+ options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids);
}
}
- for (const f of [...native, ...outside]) {
- if (!(f as any).user_challenge || challenged.has(f.id)) continue;
- items.push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why: `one voice (${f.voice}) recommends changing your stated direction`,
- cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [{ key: 'a', text: 'keep your direction' }, { key: 'b', text: 'accept the change' }] });
+ for (const f of [...native, ...outside] as Array) {
+ if (!f.user_challenge || challenged.has(f.id)) continue;
+ const why = `one voice (${f.voice}) recommends changing your stated direction`;
+ if (reran && !f.changes) { push(agentDecision(`d${++d}`, `${f.title} [${f.id}]`, phase, why), f.id); continue; }
+ push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why, ...(f.changes ? { changes: f.changes } : {}),
+ cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
}
}
- items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'a',
- options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: 'approve with the overrides above' }, { key: 'c', text: 'interrogate (ask questions; stays pending)' }, { key: 'd', text: 'revise the plan (every phase reruns, Eng last)' }, { key: 'e', text: 'reject' }] });
+ const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
+ items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a',
+ options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
+ { key: 'c', text: 'interrogate (ask questions; stays pending)' },
+ { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan )` }, { key: 'e', text: 'reject' }] });
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' };
const problems = validateGateList(list);
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
return list;
}
-/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. */
+/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */
export function finishGate(state: RunState): GateList {
const gateRev = (state.gate_rev ?? 0) + 1;
const list = buildGate(state, gateRev);
- fs.writeFileSync(path.join(state.out, 'gate.json'), JSON.stringify(list, null, 2) + '\n');
const kept = readDecisions(state.out).filter(r => r.gate_rev !== gateRev);
- writeDecisions(state.out, [...kept, ...decisionRows(list)]);
+ const answered = new Map(kept.filter(r => r.origin && r.chosen != null && r.status !== 'pending').map(r => [r.origin!, r]));
+ for (const item of list.items) {
+ 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');
+ 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;
+ const prev = answered.get(item.origin!)!;
+ return { ...row, chosen: item.carried.chosen, status: item.carried.chosen === item.recommended ? 'approved' as const : 'overridden' as const,
+ answered_at: prev.answered_at, reply: prev.reply, carried_from: item.carried.from, ...(prev.chosen_text ? { chosen_text: prev.chosen_text } : {}) };
+ });
+ writeDecisions(state.out, [...kept, ...rows]);
state.gate_rev = gateRev;
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 });
+ journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id) });
return list;
}
@@ -123,36 +182,53 @@ export function finishGate(state: RunState): GateList {
// ---------------------------------------------------------------------------
export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected'; lines: string[] }
+/**
+ * Apply one reply. Earlier answers at this revision stand unless named again;
+ * the gate stays pending until every approval settles. A chosen option with
+ * `changes_plan` is an accepted plan change: it reopens its phase plus Eng,
+ * prints `APPLY:` and becomes a pending amendment (`next` refuses until
+ * `revise --incorporates` or `--withdraw`).
+ */
export function answerGate(state: RunState, gateRev: number, reply: string): AnswerResult {
const gateFile = path.join(state.out, 'gate.json');
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
- const parsed = parseReply(reply, list, gateRev);
+ 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!]));
+ const parsed = parseReply(reply, list, gateRev, prior);
if (parsed.error === 'GATE_REV_STALE') throw new RunError('GATE_REV_STALE', `reply names gate_rev=${gateRev}, current gate_rev=${list.gate_rev}`, 3);
if (!parsed.valid) throw new RunError('GATE_REPLY_UNPARSED', parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`);
- const choice = (id: string) => parsed.choices.find(c => c.id === id);
- const plan = choice('p1');
+ 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 answeredAt = now();
- if (!plan) {
- // Approval items unanswered: record the auto items, keep the gate pending; nothing reviewed changes.
- writeDecisions(state.out, applyReply(readDecisions(state.out), parsed, answeredAt));
- state.gate = { ...(state.gate ?? {}), last_reply: reply, pending: parsed.pending };
- lines.push(`GATE_PENDING: ${parsed.pending.join(', ')} unanswered (reply with p1a..p1e or all)`);
- return { state, applied: true, pending: parsed.pending, reopened: [], outcome: 'gate_pending', lines };
+ const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt).map(r => {
+ const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
+ return c ? { ...r, chosen_text: c.text } : r;
+ }));
+ if (!plan || parsed.pending.length) {
+ // Approvals unanswered: record what was answered, keep the gate pending; nothing reviewed changes.
+ record();
+ const pending = parsed.pending.length ? parsed.pending : ['p1'];
+ state.gate = { ...(state.gate ?? {}), last_reply: reply, pending };
+ lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (reply with p1a..p1e or all)`);
+ return { state, applied: true, pending, reopened: [], outcome: 'gate_pending', lines };
}
+ const proceeds = plan.chosen !== 'c' && plan.chosen !== 'e';
+ const carried = (c: GateChoice) => itemOf(c.id).carried?.chosen === c.chosen;
+ const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : [];
+ const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`);
const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1');
const reopenSet = new Set();
if (plan.chosen === 'd') state.phase_order.forEach(p => reopenSet.add(p));
- else if (plan.chosen !== 'c' && plan.chosen !== 'e') {
- for (const o of overrides) {
- const item = list.items.find(i => i.id === o.id)!;
- if (item.phase) { reopenSet.add(item.phase as Phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); }
- }
+ for (const c of changing) {
+ const phase = itemOf(c.id).phase as Phase | undefined;
+ if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); }
}
- writeDecisions(state.out, applyReply(readDecisions(state.out), parsed, answeredAt));
- state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: overrides.map(o => `${o.id}${o.chosen}`) };
- journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: overrides.map(o => `${o.id}${o.chosen}`), reopen: [...reopenSet] });
+ record();
+ state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides) };
+ journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet] });
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 };
@@ -164,16 +240,26 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
lines.push('REJECTED: run.json.status=incomplete');
return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines };
}
+ for (const c of changing) {
+ const decision = decisionId(state.run, gateRev, c.id);
+ (state.amendments ??= []).push({ decision, title: itemOf(c.id).title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt });
+ journalRow(state.out, { event: 'amendment_pending', decision, gate_rev: gateRev });
+ lines.push(`APPLY: ${decision} ${itemOf(c.id).title} -> ${optionOf(c).text}`);
+ }
if (reopenSet.size === 0) {
state.status = 'complete';
lines.push(...closeDraftDirections(state, answeredAt.slice(0, 10)));
exportRun(state);
- lines.push(`APPROVED: gate_rev=${gateRev} ${overrides.length ? `overrides=${overrides.map(o => `${o.id}${o.chosen}`).join(',')} (none change reviewed content)` : 'as-is'}`);
+ const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id)));
+ lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`);
return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines };
}
const reopened = state.phase_order.filter(p => reopenSet.has(p));
- reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: ${plan.chosen === 'd' ? 'revise' : overrides.map(o => `${o.id}${o.chosen}`).join(',')}`);
- lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=${plan.chosen === 'd' ? 'revise' : overrides.map(o => `${o.id}${o.chosen}`).join(',')}`);
+ const reason = plan.chosen === 'd' ? 'revise' : tokens(changing).join(',');
+ reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: ${reason}`);
+ lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=${reason}`);
+ const drift = driftLine(state, 'PLAN_SOURCE_CHANGED');
+ if (drift) lines.push(drift);
return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines };
}
@@ -187,18 +273,35 @@ function closeDraftDirections(state: RunState, date: string): string[] {
return [`DRAFT_DIRECTIONS: rewritten=${r.rewrites.length - r.unresolved.length} unresolved=${r.unresolved.length}`];
}
-/** Archive the reopened phases' bound files under their gate revision and reset them; the gate itself is archived too. */
-export function reopenPhases(state: RunState, phases: Phase[], gateRev: number, reason: string): void {
- const archive = (name: string) => {
- const from = path.join(state.out, name);
- if (!fs.existsSync(from)) return;
- const ext = path.extname(name);
- fs.renameSync(from, path.join(state.out, `${name.slice(0, -ext.length || undefined)}.r${gateRev}${ext}`));
- };
+/** `.r`, or `.r.v` when that name is taken: an archive is never overwritten. */
+export function archiveName(out: string, name: string, gateRev: number, taken: Set): string {
+ const ext = path.extname(name);
+ const stem = name.slice(0, -ext.length || undefined);
+ for (let k = 1; ; k++) {
+ const candidate = `${stem}.r${gateRev}${k > 1 ? `.v${k}` : ''}${ext}`;
+ if (!taken.has(candidate) && !fs.existsSync(path.join(out, candidate))) { taken.add(candidate); return candidate; }
+ }
+}
+
+/**
+ * Reset the reopened phases in `state` (history, superseded open attempts,
+ * artifacts) and return the archive renames still to perform, so a caller can
+ * journal them before touching the directory (revise's transaction).
+ */
+export function planReopen(state: RunState, phases: Phase[], gateRev: number, reason: string): Array<[string, string]> {
+ const taken = new Set();
+ const renames: Array<[string, string]> = [];
+ const archive = (name: string) => { if (fs.existsSync(path.join(state.out, name))) renames.push([name, archiveName(state.out, name, gateRev, taken)]); };
for (const phase of phases) {
const ps = state.phases[phase] ?? {};
state.history.push({ event: 'reopened', phase, gate_rev: gateRev, reason, at: now(), previous: ps });
for (const voice of ['native', 'outside'] as const) {
+ for (const a of ps[voice]?.attempts ?? []) {
+ if (a.state !== 'dispatched' && a.state !== 'execution_unknown') continue;
+ a.state = 'superseded'; a.terminal_at = now();
+ settle(path.join(state.out, FILES.spend), a.attempt, 'unknown');
+ journalRow(state.out, { event: 'superseded', attempt: a.attempt, phase, voice, reason });
+ }
const output = ps[voice]?.output;
if (output) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; }
archive(`${phase}-${voice}-prompt.md`);
@@ -210,6 +313,20 @@ export function reopenPhases(state: RunState, phases: Phase[], gateRev: number,
}
archive('gate.json');
delete state.artifacts['gate.json'];
+ return renames;
+}
+
+/** Perform archive renames; a rename already done (source gone, target present) is skipped, so a recovery can replay the list. */
+export function applyRenames(out: string, renames: Array<[string, string]>): void {
+ for (const [from, to] of renames) {
+ if (fs.existsSync(path.join(out, to))) continue;
+ if (fs.existsSync(path.join(out, from))) fs.renameSync(path.join(out, from), path.join(out, to));
+ }
+}
+
+/** Archive the reopened phases' bound files under their gate revision and reset them; the gate itself is archived too. */
+export function reopenPhases(state: RunState, phases: Phase[], gateRev: number, reason: string): void {
+ applyRenames(state.out, planReopen(state, phases, gateRev, reason));
state.status = 'running';
exportRun(state);
}
diff --git a/lib/autoplan-prompts.ts b/lib/autoplan-prompts.ts
index 0b1396a03..f9ab28c2b 100644
--- a/lib/autoplan-prompts.ts
+++ b/lib/autoplan-prompts.ts
@@ -70,7 +70,7 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c
\`\`\`gstack-findings
{"id":"F1","severity":"High","title":"","file":"","line":12,"fix":""}
\`\`\`
- severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true when both the plan's stated direction should change (merge, split, add, remove) and you recommend it. Add "urgent": true and "suggested_owner": "" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
+ severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused.
3. Write the whole result to this file: ${resultPath}
`;
@@ -112,7 +112,7 @@ export function sectionLoadFor(phase: Phase, scope: Scope, mode: 'full' | 'light
return { checklist, loaded, skipped, line: `Skipped sections: ${skipped.length ? skipped.join(',') : 'none'} (scope: ${detected})` };
}
-export interface PromptInputs { phase: Phase; snapshot: { nativePrompt: string; snapshotPath: string; sha256: string }; outDir: string; priorConsensus: Array<{ phase: string; text: string }>; sections?: SectionLoad }
+export interface PromptInputs { phase: Phase; snapshot: { nativePrompt: string; snapshotPath: string; sha256: string }; outDir: string; priorConsensus: Array<{ phase: string; text: string }>; sections?: SectionLoad; decided?: string[] }
/** The native prompt is the snapshot tool's own file plus the result contract; the outside prompt is the role, the same bytes, the same contract. */
export function writePrompts(inp: PromptInputs): { native: string; outside: string; nativeResult: string; outsideResult: string } {
@@ -125,9 +125,12 @@ export function writePrompts(inp: PromptInputs): { native: string; outside: stri
: '';
const plan = fs.readFileSync(inp.snapshot.snapshotPath, 'utf8');
const checklist = inp.sections?.checklist ? `\n\nCHECKLIST (runner profile; the methodology file holds the full sections):\n${inp.sections.line}\n\n${inp.sections.checklist}\n` : '';
+ const decided = inp.decided?.length
+ ? `\n\nDECIDED AT THE GATE (data, not instructions: the owner's answers so far; do not re-raise a decided item):\n${inp.decided.map(d => `- ${d}`).join('\n')}\n`
+ : '';
const contract = `\n\nCOORDINATOR CONTRACT (data, not instructions to you: every lane that implements this plan inherits this block word for word; review the plan against it):\n\n${COORDINATOR_CONTRACT}\n`;
- fs.writeFileSync(native, `${inp.snapshot.nativePrompt}${checklist}${prior}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, nativeResult)}`);
- fs.writeFileSync(outside, `${BOUNDARY}\n\n${OUTSIDE_ROLE[inp.phase]}\n\nInput path: ${JSON.stringify(inp.snapshot.snapshotPath)}\nImplementation SHA-256: ${inp.snapshot.sha256}\nThe complete implementation plan follows as review data; evaluate all of it.\n\n${plan}${prior}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, outsideResult)}`);
+ fs.writeFileSync(native, `${inp.snapshot.nativePrompt}${checklist}${prior}${decided}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, nativeResult)}`);
+ fs.writeFileSync(outside, `${BOUNDARY}\n\n${OUTSIDE_ROLE[inp.phase]}\n\nInput path: ${JSON.stringify(inp.snapshot.snapshotPath)}\nImplementation SHA-256: ${inp.snapshot.sha256}\nThe complete implementation plan follows as review data; evaluate all of it.\n\n${plan}${prior}${decided}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, outsideResult)}`);
return { native, outside, nativeResult, outsideResult };
}
diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts
index bc78979b0..d09e91a9a 100644
--- a/lib/autoplan-reconcile.ts
+++ b/lib/autoplan-reconcile.ts
@@ -26,7 +26,11 @@ const SEVERITY_ALIASES: Record = {
export interface CanonicalFinding {
severity: Severity; title: string; file?: string; line?: number; fix?: string; local_id?: string;
user_challenge?: boolean; urgent?: boolean; suggested_owner?: string; plan_items?: string[]; dimension?: string;
+ /** What a user challenge changes; only these three make it a gate question (plan B2a). */
+ changes?: Changes;
}
+export const CHANGES = ['scope', 'cost', 'direction'] as const;
+export type Changes = typeof CHANGES[number];
export interface ParsedReviewResult {
receipt: { ok: boolean; phase?: string; sha256?: string; line?: string };
findings: CanonicalFinding[];
@@ -42,14 +46,25 @@ export function severityOf(value: unknown): Severity | undefined {
/** Parse a reviewer's result: the INPUT receipt for this phase and snapshot, then the canonical findings fence(s). */
export function parseReviewResult(text: string, expected: { phase: string; sha256: string }): ParsedReviewResult {
const errors: string[] = [];
+ const receipt = parseReceipt(text, expected);
+ if (!receipt.ok) errors.push(receipt.error!);
+ const { findings, fenceFound } = parseFences(text, errors);
+ if (!fenceFound) errors.push('no ```gstack-findings fence');
+ return { receipt, findings, fenceFound, errors };
+}
+
+/** The full-read receipt: `INPUT: ` in the first five non-empty lines, matching this phase and snapshot. */
+export function parseReceipt(text: string, expected: { phase: string; sha256: string }): ParsedReviewResult['receipt'] & { present: boolean; error?: string } {
const head = text.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 5);
const receiptLine = head.find(l => /^INPUT:\s*\S+\s+[0-9a-f]{64}\b/i.test(l));
const m = receiptLine ? /^INPUT:\s*(\S+)\s+([0-9a-f]{64})/i.exec(receiptLine) : null;
- const receipt = m
- ? { ok: m[1]!.toLowerCase() === expected.phase && m[2]!.toLowerCase() === expected.sha256.toLowerCase(), phase: m[1]!.toLowerCase(), sha256: m[2]!.toLowerCase(), line: receiptLine }
- : { ok: false };
- if (!receipt.ok) errors.push(m ? `receipt names ${m[1]} ${m[2]!.slice(0, 12)}…, expected ${expected.phase} ${expected.sha256.slice(0, 12)}…` : 'no INPUT: line in the first five lines');
+ if (!m) return { ok: false, present: false, error: 'no INPUT: line in the first five lines' };
+ const ok = m[1]!.toLowerCase() === expected.phase && m[2]!.toLowerCase() === expected.sha256.toLowerCase();
+ return { ok, present: true, phase: m[1]!.toLowerCase(), sha256: m[2]!.toLowerCase(), line: receiptLine,
+ ...(ok ? {} : { error: `receipt names ${m[1]} ${m[2]!.slice(0, 12)}…, expected ${expected.phase} ${expected.sha256.slice(0, 12)}…` }) };
+}
+function parseFences(text: string, errors: string[]): { findings: CanonicalFinding[]; fenceFound: boolean } {
const findings: CanonicalFinding[] = [];
let fenceFound = false;
for (const fence of text.matchAll(/^[ \t]*(`{3,}|~{3,})gstack-findings[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1[ \t]*$/gm)) {
@@ -73,11 +88,88 @@ export function parseReviewResult(text: string, expected: { phase: string; sha25
if (typeof row.suggested_owner === 'string') finding.suggested_owner = row.suggested_owner;
if (Array.isArray(row.plan_items)) finding.plan_items = row.plan_items.filter((p: unknown): p is string => typeof p === 'string');
if (typeof row.dimension === 'string') finding.dimension = row.dimension;
+ if (row.changes !== undefined) {
+ if (!(CHANGES as readonly unknown[]).includes(row.changes)) { errors.push(`findings row ${i + 1}: changes ${JSON.stringify(row.changes)} is not one of ${CHANGES.join('|')}`); return; }
+ finding.changes = row.changes;
+ }
findings.push(finding);
});
}
- if (!fenceFound) errors.push('no ```gstack-findings fence');
- return { receipt, findings, fenceFound, errors };
+ return { findings, fenceFound };
+}
+
+export const RECORD_SHAPES = 'a ```gstack-findings fence, exactly one findings table (Severity and Title columns), one severity-tagged list (`- [High] title`), or an exact `No findings.` line';
+const SEVERITY_HEADER = /^(severity|sev|priority)$/i;
+const TITLE_HEADER = /^(finding|title|issue|summary)$/i;
+const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
+
+/** Findings tables in a record: header row with severity and title columns, a delimiter row, then body rows. */
+function recordTables(lines: string[]): Array<{ header: string[]; rows: Array<{ cells: string[]; n: number }> }> {
+ const tables: Array<{ header: string[]; rows: Array<{ cells: string[]; n: number }> }> = [];
+ for (let i = 0; i + 1 < lines.length; i++) {
+ if (!/^\s*\|/.test(lines[i]!) || !/^\s*\|?\s*:?-{3,}/.test(lines[i + 1]!)) continue;
+ const header = cells(lines[i]!).map(h => h.replace(/[*_`]/g, '').trim());
+ if (!header.some(h => SEVERITY_HEADER.test(h)) || !header.some(h => TITLE_HEADER.test(h))) continue;
+ const rows: Array<{ cells: string[]; n: number }> = [];
+ let j = i + 2;
+ for (; j < lines.length && /^\s*\|/.test(lines[j]!); j++) rows.push({ cells: cells(lines[j]!), n: rows.length + 1 });
+ tables.push({ header, rows });
+ i = j - 1;
+ }
+ return tables;
+}
+
+const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[([^\]]+)\]|\*\*([^*]+)\*\*|\(([^)]+)\))\s*:?\s*(\S.*)$/;
+
+/**
+ * Findings from a hand-run record (`submit --record`): the canonical fence
+ * when present; else exactly one of a findings table, a severity-tagged list
+ * or an exact `No findings.` line. More than one candidate shape, none, or a
+ * table row whose severity is not recognized is an error: the parser never
+ * guesses a severity.
+ */
+export function parseRecordFindings(text: string): { findings: CanonicalFinding[]; source: 'fence' | 'table' | 'list' | 'none'; errors: string[] } {
+ const errors: string[] = [];
+ const fenced = parseFences(text, errors);
+ if (fenced.fenceFound) return { findings: fenced.findings, source: 'fence', errors };
+ const lines = text.split('\n');
+ const tables = recordTables(lines);
+ const listBlocks: Array = [];
+ let current: CanonicalFinding[] | null = null;
+ for (const line of lines) {
+ const m = LIST_ITEM.exec(line);
+ const severity = m ? severityOf(m[1] ?? m[2] ?? m[3]) : undefined;
+ if (m && severity) { (current ??= (listBlocks.push([]), listBlocks[listBlocks.length - 1]!)).push({ severity, title: m[4]!.trim() }); continue; }
+ if (line.trim()) current = null;
+ }
+ const none = lines.some(l => l.trim() === 'No findings.');
+ const shapes = [...tables.map(() => 'table'), ...listBlocks.map(() => 'list'), ...(none ? ['No findings.'] : [])];
+ if (shapes.length === 0) return { findings: [], source: 'none', errors: [`no findings shape; expected ${RECORD_SHAPES}`] };
+ if (shapes.length > 1) return { findings: [], source: 'none', errors: [`more than one findings shape (${shapes.join(', ')}); keep exactly one of ${RECORD_SHAPES}`] };
+ if (none) return { findings: [], source: 'none', errors };
+ if (listBlocks.length) return { findings: listBlocks[0]!, source: 'list', errors };
+ const { header, rows } = tables[0]!;
+ const col = (re: RegExp) => header.findIndex(h => re.test(h));
+ const at = { sev: col(SEVERITY_HEADER), title: col(TITLE_HEADER), file: col(/^(file|location|where)$/i), fix: col(/^(fix|recommendation|remedy|resolution)$/i), id: col(/^(id|#)$/i), uc: col(/^user[ _]challenge$/i), changes: col(/^changes$/i) };
+ const findings: CanonicalFinding[] = [];
+ for (const row of rows) {
+ const cell = (i: number) => (i >= 0 ? (row.cells[i] ?? '').trim() : '');
+ const severity = severityOf(cell(at.sev).replace(/[*_`]/g, ''));
+ if (!severity) { errors.push(`table row ${row.n}: severity ${JSON.stringify(cell(at.sev))} is not one of ${SEVERITIES.join('|')}`); continue; }
+ if (!cell(at.title)) { errors.push(`table row ${row.n}: title is required`); continue; }
+ const f: CanonicalFinding = { severity, title: cell(at.title) };
+ const loc = /^`?([^`\s:]+?)(?::(\d+))?`?$/.exec(cell(at.file));
+ if (loc?.[1]) { f.file = loc[1]; if (loc[2]) f.line = Number(loc[2]); }
+ if (cell(at.fix)) f.fix = cell(at.fix);
+ if (cell(at.id)) f.local_id = cell(at.id);
+ if (/^(yes|true|y)$/i.test(cell(at.uc))) f.user_challenge = true;
+ if (cell(at.changes)) {
+ if (!(CHANGES as readonly string[]).includes(cell(at.changes).toLowerCase())) { errors.push(`table row ${row.n}: changes ${JSON.stringify(cell(at.changes))} is not one of ${CHANGES.join('|')}`); continue; }
+ f.changes = cell(at.changes).toLowerCase() as Changes;
+ }
+ findings.push(f);
+ }
+ return { findings, source: 'table', errors };
}
/** Run-bound findings.jsonl rows for one voice of one phase. */
@@ -90,6 +182,7 @@ export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string;
...(f.local_id ? { source: f.local_id } : {}),
...(f.user_challenge ? { user_challenge: true } : {}), ...(f.urgent ? { urgent: true } : {}),
...(f.suggested_owner ? { suggested_owner: f.suggested_owner } : {}), ...(f.dimension ? { dimension: f.dimension } : {}),
+ ...(f.changes ? { changes: f.changes } : {}),
} as FindingRow));
}
@@ -100,8 +193,10 @@ export interface ReconciliationRow {
resolution: string;
basis: 'file_line' | 'title' | 'override' | 'none';
severity: { outside: Severity; native: Severity | null };
+ /** A parent override's one shared proposal for both voices' user challenge; the gate renders it as option b. */
+ proposal?: string;
}
-export interface ReconciliationOverride { outside_id: string; native_id?: string | null; disposition: ReconciliationDisposition; resolution: string }
+export interface ReconciliationOverride { outside_id: string; native_id?: string | null; disposition: ReconciliationDisposition; resolution: string; proposal?: string }
const tokens = (title: string) => new Set(title.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(t => t.length > 2));
function jaccard(a: Set, b: Set): number {
@@ -139,7 +234,8 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
if (override) {
const n = override.native_id ? native.find(x => x.id === override.native_id) : undefined;
if (n) used.add(n.id);
- rows.push({ outside_id: o.id, native_id: n?.id ?? null, disposition: override.disposition, resolution: override.resolution, basis: 'override', severity: { outside: o.severity, native: n?.severity ?? null } });
+ rows.push({ outside_id: o.id, native_id: n?.id ?? null, disposition: override.disposition, resolution: override.resolution, basis: 'override', severity: { outside: o.severity, native: n?.severity ?? null },
+ ...(typeof override.proposal === 'string' && override.proposal.trim() ? { proposal: override.proposal.trim() } : {}) });
continue;
}
let match: FindingRow | undefined;
diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts
new file mode 100644
index 000000000..502bccee2
--- /dev/null
+++ b/lib/autoplan-revise.ts
@@ -0,0 +1,137 @@
+/**
+ * autoplan-revise — `gstack-autoplan revise` (plan B1c with the B-core
+ * amendments): the run reviews a revised plan in the same run directory.
+ * Drift is the Implementation-plan hash (the snapshot sha reviewers bound
+ * to), not file bytes: every opened phase whose reviewed hash differs from
+ * the revision is reopened (open attempts superseded), plus Eng, whose prompt
+ * carries the earlier phases' consensus; phases the revised plan newly puts in
+ * scope are added (never removed). Accepted gate changes are discharged only
+ * when named: `--incorporates ` binds each id to the old and new hashes
+ * and refuses an unchanged plan; `--withdraw ` is the owner's journaled
+ * decision to drop one. Archives keep `.r` names with `.v` only on
+ * collision. One recoverable transaction: the staged plan and state are
+ * written and `revise_intent` journaled before anything moves; `resume`
+ * (recoverRevise) completes an interrupted revise from the stage.
+ */
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { exportRun } from './autoplan-export';
+import { applyRenames, planReopen } from './autoplan-gate';
+import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
+import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts';
+import { FILES, RunError, journal, now, pendingAmendments, readJournal, readState, writeState, type RunState } from './autoplan-state';
+
+export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; cwd: string }
+export interface ReviseResult { state: RunState; reopened: Phase[]; lines: string[] }
+
+const amendmentLine = (state: RunState) => `AMENDMENTS: pending=${pendingAmendments(state).map(a => a.decision).join(',') || 'none'}`;
+
+/** The revised plan's text as the run reviews it (cut to the run's heading; scaffolded when the run was). */
+function revisionText(state: RunState, file: string): string {
+ if (!fs.existsSync(file)) throw new RunError('PLAN_SECTIONS_INVALID', `${file}: not found`, 3);
+ let text = fs.readFileSync(file, 'utf8');
+ if (state.plan_heading) {
+ const section = extractHeading(text, state.plan_heading);
+ if (section === undefined) throw new RunError('PLAN_SECTIONS_INVALID', `${file}: heading ${JSON.stringify(state.plan_heading)} not found`, 3);
+ text = section;
+ }
+ const diag = scaffold(text);
+ if (diag.problem && !(state.scaffolded && diag.text)) throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${diag.problem}`, 3);
+ return diag.text ?? text;
+}
+
+function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn', at: string, extra: Record = {}): Array> {
+ return ids.map(id => {
+ const a = pendingAmendments(state).find(x => x.decision === id)!;
+ a.status = status; a.settled_at = at;
+ Object.assign(a, extra);
+ return { event: status === 'incorporated' ? 'amendment_incorporated' : 'amendment_withdrawn', decision: id, ...extra };
+ });
+}
+
+export function reviseRun(o: ReviseOptions): ReviseResult {
+ const out = path.resolve(o.out);
+ const state = readState(out);
+ if (['complete', 'incomplete', 'refused'].includes(state.status)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}; revise works on a running or gated run`, 3);
+ const pending = pendingAmendments(state).map(a => a.decision);
+ for (const id of [...(o.incorporates ?? []), ...(o.withdraw ?? [])]) {
+ if (!pending.includes(id)) throw new RunError('PLAN_AMEND_PENDING', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3);
+ }
+ const at = now();
+ if (!o.plan) {
+ for (const row of settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })) journal(out, row);
+ writeState(state);
+ return { state, reopened: [], lines: [amendmentLine(state)] };
+ }
+ const file = path.resolve(o.plan);
+ const text = revisionText(state, file);
+ let newHash: string;
+ try { newHash = reviewHash(text); } catch (e: any) { throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${e.message}`, 3); }
+ const oldHash = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
+ if (o.incorporates?.length && newHash === oldHash) {
+ throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${o.incorporates.join(', ')} needs the edit in ${file}`, 3);
+ }
+ const k = (state.plan_revision ?? 0) + 1;
+ const staging = path.join(out, `.revise-v${k}`);
+ fs.rmSync(staging, { recursive: true, force: true });
+ fs.mkdirSync(staging, { recursive: true });
+ fs.writeFileSync(path.join(staging, FILES.active), normalizeRevision(text, state.restore));
+ fs.writeFileSync(path.join(staging, FILES.source), text);
+
+ const flags = (state.scope_flags ?? { ui: state.scope.source === 'flags' ? state.scope.ui : undefined, developerTool: state.scope.developer_tool, agentPrimary: state.scope.agent_primary }) as ScopeFlags;
+ const detected = detectScope(path.join(staging, FILES.active), o.cwd, flags);
+ const order = PHASES.filter(p => state.phase_order.includes(p) || phaseOrder(detected).includes(p));
+ const added = order.filter(p => !state.phase_order.includes(p));
+ const opened = state.phase_order.filter(p => state.phases[p]?.snapshot);
+ const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash);
+ const reopen = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0));
+ const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
+ const incorporated = o.incorporates ?? [];
+ journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates: incorporated, withdraw: o.withdraw ?? [] });
+
+ const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : [];
+ for (const p of added) state.phases[p] = {};
+ state.phase_order = order; state.required_phases = order;
+ state.scope = { ...detected, ui: detected.ui || state.scope.ui, dx: detected.dx || state.scope.dx };
+ if (reopen.length || added.length) state.status = 'running';
+ state.plan_input = file; state.plan_source = path.join(out, `plan-source.v${k}.md`); state.plan_revision = k;
+ delete state.plan_reality;
+ const settled = [...settleAmendments(state, incorporated, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })];
+ const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, 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);
+ return { state, reopened: reopen, lines: [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)] };
+}
+
+/** The commit half of the transaction; recoverRevise replays it from the stage. Every step is idempotent. */
+function publish(state: RunState, staging: string, renames: Array<[string, string]>, committed: Record, settled: Array>): void {
+ applyRenames(state.out, renames);
+ if (process.env.GSTACK_AUTOPLAN_FAULT === 'revise-after-archive') throw new Error('fault injected: revise interrupted after archiving (GSTACK_AUTOPLAN_FAULT, tests only)');
+ const stagedActive = path.join(staging, FILES.active);
+ if (fs.existsSync(stagedActive)) fs.renameSync(stagedActive, state.active_plan);
+ const stagedSource = path.join(staging, FILES.source);
+ if (fs.existsSync(stagedSource)) fs.renameSync(stagedSource, state.plan_source);
+ exportRun(state);
+ writeState(state);
+ journal(state.out, committed);
+ for (const row of settled) journal(state.out, row);
+ fs.rmSync(staging, { recursive: true, force: true });
+}
+
+/** `resume`: complete a revise whose intent is journaled without its commit, or record it abandoned when nothing was staged. */
+export function recoverRevise(out: string): string[] {
+ const rows = readJournal(out);
+ const intent = rows.filter(r => r.event === 'revise_intent').pop();
+ if (!intent || rows.some(r => (r.event === 'plan_revised' || r.event === 'revise_abandoned') && r.k === intent.k)) return [];
+ const stagedRun = path.join(intent.staging, FILES.run);
+ if (!fs.existsSync(stagedRun)) {
+ journal(out, { event: 'revise_abandoned', k: intent.k });
+ fs.rmSync(intent.staging, { recursive: true, force: true });
+ return [`REVISE_RECOVERED: v${intent.k} abandoned (nothing was published)`];
+ }
+ const stage = JSON.parse(fs.readFileSync(stagedRun, 'utf8')) as { state: RunState; renames: Array<[string, string]>; committed: Record; settled: Array> };
+ fs.writeFileSync(path.join(out, FILES.run), JSON.stringify(stage.state, null, 2) + '\n');
+ const state = readState(out);
+ publish(state, intent.staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled);
+ return [`REVISE_RECOVERED: v${intent.k} completed (reopened=${(stage.committed.reopened as string[]).join(',') || 'none'})`];
+}
diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts
index 94d39d652..cbd8c5a77 100644
--- a/lib/autoplan-run.ts
+++ b/lib/autoplan-run.ts
@@ -17,10 +17,14 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
-import { canonicalRows, parseReviewResult } from './autoplan-reconcile';
+import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { closePhase, finishGate } from './autoplan-gate';
+import { GSTACK_VERSION } from './autoplan-migrate';
+import { driftLine, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, detectScope, extractHeading, phaseOrder, reviewSkillFile, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
-import { FILES, RunError, journal, now, readJournal, readState, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state';
+import { recoverRevise } from './autoplan-revise';
+import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceTerminal, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state';
+import { checkRealityRows } from './plan-reality';
import type { RunStatus } from './headless-artifacts';
import { sha256File } from './headless-artifacts';
import { outsideConsent } from './outside-consent';
@@ -57,19 +61,33 @@ export function withRunLock(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 }
+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 }
-export function initRun(o: InitOptions): RunState {
+/** `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 {
const out = path.resolve(o.out);
- fs.mkdirSync(out, { recursive: true });
- let source = path.resolve(o.plan);
+ const input = path.resolve(o.plan);
+ let source = input;
if (!fs.existsSync(source)) throw new Error(`plan not found: ${source}`);
+ let scaffolded = false;
if (o.planHeading) {
const section = extractHeading(fs.readFileSync(source, 'utf8'), o.planHeading);
if (section === undefined) throw new Error(`heading ${JSON.stringify(o.planHeading)} not found in ${source}`);
+ fs.mkdirSync(out, { recursive: true });
source = path.join(out, FILES.source);
fs.writeFileSync(source, section);
+ } else {
+ const diag = scaffold(fs.readFileSync(source, 'utf8'));
+ if (diag.problem && (!o.scaffold || !diag.text)) throw new RunError('PLAN_SECTIONS_INVALID', `${source}: ${diag.problem}`, 3);
+ if (diag.text) {
+ fs.mkdirSync(out, { recursive: true });
+ source = path.join(out, FILES.source);
+ fs.writeFileSync(source, diag.text);
+ scaffolded = true;
+ lines.push(`SCAFFOLDED: added "${diag.added}" (copy at ${source}; ${input} unchanged)`);
+ }
}
+ fs.mkdirSync(out, { recursive: true });
const active = path.join(out, FILES.active);
const restore = path.join(out, FILES.restore);
const init = snapshotTool(['init', source, active, restore], o.cwd);
@@ -90,6 +108,8 @@ export function initRun(o: InitOptions): RunState {
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
...(o.light ? { light: true } : {}),
+ gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
+ ...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
};
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' } : {}) });
@@ -127,7 +147,9 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
}
}
const sections = sectionLoadFor(phase, state.scope, state.session_kind === 'unattended' || state.light ? 'light' : 'full');
- const prompts = writePrompts({ phase, snapshot: { nativePrompt: snap.nativePrompt, snapshotPath: snap.snapshotPath, sha256: snap.sha256 }, outDir: state.out, priorConsensus: prior, sections });
+ const decided = (state.gate_rev ?? 0) >= 1 ? readDecisions(state.out).filter(r => r.chosen != null && r.status !== 'pending')
+ .map(r => `r${r.gate_rev} ${(r.label ?? r.id).toLowerCase()}${r.chosen} ${r.title}${r.chosen_text ? ` -> ${r.chosen_text}` : ''}`) : [];
+ const prompts = writePrompts({ phase, snapshot: { nativePrompt: snap.nativePrompt, snapshotPath: snap.snapshotPath, sha256: snap.sha256 }, outDir: state.out, priorConsensus: prior, sections, decided });
ps.snapshot = snap.sha256; ps.snapshot_dir = path.dirname(snap.snapshotPath); ps.snapshot_path = snap.snapshotPath; ps.methodology = methodology.methodologyPath; ps.opened_at = now();
ps.sections = { loaded: sections.loaded, skipped: sections.skipped, checklist: !!sections.checklist };
ps.native = { status: 'pending', attempts: [] };
@@ -144,18 +166,24 @@ export function nextStep(o: NextOptions): NextResult {
const result: NextResult = { state, attempts: [], lines };
if (state.status === 'gate_pending' || state.status === 'complete' || state.status === 'incomplete' || state.status === 'refused') return result;
if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; }
+ const pending = pendingAmendments(state).map(a => a.decision);
+ if (pending.length) {
+ throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')}; edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --withdraw )`, 3);
+ }
const phase = currentPhase(state);
if (!phase) { finishGate(state); writeState(state); return result; }
openPhase(state, phase, o.cwd);
const ps = state.phases[phase]!;
result.phase = phase;
lines.push(`PHASE: ${phase} snapshot=${ps.snapshot} order=${state.phase_order.join(',')}`);
+ const drift = driftLine(state, 'PLAN_CHANGED');
+ if (drift) lines.push(drift);
if (ps.outside?.reason === 'consent_missing') lines.push(`OUTSIDE: ${phase} not_run reason=consent_missing`, String(ps.outside.detail));
if (ps.sections) lines.push(`Skipped sections: ${ps.sections.skipped.length ? ps.sections.skipped.join(',') : 'none'} (scope: ui=${state.scope.ui ? 'yes' : 'no'},dx=${state.scope.dx ? 'yes' : 'no'},source=${state.scope.source}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`);
const prompts = ps.prompts!;
for (const voice of ['native', 'outside'] as const) {
const v = voiceState(state, phase, voice);
- if (v.status === 'completed' || v.status === 'disabled' || v.status === 'skipped') continue;
+ if (voiceTerminal(v)) continue;
const open = openAttempt(v);
if (open?.state === 'execution_unknown') {
if (!o.redispatch?.includes(open.attempt)) throw new RunError('EXECUTION_UNKNOWN', `${open.attempt} (${phase} ${voice})`, 3);
@@ -190,27 +218,81 @@ function familyHint(state: RunState, voice: Voice): string {
// ---------------------------------------------------------------------------
// submit
// ---------------------------------------------------------------------------
-export interface SubmitOptions { out: string; phase: string; voice: Voice; result: string; model: string; attempt: string; runner?: string; usageUsd?: number; reconcile?: string; cwd: string }
+export interface SubmitOptions {
+ out: string; phase: string; voice: Voice; model?: string; cwd: string; runner?: string; usageUsd?: number; reconcile?: string;
+ /** Exactly one of: a canonical result, a hand-run record, or a terminal outside outcome. */
+ result?: string; record?: string; unavailable?: string; failed?: string;
+ /** Optional when exactly one attempt is open for the phase and voice; required for an attested bind. */
+ attempt?: string; attest?: string;
+}
export interface SubmitResult { state: RunState; closed: boolean; findings: number; lines: string[] }
+/** Report-only PLAN_REALITY on the active plan, once per Implementation-plan hash and checker (revise clears it). */
+export const PLAN_REALITY_CHECKER = 'plan-reality/1';
+function recordPlanReality(state: RunState): string[] {
+ let sha: string;
+ try { sha = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); } catch { return []; }
+ if (state.plan_reality?.plan_sha256 === sha && state.plan_reality.checker === PLAN_REALITY_CHECKER) return [];
+ const check = checkRealityRows(fs.readFileSync(state.active_plan, 'utf8'), { phase: 'plan', sessionKind: state.session_kind });
+ state.plan_reality = { plan_sha256: sha, checker: PLAN_REALITY_CHECKER, verdict: check.verdict, line: check.line };
+ journal(state.out, { event: 'plan_reality', plan_sha256: sha, checker: PLAN_REALITY_CHECKER, verdict: check.verdict });
+ return [`${check.line} (report only)`];
+}
+
+function resolveAttempt(v: VoiceState, attempt: string | undefined, phase: string, voice: Voice): AttemptState {
+ const open = openAttempt(v);
+ if (!open || (attempt !== undefined && open.attempt !== attempt)) {
+ throw new RunError('ATTEMPT_MISMATCH', `${attempt ?? '(none given)'} is not the open attempt for ${phase} ${voice}${open ? ` (${open.attempt} is)` : ''}`, 3);
+ }
+ return open;
+}
+
+/** The record's receipt and findings: a matching INPUT line binds verified; none binds only on --attest with --attempt, never across a later revise. */
+function readRecord(state: RunState, phase: Phase, open: AttemptState, o: SubmitOptions, text: string): { findings: CanonicalFinding[]; source: VoiceState['findings_source']; receipt: 'verified' | 'attested' } {
+ const receipt = parseReceipt(text, { phase, sha256: state.phases[phase]!.snapshot! });
+ if (receipt.present && !receipt.ok) throw new RunError('RESULT_RECEIPT_MISSING', receipt.error!);
+ if (!receipt.present) {
+ if (!o.attest) throw new RunError('RESULT_RECEIPT_MISSING', `${o.record} has no INPUT: ${phase} line; bind it on your word with --attest "" --attempt ${open.attempt}, or have the reviewer start with the INPUT line its prompt names`);
+ if (!o.attempt) throw new RunError('ATTEMPT_MISMATCH', `an attested bind names its attempt: pass --attempt ${open.attempt}`, 3);
+ const revised = readJournal(state.out).find(r => r.event === 'plan_revised' && String(r.ts) > open.dispatched_at);
+ if (revised) throw new RunError('RESULT_RECEIPT_MISSING', `the plan was revised after ${open.attempt} was dispatched (${revised.ts}); an attestation cannot say which plan was read: redispatch with gstack-autoplan next --redispatch, or bind a record with the INPUT line`);
+ }
+ const parsed = parseRecordFindings(text);
+ if (parsed.errors.length) throw new RunError('RESULT_FINDINGS_MISSING', parsed.errors.join('; '));
+ return { findings: parsed.findings, source: parsed.source, receipt: receipt.present ? 'verified' : 'attested' };
+}
+
export function submitResult(o: SubmitOptions): SubmitResult {
const state = readState(path.resolve(o.out));
if (!(PHASES as readonly string[]).includes(o.phase)) throw new RunError('PHASE_NOT_AWAITING', `${o.phase} is not a phase`, 3);
const phase = o.phase as Phase;
const ps = state.phases[phase];
const v = ps?.[o.voice];
- if (!ps?.snapshot || !v || ps.closed_at || v.status === 'completed' || v.status === 'disabled' || v.status === 'skipped') throw new RunError('PHASE_NOT_AWAITING', `${phase} ${o.voice} is ${v?.status ?? 'not opened'}`, 3);
- const open = openAttempt(v);
- if (!open || open.attempt !== o.attempt) throw new RunError('ATTEMPT_MISMATCH', `${o.attempt} is not the open attempt for ${phase} ${o.voice}${open ? ` (${open.attempt} is)` : ''}`, 3);
- if (!fs.existsSync(o.result)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${o.result}`);
- const text = fs.readFileSync(o.result, 'utf8');
- const parsed = parseReviewResult(text, { phase, sha256: ps.snapshot });
- 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('; '));
+ if (ps?.skipped) throw new RunError('PHASE_NOT_AWAITING', `${phase} is skipped (${ps.skipped})`, 3);
+ if (!ps?.snapshot) throw new RunError('PHASE_NOT_AWAITING', `${phase} has not been opened; run gstack-autoplan next --out ${state.out} first`, 3);
+ if (!v || ps.closed_at || voiceTerminal(v)) throw new RunError('PHASE_NOT_AWAITING', `${phase} ${o.voice} is ${v?.status ?? 'not opened'}`, 3);
+ if (o.unavailable !== undefined || o.failed !== undefined) {
+ const lines = closeOutside(state, phase, o.unavailable !== undefined ? 'unavailable' : 'failed', (o.unavailable ?? o.failed)!);
+ writeState(state);
+ return { state, closed: !!ps.closed_at, findings: 0, lines };
+ }
+ const open = resolveAttempt(v, o.attempt, phase, o.voice);
+ const file = (o.record ?? o.result)!;
+ if (!fs.existsSync(file)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${file}`);
+ const text = fs.readFileSync(file, 'utf8');
+ let bind: ReturnType;
+ if (o.record) bind = readRecord(state, phase, open, o, text);
+ else {
+ const parsed = parseReviewResult(text, { phase, sha256: ps.snapshot });
+ 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('; '));
+ bind = { findings: parsed.findings, source: 'fence', receipt: 'verified' };
+ }
+ const model = o.model!;
let conflict: string | undefined;
- if (o.voice === 'outside') conflict = familyConflict(o.model, state.native_model);
- else if (state.outside_model) conflict = familyConflict(state.outside_model, o.model);
- else if (modelFamily(o.model) === 'unknown') conflict = `native model ${JSON.stringify(o.model)} has no recognized family`;
+ if (o.voice === 'outside') conflict = familyConflict(model, state.native_model);
+ else if (state.outside_model) conflict = familyConflict(state.outside_model, model);
+ else if (modelFamily(model) === 'unknown') conflict = `native model ${JSON.stringify(model)} has no recognized family`;
if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3);
// Bind: immutable copy, per-voice canonical rows, journal, ledger, manifest. Nothing above this line wrote.
@@ -218,39 +300,53 @@ export function submitResult(o: SubmitOptions): SubmitResult {
const tmp = `${bound}.${process.pid}.tmp`;
fs.writeFileSync(tmp, text);
fs.renameSync(tmp, bound);
- const rows = canonicalRows(parsed.findings, { run: state.run, phase, voice: o.voice, model: o.model });
+ const rows = canonicalRows(bind.findings, { run: state.run, phase, voice: o.voice, model });
fs.writeFileSync(`${bound}.findings.jsonl`, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
const sha = sha256File(bound);
- journal(state.out, { event: 'submitted', attempt: o.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model: o.model, runner: o.runner ?? null, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown' });
- settle(path.join(state.out, FILES.spend), o.attempt, o.usageUsd ?? 'unknown');
+ const receipt = { receipt: bind.receipt, findings_source: bind.source, reviewed_sha256: ps.snapshot, ...(bind.receipt === 'attested' ? { attest_reason: o.attest } : {}) };
+ journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model, runner: o.runner ?? null, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt });
+ settle(path.join(state.out, FILES.spend), open.attempt, o.usageUsd ?? 'unknown');
open.state = 'submitted'; open.terminal_at = now();
- Object.assign(v, { status: 'completed', model: o.model, provider: o.runner ?? (o.voice === 'native' ? 'host-subagent' : 'unknown'), output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: o.attempt, usage: { usd: o.usageUsd ?? 'unknown' } } satisfies Partial);
+ Object.assign(v, { status: 'completed', model, provider: o.runner ?? (o.voice === 'native' ? 'host-subagent' : 'unknown'), output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial);
+ if (bind.receipt === 'attested') (state.deviations ??= []).push(`receipt attested: ${phase} ${o.voice} ${open.attempt} bound from a hand-run record without a reviewer receipt (snapshot ${ps.snapshot.slice(0, 12)}; reason: ${o.attest})`);
state.artifacts[path.basename(bound)] = { path: path.basename(bound), sha256: sha };
- if (o.voice === 'native') state.native_model = o.model; else state.outside_model = o.model;
- const lines = [`BOUND: ${o.attempt} phase=${phase} voice=${o.voice} model=${o.model} findings=${rows.length} sha256=${sha.slice(0, 12)}`];
- const outsideTerminal = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'].includes(String(ps.outside?.status));
- const closed = ps.native?.status === 'completed' && outsideTerminal;
- if (closed) {
- const close = closePhase(state, phase, { overridesFile: o.reconcile });
- lines.push(`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`);
- if (!currentPhase(state)) finishGate(state); else state.status = 'running';
- } else state.status = 'awaiting_result';
+ if (o.voice === 'native') state.native_model = model; else state.outside_model = model;
+ const lines = [`BOUND: ${open.attempt} phase=${phase} voice=${o.voice} model=${model} findings=${rows.length} sha256=${sha.slice(0, 12)} receipt=${bind.receipt}`];
+ lines.push(...recordPlanReality(state));
+ const closed = ps.native?.status === 'completed' && voiceTerminal(ps.outside);
+ if (closed) lines.push(...closeAndAdvance(state, phase, o.reconcile));
+ else state.status = 'awaiting_result';
writeState(state);
return { state, closed, findings: rows.length, lines };
}
+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} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`];
+}
+
+/**
+ * Record the outside voice of `phase` unavailable or failed: its open attempt
+ * is cancelled and the outcome journaled with that attempt id before the
+ * manifest is written (resume replays it); closes the phase when native is bound.
+ */
+export function closeOutside(state: RunState, phase: Phase, status: 'unavailable' | 'failed', detail: string): string[] {
+ const v = voiceState(state, phase, 'outside');
+ const open = openAttempt(v);
+ journal(state.out, { event: 'outside_' + status, phase, attempt: open?.attempt ?? null, detail });
+ if (open) { open.state = 'cancelled'; open.terminal_at = now(); settle(path.join(state.out, FILES.spend), open.attempt, 'unknown'); }
+ Object.assign(v, { status, detail });
+ const lines = [`OUTSIDE: ${phase} ${status} (${detail})`];
+ if (state.phases[phase]?.native?.status === 'completed') lines.push(...closeAndAdvance(state, phase));
+ else if (state.status !== 'gate_pending') state.status = 'awaiting_result';
+ return lines;
+}
+
/** Mark the outside voice of the current phase unavailable/failed (the runner could not run); closes the phase if native is bound. */
export function markOutside(out: string, phase: Phase, status: 'unavailable' | 'failed', detail: string): RunState {
const state = readState(out);
- const v = voiceState(state, phase, 'outside');
- const open = openAttempt(v);
- if (open) { open.state = 'cancelled'; open.terminal_at = now(); settle(path.join(out, FILES.spend), open.attempt, 'unknown'); }
- Object.assign(v, { status, detail });
- journal(out, { event: 'outside_' + status, phase, detail });
- if (state.phases[phase]?.native?.status === 'completed') {
- closePhase(state, phase, {});
- if (!currentPhase(state)) finishGate(state); else state.status = 'running';
- }
+ closeOutside(state, phase, status, detail);
writeState(state);
return state;
}
@@ -261,11 +357,22 @@ export function markOutside(out: string, phase: Phase, status: 'unavailable' | '
export interface ResumeResult { state: RunState; unknown: string[]; rebound: string[]; lines: string[] }
export function resumeRun(out: string): ResumeResult {
+ const recovered = recoverRevise(out);
const state = readState(out);
const rows = readJournal(out);
- const terminal = new Set(rows.filter(r => ['submitted', 'cancelled', 'superseded'].includes(r.event)).map(r => r.attempt));
+ const terminal = new Set(rows.filter(r => ['submitted', 'cancelled', 'superseded', 'outside_unavailable', 'outside_failed'].includes(r.event)).map(r => r.attempt));
const unknown: string[] = [];
const rebound: string[] = [];
+ // An outside outcome journaled before its manifest write: replay it onto the attempt it names, never onto a later round.
+ for (const r of rows) {
+ if (r.event !== 'outside_unavailable' && r.event !== 'outside_failed') continue;
+ const ps = state.phases[r.phase];
+ const v = ps?.outside;
+ const a = v?.attempts?.find(x => x.attempt === r.attempt);
+ if (!a || !v || voiceTerminal(v) || ps.closed_at) continue;
+ if (a.state === 'dispatched' || a.state === 'execution_unknown') { a.state = 'cancelled'; a.terminal_at = r.ts; }
+ Object.assign(v, { status: r.event.slice('outside_'.length), detail: r.detail });
+ }
for (const r of rows) {
if (r.event !== 'dispatched') continue;
const ps = state.phases[r.phase];
@@ -276,7 +383,8 @@ export function resumeRun(out: string): ResumeResult {
const sub = rows.find(x => x.event === 'submitted' && x.attempt === r.attempt);
if (sub && a && a.state !== 'submitted' && fs.existsSync(sub.result) && sha256File(sub.result) === sub.result_sha256) {
a.state = 'submitted'; a.terminal_at = sub.ts;
- Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(sub.result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt });
+ Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(sub.result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt,
+ ...(sub.receipt ? { receipt: sub.receipt, findings_source: sub.findings_source, reviewed_sha256: sub.reviewed_sha256, ...(sub.attest_reason ? { attest_reason: sub.attest_reason } : {}) } : {}) });
state.artifacts[path.basename(sub.result)] = { path: path.basename(sub.result), sha256: sub.result_sha256 };
if (r.voice === 'native') state.native_model = sub.model; else state.outside_model = sub.model;
rebound.push(r.attempt);
@@ -292,8 +400,7 @@ export function resumeRun(out: string): ResumeResult {
for (const phase of state.phase_order) {
const ps = state.phases[phase];
if (!ps?.snapshot || ps.closed_at) continue;
- const outsideTerminal = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'].includes(String(ps.outside?.status));
- if (ps.native?.status === 'completed' && outsideTerminal) closePhase(state, phase, {});
+ if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
}
if (!currentPhase(state) && !['gate_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state);
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
@@ -302,7 +409,7 @@ export function resumeRun(out: string): ResumeResult {
}
journal(out, { event: 'resumed', unknown, rebound, status: state.status });
writeState(state);
- const lines = [`RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
+ const lines = [...recovered, `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 };
}
diff --git a/test/autoplan-admission.test.ts b/test/autoplan-admission.test.ts
new file mode 100644
index 000000000..1a04d8ee9
--- /dev/null
+++ b/test/autoplan-admission.test.ts
@@ -0,0 +1,80 @@
+/**
+ * Admission at `gstack-autoplan`'s mutating verbs (plan Lane A amendments,
+ * as corrected: the verdict is required only under GSTACK_CAPY=1). On a Capy
+ * host (GSTACK_CAPY=1) `next`, `submit`, `answer`, `revise` and `export`
+ * refuse unless the install verdict at GSTACK_INSTALL_VERDICT says `gstack:
+ * ok` for this checkout and HEAD; any unattended session also refuses when
+ * the PATH Bun is below gstack's supported minimum. `status` and `resume` stay
+ * available. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 overrides and is recorded as
+ * a run deviation. Interactive sessions are never gated here.
+ */
+import { afterAll, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { ROOT } from './helpers/autoplan-run-fixture';
+import { HEAD, capyRunner, readRunJson, writePlan } from './helpers/autoplan-capy';
+
+const runners: Array<{ cleanup(): void }> = [];
+afterAll(() => runners.forEach(r => r.cleanup()));
+const make = (o: Parameters[0] = {}) => { const r = capyRunner(o); runners.push(r); return r; };
+const first = (r: ReturnType, name: string, extra: Record = {}) => {
+ const dir = path.join(r.host.base, name);
+ const out = path.join(dir, 'run');
+ return { out, res: r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui'], extra) };
+};
+
+describe('admission', () => {
+ test('GSTACK_CAPY=1 without a verdict refuses before writing anything', () => {
+ const r = make({ verdict: null });
+ const { out, res } = first(r, 'none');
+ expect(res.status).toBe(3);
+ expect(res.stderr).toMatch(/install verdict.*not found.*\(INSTALL_VERDICT_FAILED\)$/m);
+ expect(res.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
+ expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
+ });
+
+ test('a failing verdict, or one for another HEAD or checkout, refuses with the trailer', () => {
+ const r = make();
+ r.writeVerdict({ trailer: 'gstack: fail runtime', head: HEAD, checkout: fs.realpathSync(ROOT) });
+ expect(first(r, 'fail').res.stderr).toMatch(/gstack: fail runtime.*\(INSTALL_VERDICT_FAILED\)$/m);
+ r.writeVerdict({ trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: '0'.repeat(40), checkout: fs.realpathSync(ROOT) });
+ expect(first(r, 'head').res.stderr).toMatch(/another HEAD.*\(INSTALL_VERDICT_FAILED\)$/m);
+ r.writeVerdict({ trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: HEAD, checkout: '/elsewhere/gstack' });
+ expect(first(r, 'checkout').res.stderr).toMatch(/another checkout.*\(INSTALL_VERDICT_FAILED\)$/m);
+ });
+
+ test('an ok verdict admits; status and resume need none; every mutating verb is gated', () => {
+ const r = make();
+ const { out, res } = first(r, 'ok');
+ expect(res.status, res.stderr).toBe(0);
+ fs.rmSync(r.verdict);
+ expect(r.ap(['status', '--out', out]).status).toBe(0);
+ expect(r.ap(['resume', '--out', out]).status).toBe(0);
+ for (const args of [['next'], ['submit', '--phase', 'ceo', '--voice', 'outside', '--unavailable', 'x'], ['answer', '--gate-rev', '1', '--reply', 'all'], ['revise', '--plan', path.join(r.host.base, 'ok', 'plan.md')], ['export']]) {
+ const res2 = r.ap([args[0]!, '--out', out, ...args.slice(1)]);
+ expect(res2.status, args[0]).toBe(3);
+ expect(res2.stderr, args[0]).toMatch(/\(INSTALL_VERDICT_FAILED\)$/m);
+ }
+ });
+
+ test('an unattended session with the PATH Bun below the supported minimum refuses even with an ok verdict', () => {
+ const r = make({ launched: false });
+ const { res } = first(r, 'oldbun');
+ expect(res.status).toBe(3);
+ expect(res.stderr).toMatch(/bun 1\.3\.14.*\(RUNTIME_BELOW_MINIMUM\)$/m);
+ });
+
+ test('GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 overrides and is recorded as a run deviation', () => {
+ const r = make({ verdict: null, launched: false });
+ const { out, res } = first(r, 'override', { GSTACK_ALLOW_UNSUPPORTED_RUNTIME: '1' });
+ expect(res.status, res.stderr).toBe(0);
+ expect(res.stdout).toMatch(/^ADMISSION: override GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1/m);
+ expect(readRunJson(out).deviations.some((d: string) => d.startsWith('GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1'))).toBe(true);
+ });
+
+ test('an interactive session is not gated', () => {
+ const r = make({ verdict: null, launched: false });
+ const { res } = first(r, 'interactive', { GSTACK_SESSION_KIND: 'interactive', GSTACK_CAPY: '' });
+ expect(res.status, res.stderr).toBe(0);
+ });
+});
diff --git a/test/autoplan-answer-invalidation.test.ts b/test/autoplan-answer-invalidation.test.ts
index d28fac5be..d958d2109 100644
--- a/test/autoplan-answer-invalidation.test.ts
+++ b/test/autoplan-answer-invalidation.test.ts
@@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
-import { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
+import { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
@@ -40,6 +40,25 @@ function gatedRun(name: string): string {
}
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
+/**
+ * An accepted plan change is a pending amendment (B-core interlock): `next` refuses with PLAN_AMEND_PENDING and writes
+ * nothing until the plan is edited and `revise --incorporates` names the decision. Returns the revise output.
+ */
+function incorporate(out: string, ids: string[], edit: string) {
+ const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
+ const blocked = autoplan(['next', '--out', out]);
+ expect(blocked.status).toBe(3);
+ expect(blocked.stderr).toMatch(new RegExp(`${ids.join(',')}.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
+ expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before);
+ const plan = path.join(path.dirname(out), 'plan.md');
+ fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover', `${edit} Tests cover`));
+ const revised = autoplan(['revise', '--out', out, '--plan', plan, '--incorporates', ids.join(',')]);
+ expect(revised.status, revised.stderr).toBe(0);
+ expect(revised.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
+ expect(journal(out).filter(j => j.event === 'amendment_incorporated').map(j => j.decision)).toEqual(expect.arrayContaining(ids));
+ return revised;
+}
+
describe('answer', () => {
test('the gate lists the disagreement as an auto item and the user challenges as approval items', () => {
const out = gatedRun('shape');
@@ -97,7 +116,8 @@ describe('answer', () => {
const out = gatedRun('reopen');
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
expect(r.status, r.stderr).toBe(0);
- expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'] });
+ const runId = readRun(out).run;
+ expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] });
const run = readRun(out);
expect(run.phases.ceo).toEqual({});
expect(run.phases.eng).toEqual({});
@@ -109,6 +129,9 @@ describe('answer', () => {
const d1 = decisions(out).find((d: any) => d.label === 'D1');
expect(d1).toMatchObject({ status: 'overridden', chosen: 'b', gate_rev: 1 });
expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
+ // the accepted change is applied to the plan and named before any phase reruns; the reopened phases stay reopened once
+ incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.');
+ expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
// the loop continues: ceo first, then eng, then a new gate at gate_rev 2 whose rows join the old ones
const ceo = runPhase(out, 'ceo');
expect(ceo.next.stdout).toMatch(/^PHASE: ceo /m);
@@ -140,8 +163,14 @@ describe('answer', () => {
test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
const out = gatedRun('eng-only');
const engOnly = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
- expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'] });
+ const runId = readRun(out).run;
+ expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] });
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
+ // the revised plan changes the hash ceo reviewed, so revise (not the answer) reruns ceo on v2 before eng
+ const revised = incorporate(out, [`${runId}-uc2`], 'Retries stop at the dead-letter queue.');
+ expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo$/m);
+ expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['eng', expect.anything()], ['ceo', expect.stringContaining('revise')]]);
+ expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
const interrogate = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all except p1c', '--json']);
diff --git a/test/autoplan-capy-e2e.test.ts b/test/autoplan-capy-e2e.test.ts
new file mode 100644
index 000000000..f1ef7de0f
--- /dev/null
+++ b/test/autoplan-capy-e2e.test.ts
@@ -0,0 +1,183 @@
+/**
+ * End-to-end unattended runs on the simulated Capy host, driven only through
+ * the CLI (plan B1a/B1b/B1c, the wave's mandatory free-tier acceptance): a
+ * three-revision run validates with unique ids; phases bound from free-form
+ * records with one outside voice unavailable close, answer, export and
+ * validate; a run gated on <=1.91.94-style decision ids validates after the
+ * load-time migration; run.json records gstack_version, a newer run refuses
+ * and an older one migrates forward. One conformance run strings every
+ * mechanism together.
+ */
+import { afterAll, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture';
+import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan, type CapyRunner } from './helpers/autoplan-capy';
+
+const r = capyRunner();
+afterAll(() => r.cleanup());
+const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
+let n = 0;
+function start(args: string[] = []): { out: string; dir: string; plan: string } {
+ const dir = path.join(r.host.base, `e2e${++n}`);
+ const plan = writePlan(dir);
+ const out = path.join(dir, 'run');
+ const res = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]);
+ if (res.status !== 0) throw new Error(`first next failed: ${res.stderr}\n${res.stdout}`);
+ return { out, dir, plan };
+}
+function expectUniqueIds(out: string) {
+ for (const f of ['decisions.jsonl', 'findings.jsonl', 'tasks.jsonl']) {
+ const ids = readJsonl(path.join(out, f)).map(x => x.id);
+ expect(new Set(ids).size, f).toBe(ids.length);
+ }
+ const attempts = readJsonl(path.join(out, 'attempts.jsonl')).filter(e => e.event === 'dispatched').map(e => e.attempt);
+ expect(new Set(attempts).size).toBe(attempts.length);
+}
+/** Every completed voice reviewed its phase's snapshot (receipt or attestation), recorded as reviewed_sha256. */
+function expectReviewedHashes(out: string) {
+ const run = readRunJson(out);
+ for (const p of run.phase_order) for (const voice of ['native', 'outside']) {
+ const v = run.phases[p][voice];
+ if (v.status !== 'completed') continue;
+ expect(v.reviewed_sha256, `${p} ${voice}`).toBe(run.phases[p].snapshot);
+ if (v.receipt !== 'attested') expect(fs.readFileSync(path.join(out, v.output), 'utf8')).toContain(`INPUT: ${p} ${run.phases[p].snapshot}`);
+ }
+}
+const ok = (res: { status: number | null; stderr: string; stdout: string }) => { expect(res.status, res.stderr + res.stdout).toBe(0); return res; };
+
+describe('unattended runs on the Capy host', () => {
+ test('a three-revision run driven only through the CLI validates with unique ids and records gstack_version', () => {
+ const s = start();
+ expect(readRunJson(s.out).gstack_version).toBe(VERSION);
+ for (const rev of [1, 2]) {
+ phaseVia(r, s.out, 'ceo');
+ expect(phaseVia(r, s.out, 'eng').outside.stdout).toContain('status=gate_pending');
+ expectReviewedHashes(s.out);
+ expect(ok(r.ap(['answer', '--out', s.out, '--gate-rev', String(rev), '--reply', 'all except p1d'])).stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=revise$/m);
+ }
+ phaseVia(r, s.out, 'ceo');
+ phaseVia(r, s.out, 'eng');
+ expect(ok(r.ap(['answer', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
+ ok(r.ap(['export', '--out', s.out]));
+ const v = ok(r.validate(s.out));
+ expect(v.stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete/m);
+ expectUniqueIds(s.out);
+ expectReviewedHashes(s.out);
+ expect(readJsonl(path.join(s.out, 'decisions.jsonl')).filter(d => d.gate_rev === 3).every(d => d.id.includes('-r3-'))).toBe(true);
+ });
+
+ test('free-form records (table, list, No findings.) and one unavailable outside voice reach the gate, answer, export and validate', () => {
+ const s = start();
+ const run = readRunJson(s.out);
+ const sha = run.phases.ceo.snapshot;
+ const rec = (name: string, text: string) => { const f = path.join(s.dir, name); fs.writeFileSync(f, text); return f; };
+ ok(r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--model', 'anthropic/claude-opus-5-5', '--attest', 'Capy subagent read the prompt file', '--attempt', run.phases.ceo.native.attempt,
+ '--record', rec('ceo-n.md', '# CEO review\n\n| Severity | Title | File |\n|---|---|---|\n| High | Retry cap is unbounded on restart | lib/worker.ts:10 |\n| Low | Name the env var | |\n')]));
+ const closed = ok(r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--model', 'openai/gpt-6.1-sol', '--record', rec('ceo-o.md', `INPUT: ceo ${sha}\n\n- [High] Jitter bounds are untested\n- [Medium] Resume path skips the cap\n`)]));
+ expect(closed.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=both/m);
+ const eng = ok(r.ap(['next', '--out', s.out]));
+ const engSha = /^PHASE: eng snapshot=([0-9a-f]{64})/m.exec(eng.stdout)![1];
+ ok(r.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--model', 'anthropic/claude-opus-5-5', '--record', rec('eng-n.md', `INPUT: eng ${engSha}\n\nRead the whole plan.\n\nNo findings.\n`)]));
+ const gated = ok(r.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no cross-family subagent on this host']));
+ expect(gated.stdout).toMatch(/^PHASE_CLOSED: eng .*coverage=native_only/m);
+ expect(gated.stdout).toContain('status=gate_pending');
+ expect(ok(r.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all'])).stdout).toContain('status=complete');
+ ok(r.ap(['export', '--out', s.out]));
+ expect(ok(r.validate(s.out)).stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete attested=1$/m);
+ expect(fs.readFileSync(path.join(s.out, 'review-record.md'), 'utf8')).toMatch(/attested/);
+ expectReviewedHashes(s.out);
+ });
+
+ test('a run gated on <=1.91.94-style decision ids validates after the load-time migration', () => {
+ const s = start();
+ phaseVia(r, s.out, 'ceo');
+ phaseVia(r, s.out, 'eng');
+ ok(r.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']));
+ phaseVia(r, s.out, 'ceo');
+ phaseVia(r, s.out, 'eng');
+ const file = path.join(s.out, 'decisions.jsonl');
+ fs.writeFileSync(file, readJsonl(file).map(d => JSON.stringify({ ...d, id: d.id.replace(/-r\d+-/, '-') })).join('\n') + '\n');
+ const manifest = readRunJson(s.out);
+ delete manifest.gstack_version;
+ fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(manifest, null, 2) + '\n');
+ const res = ok(r.ap(['answer', '--out', s.out, '--gate-rev', '2', '--reply', 'all']));
+ expect(res.stdout).toMatch(/^GSTACK_VERSION_CHANGED: run unknown -> /m);
+ expect(res.stdout).toContain('status=complete');
+ ok(r.ap(['export', '--out', s.out]));
+ const v = r.validate(s.out);
+ expect(v.stderr).not.toContain('ARTIFACT_DUPLICATE_ID');
+ expect(v.stderr).not.toContain('ARTIFACT_DANGLING_REF');
+ expect(v.status, v.stderr).toBe(0);
+ const migrated = readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'decision_ids_migrated');
+ expect(migrated).toHaveLength(1);
+ expect(migrated[0].n).toBeGreaterThan(0);
+ expect(readRunJson(s.out).gstack_version).toBe(VERSION);
+ });
+
+ test('a run written by a newer gstack refuses; an older one prints GSTACK_VERSION_CHANGED once and moves forward', () => {
+ const s = start();
+ const set = (v: string) => { const m = readRunJson(s.out); m.gstack_version = v; fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(m, null, 2) + '\n'); };
+ set('99.0.0.0');
+ const newer = r.ap(['next', '--out', s.out]);
+ expect(newer.status).toBe(3);
+ expect(newer.stderr).toMatch(/99\.0\.0\.0.*\(GSTACK_VERSION_CHANGED\)$/m);
+ set('1.91.22.0');
+ const older = ok(r.ap(['next', '--out', s.out]));
+ expect(older.stdout).toMatch(new RegExp(`^GSTACK_VERSION_CHANGED: run 1\\.91\\.22\\.0 -> ${VERSION.replace(/\./g, '\\.')}`, 'm'));
+ expect(ok(r.ap(['next', '--out', s.out])).stdout).not.toMatch(/GSTACK_VERSION_CHANGED/);
+ expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'gstack_version_changed')).toHaveLength(1);
+ });
+});
+
+describe('conformance: one run through every mechanism', () => {
+ test('canonical and attested binds, an unavailable voice, partial then full replies, PLAN_AMEND_PENDING, revise --incorporates, an interruption, three gate revisions', () => {
+ const c: CapyRunner = r;
+ const s = start();
+ const run = readRunJson(s.out).run;
+ // ceo: native canonical (fixture), outside attested record
+ ok(c.review(path.join(s.out, 'ceo-native-prompt.md')));
+ ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(s.out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']));
+ const hand = path.join(s.dir, 'ceo-outside-hand.md');
+ fs.writeFileSync(hand, '| Severity | Title |\n|---|---|\n| Medium | Retry storage cost is unpriced |\n');
+ ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--record', hand, '--model', 'openai/gpt-6.1-sol', '--attest', 'Capy subagent', '--attempt', readRunJson(s.out).phases.ceo.outside.attempt]));
+ // eng: native challenge with changes, outside unavailable
+ ok(c.ap(['next', '--out', s.out]));
+ ok(c.review(path.join(s.out, 'eng-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ id: 'F1', severity: 'High', title: 'Gate the backoff behind a flag', user_challenge: true, changes: 'scope', fix: 'RETRY_BACKOFF=1' }]) }));
+ ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', path.join(s.out, 'eng-native.md'), '--model', 'anthropic/claude-opus-5-5']));
+ expect(ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no outside runner'])).stdout).toContain('status=gate_pending');
+ const accept = JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).items.find((i: any) => i.title.startsWith('Gate the backoff')).id;
+ // partial, then full
+ expect(ok(c.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', `${accept}b`])).stdout).toMatch(/^GATE_PENDING: p1/m);
+ const full = ok(c.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all']));
+ expect(full.stdout).toMatch(new RegExp(`^APPLY: ${run}-${accept} `, 'm'));
+ expect(c.ap(['next', '--out', s.out]).stderr).toMatch(/\(PLAN_AMEND_PENDING\)$/m);
+ const v2 = path.join(s.dir, 'plan-v2.md');
+ fs.writeFileSync(v2, SMALL_PLAN.replace('Tests cover', 'Ship behind RETRY_BACKOFF=1, default off. Tests cover'));
+ expect(ok(c.ap(['revise', '--out', s.out, '--plan', v2, '--incorporates', `${run}-${accept}`])).stdout).toMatch(/^AMENDMENTS: pending=none$/m);
+ // interruption: attempts dispatched, the driver dies holding the lock; resume reclaims and the parent redispatches
+ ok(c.ap(['next', '--out', s.out]));
+ fs.writeFileSync(path.join(s.out, 'run.lock'), JSON.stringify({ pid: 999_999_999, host: require('node:os').hostname(), at: new Date().toISOString() }));
+ const resumed = ok(c.ap(['resume', '--out', s.out]));
+ expect(resumed.stdout).toMatch(/execution_unknown=2/);
+ const unknown = [...resumed.stdout.matchAll(/^EXECUTION_UNKNOWN: (\S+)$/gm)].map(m => m[1]!);
+ ok(c.ap(['next', '--out', s.out, ...unknown.flatMap(a => ['--redispatch', a])]));
+ for (const voice of ['native', 'outside']) ok(c.review(path.join(s.out, `ceo-${voice}-prompt.md`)));
+ for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', path.join(s.out, `ceo-${voice}.md`), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol']));
+ phaseVia(c, s.out, 'eng');
+ expect(readRunJson(s.out).gate_rev).toBe(2);
+ ok(c.ap(['answer', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d']));
+ phaseVia(c, s.out, 'ceo');
+ phaseVia(c, s.out, 'eng');
+ expect(ok(c.ap(['answer', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
+ ok(c.ap(['export', '--out', s.out]));
+ expect(ok(c.validate(s.out)).stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete/m);
+ const final = readRunJson(s.out);
+ expect(final.counts.decisions_pending).toBe(0);
+ expect((final.amendments ?? []).filter((a: any) => a.status === 'pending')).toEqual([]);
+ expect(final.gate_rev).toBe(3);
+ expectUniqueIds(s.out);
+ expectReviewedHashes(s.out);
+ expect(fs.readFileSync(path.join(s.out, `ceo-native.md`), 'utf8')).toContain(final.phases.eng.snapshot);
+ });
+});
diff --git a/test/autoplan-gate-judgment.test.ts b/test/autoplan-gate-judgment.test.ts
new file mode 100644
index 000000000..58edccfd9
--- /dev/null
+++ b/test/autoplan-gate-judgment.test.ts
@@ -0,0 +1,204 @@
+/**
+ * Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments):
+ * findings carry `changes`; a user challenge both voices raised on a confirmed
+ * finding with the same `changes` and both remedies recommends `b`, rendering
+ * both remedies verbatim and the basis; anything weaker keeps `a` with its
+ * basis. Reopen and APPLY key on the chosen option's `changes_plan`; accepted
+ * changes are pending amendments that `next` refuses past (PLAN_AMEND_PENDING)
+ * until `revise --incorporates` binds them to a changed plan or `--withdraw`
+ * drops them. `all as-is` keeps every direction. Partial replies resolve
+ * against persisted decisions. On reruns a challenge without `changes` is an
+ * agent decision that reopens nothing, rerun prompts carry the decided items,
+ * and answered items from phases that did not rerun carry forward.
+ */
+import { afterAll, describe, expect, test } from 'bun:test';
+import { spawnSync } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture';
+import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
+
+const r = capyRunner();
+afterAll(() => r.cleanup());
+let n = 0;
+const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+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: 'add a kill switch flag for the new schedule' }],
+};
+const DISAGREE = {
+ native: [{ id: 'F1', severity: 'Critical', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 10, user_challenge: true, changes: 'scope', fix: 'new table' }],
+ outside: [{ id: 'O1', severity: 'Low', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 11, user_challenge: true, changes: 'scope', fix: 'keep the column' }],
+};
+const MISMATCH = {
+ native: [{ id: 'F1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'scope', fix: 'remove jitter' }],
+ outside: [{ id: 'O1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'cost', fix: 'remove jitter' }],
+};
+function gated(ceo: { native?: unknown[]; outside?: unknown[] }, eng: { native?: unknown[]; outside?: unknown[] }): { out: string; dir: string; plan: string; run: string } {
+ const dir = path.join(r.host.base, `gj${++n}`);
+ const plan = writePlan(dir);
+ const out = path.join(dir, 'run');
+ expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui']).status).toBe(0);
+ phaseVia(r, out, 'ceo', ceo);
+ expect(phaseVia(r, out, 'eng', eng).outside.stdout).toContain('status=gate_pending');
+ return { out, dir, plan, run: readRunJson(out).run };
+}
+const gate = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
+const decisions = (out: string) => readJsonl(path.join(out, 'decisions.jsonl'));
+const answer = (out: string, rev: number, reply: string, json = false) => r.ap(['answer', '--out', out, '--gate-rev', String(rev), '--reply', reply, ...(json ? ['--json'] : [])]);
+const v = (dir: string, text: string) => { const f = path.join(dir, `v${++n}.md`); fs.writeFileSync(f, text); return f; };
+
+describe('changes and user-challenge defaults', () => {
+ test('`changes` reaches findings.jsonl, the schema and decisions; an unknown value is refused', () => {
+ const g = gated(AGREE, {});
+ expect(readJsonl(path.join(g.out, 'ceo-native.md.findings.jsonl'))[0]).toMatchObject({ changes: 'scope', user_challenge: true });
+ const schema = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'schema', 'findings'], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
+ expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction'] });
+ expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'b', recommended_basis: 'both voices; remedies shown' });
+ const dir = path.join(r.host.base, 'badchanges');
+ const out = path.join(dir, 'run');
+ expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
+ expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'packaging' }]) }).status).toBe(0);
+ const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']);
+ expect(bad.status).toBe(1);
+ expect(bad.stderr).toMatch(/changes "packaging" is not one of scope\|cost\|direction.*\(RESULT_FINDINGS_MISSING\)$/m);
+ });
+
+ test('agreeing voices recommend b with both remedies verbatim; disagreement and mismatched changes keep a with the basis', () => {
+ const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' }))], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' }))] }, DISAGREE);
+ const items = gate(g.out).items;
+ const agree = items.find((i: any) => i.title.startsWith('Ship the backoff'));
+ expect(agree).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; remedies shown' });
+ expect(agree.options.find((o: any) => o.key === 'b')).toMatchObject({ changes_plan: true });
+ expect(agree.options.find((o: any) => o.key === 'b').text).toContain('native: gate the change behind RETRY_BACKOFF=1; outside: add a kill switch flag for the new schedule');
+ expect(agree.options.find((o: any) => o.key === 'a').changes_plan).toBe(false);
+ expect(items.find((i: any) => i.title.startsWith('Drop the jitter'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices name different changes' });
+ expect(items.find((i: any) => i.title.startsWith('Persist attempts'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices disagree' });
+ const render = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-gate'), 'render', path.join(g.out, 'gate.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
+ expect(render.stdout).toMatch(new RegExp(`\\[${agree.id}\\] .*recommended: b \\(both voices; remedies shown\\)`));
+ expect(render.stdout).toMatch(/recommended: a \(plan direction; voices disagree\)/);
+ const p1 = items.find((i: any) => i.id === 'p1');
+ expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${agree.id}b`);
+ });
+
+ test('a parent reconcile override naming one shared proposal renders that proposal as option b', () => {
+ const dir = path.join(r.host.base, 'shared');
+ const out = path.join(dir, 'run');
+ expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
+ r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.native) });
+ r.review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.outside) });
+ const run = readRunJson(out);
+ expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
+ const overrides = path.join(dir, 'reconcile.jsonl');
+ fs.writeFileSync(overrides, JSON.stringify({ outside_id: `${run.run}-ceo-outside-1`, native_id: `${run.run}-ceo-native-1`, disposition: 'confirmed', resolution: 'same change', proposal: 'ship behind RETRY_BACKOFF=1, default off' }) + '\n');
+ expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0);
+ phaseVia(r, out, 'eng', {});
+ const uc = gate(out).items.find((i: any) => i.title.startsWith('Ship the backoff'));
+ expect(uc).toMatchObject({ recommended: 'b', recommended_basis: 'both voices; shared proposal' });
+ expect(uc.options.find((o: any) => o.key === 'b').text).toContain('ship behind RETRY_BACKOFF=1, default off');
+ });
+});
+
+describe('accepted changes are amendments', () => {
+ test('`all` accepts an agreeing change: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => {
+ const g = gated(AGREE, {});
+ const res = answer(g.out, 1, 'all');
+ expect(res.status, res.stderr).toBe(0);
+ expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend`, 'm'));
+ expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=uc1b$/m);
+ expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm'));
+ expect(res.stdout).not.toMatch(/^NEXT: gstack-autoplan next/m);
+ expect(readRunJson(g.out).amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending' })]);
+ const blocked = r.ap(['next', '--out', g.out]);
+ expect(blocked.status).toBe(3);
+ expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
+ const runFile = path.join(g.out, 'run.json');
+ const saved = fs.readFileSync(runFile, 'utf8');
+ fs.writeFileSync(runFile, JSON.stringify({ ...JSON.parse(saved), status: 'complete' }, null, 2) + '\n');
+ const exported = r.ap(['export', '--out', g.out]);
+ expect(exported.status).toBe(3);
+ expect(exported.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
+ fs.writeFileSync(runFile, saved);
+ const unrelated = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s'))]);
+ expect(unrelated.status, unrelated.stderr).toBe(0);
+ expect(unrelated.stdout).toMatch(new RegExp(`^AMENDMENTS: pending=${esc(g.run)}-uc1$`, 'm'));
+ expect(r.ap(['next', '--out', g.out]).status).toBe(3);
+ const unchanged = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s')), '--incorporates', `${g.run}-uc1`]);
+ expect(unchanged.status).toBe(3);
+ expect(unchanged.stderr).toMatch(/did not change.*\(PLAN_AMEND_PENDING\)$/m);
+ const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s, behind RETRY_BACKOFF=1')), '--incorporates', `${g.run}-uc1`]);
+ expect(bound.status, bound.stderr).toBe(0);
+ expect(bound.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
+ const j = readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_incorporated');
+ expect(j).toMatchObject({ decision: `${g.run}-uc1` });
+ expect(j.old_plan_sha256).not.toBe(j.new_plan_sha256);
+ expect(r.ap(['next', '--out', g.out]).status).toBe(0);
+ });
+
+ test('`all as-is` keeps every direction; --withdraw is a journaled owner decision', () => {
+ const g = gated(AGREE, {});
+ const asIs = answer(g.out, 1, 'all as-is');
+ expect(asIs.status, asIs.stderr).toBe(0);
+ expect(asIs.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m);
+ expect(asIs.stdout).not.toMatch(/^APPLY:/m);
+ expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'overridden' });
+ const w = gated(AGREE, {});
+ expect(answer(w.out, 1, 'all').stdout).toMatch(/^APPLY:/m);
+ const withdraw = r.ap(['revise', '--out', w.out, '--withdraw', `${w.run}-uc1`]);
+ expect(withdraw.status, withdraw.stderr).toBe(0);
+ expect(readJsonl(path.join(w.out, 'attempts.jsonl')).find(e => e.event === 'amendment_withdrawn')).toMatchObject({ decision: `${w.run}-uc1` });
+ expect(r.ap(['next', '--out', w.out]).status).toBe(0);
+ });
+});
+
+describe('partial replies resolve against persisted decisions', () => {
+ test('a later `all` never overwrites an earlier override, and p1 alone with approvals unanswered keeps the gate', () => {
+ const g = gated({}, {});
+ const p1only = answer(g.out, 1, 'p1a', true);
+ expect(JSON.parse(p1only.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending', pending: ['uc1', 'uc2'] });
+ const first = answer(g.out, 1, 'uc1b', true);
+ expect(JSON.parse(first.stdout)).toMatchObject({ outcome: 'gate_pending', pending: ['uc2'] });
+ const second = answer(g.out, 1, 'all', true);
+ expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] });
+ expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'b', status: 'overridden' });
+ });
+
+ test('validate rejects a complete run with a pending approval', () => {
+ const g = gated({}, {});
+ expect(answer(g.out, 1, 'all').stdout).toContain('status=complete');
+ const file = path.join(g.out, 'decisions.jsonl');
+ fs.writeFileSync(file, decisions(g.out).map(d => JSON.stringify(d.label === 'UC2' ? { ...d, status: 'pending', chosen: null } : d)).join('\n') + '\n');
+ r.ap(['export', '--out', g.out]);
+ const res = r.validate(g.out);
+ expect(res.status).toBe(1);
+ expect(res.stderr).toMatch(/UC2.*\(ARTIFACT_GATE_UNSETTLED\)$/m);
+ });
+});
+
+describe('reruns', () => {
+ test('rerun prompts carry the decided items, a ceo item answered at rev 1 carries forward, and a rerun challenge without `changes` reopens nothing', () => {
+ const g = gated({}, AGREE);
+ const res = answer(g.out, 1, 'uc1a uc2b p1a');
+ expect(res.stdout).toMatch(/^REOPENED: eng \(Eng last\) reason=uc2b$/m);
+ expect(r.ap(['revise', '--out', g.out, '--withdraw', `${g.run}-uc2`]).status).toBe(0);
+ const eng = phaseVia(r, g.out, 'eng', {
+ native: [{ id: 'F9', severity: 'Low', title: 'Name the PR branch per lane', user_challenge: true }],
+ outside: [{ id: 'O9', severity: 'Medium', title: 'Price the retry storage before shipping', user_challenge: true, changes: 'cost' }],
+ });
+ const prompt = fs.readFileSync(path.join(g.out, 'eng-native-prompt.md'), 'utf8');
+ expect(prompt).toContain('DECIDED AT THE GATE');
+ expect(prompt).toMatch(/uc2b .*Ship the backoff behind a flag first/);
+ expect(prompt).toMatch(/uc1a /);
+ expect(eng.outside.stdout).toContain('status=gate_pending');
+ const rev2 = decisions(g.out).filter(d => d.gate_rev === 2);
+ expect(rev2.find(d => d.label === 'UC1')).toMatchObject({ status: 'approved', chosen: 'a', carried_from: `${g.run}-uc1` });
+ const items = gate(g.out).items;
+ const demoted = items.find((i: any) => i.title.startsWith('Name the PR branch'));
+ expect(demoted).toMatchObject({ kind: 'auto', reopens: false });
+ expect(items.find((i: any) => i.title.startsWith('Price the retry storage'))).toMatchObject({ kind: 'auto' });
+ expect(items.find((i: any) => i.title.startsWith('Price the retry storage')).reopens).not.toBe(false);
+ const done = answer(g.out, 2, `${demoted.id}b p1a`, true);
+ expect(JSON.parse(done.stdout)).toMatchObject({ outcome: 'complete', reopened: [] });
+ expect(decisions(g.out).find(d => d.gate_rev === 2 && d.label === demoted.id.toUpperCase())).toMatchObject({ chosen: 'b', source: 'agent' });
+ });
+});
diff --git a/test/autoplan-record.test.ts b/test/autoplan-record.test.ts
new file mode 100644
index 000000000..6d3d43459
--- /dev/null
+++ b/test/autoplan-record.test.ts
@@ -0,0 +1,153 @@
+/**
+ * `gstack-autoplan submit --record` (plan B1b, item 7): a phase reviewed by a
+ * host subagent binds from its own record. Accepted shapes are the canonical
+ * fence, an exact `No findings.` line, or exactly one findings table or
+ * severity-tagged list; anything else, or two shapes, is refused. A record
+ * without the INPUT receipt binds only with `--attest ` and an
+ * explicit `--attempt` (receipt: attested); a wrong receipt always refuses.
+ * `--voice outside --unavailable|--failed` closes the phase native_only, and
+ * the terminal outcome is journaled so `next` and `resume` never redispatch
+ * or resurrect it. Runs on the simulated Capy host.
+ */
+import { afterAll, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { capyRunner, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
+
+const r = capyRunner();
+afterAll(() => r.cleanup());
+let n = 0;
+function start(): string {
+ const dir = path.join(r.host.base, `rec${++n}`);
+ const plan = writePlan(dir);
+ const out = path.join(dir, 'run');
+ const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui']);
+ if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`);
+ return out;
+}
+const write = (out: string, name: string, text: string) => { const f = path.join(path.dirname(out), name); fs.writeFileSync(f, text); return f; };
+const snapshotOf = (out: string, phase = 'ceo') => readRunJson(out).phases[phase].snapshot as string;
+
+const TABLE = `# CEO review (hand-run subagent)
+
+Prose about the plan.
+
+| # | Severity | Finding | File | Fix |
+|---|---|---|---|---|
+| 1 | High | Retry cap is unbounded on restart | lib/worker.ts:10 | persist the cap |
+| 2 | Medium | Split persistence change into its own plan | | split it |
+`;
+const list = (sha: string) => `INPUT: ceo ${sha}
+
+Review prose.
+
+- [High] Jitter bounds are untested
+- [Low] Name the environment variable
+`;
+
+describe('submit --record', () => {
+ test('a table record without a receipt is refused unless attested, and an attested bind names its attempt', () => {
+ const out = start();
+ const rec = write(out, 'ceo-table.md', TABLE);
+ const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
+ const bare = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5']);
+ expect(bare.status).toBe(1);
+ expect(bare.stderr).toMatch(/--attest.*\(RESULT_RECEIPT_MISSING\)$/m);
+ const noAttempt = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'subagent ran from the prompt file']);
+ expect(noAttempt.status).toBe(3);
+ expect(noAttempt.stderr).toMatch(/--attempt.*\(ATTEMPT_MISMATCH\)$/m);
+ expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before);
+ const attempt = readRunJson(out).phases.ceo.native.attempt;
+ const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'subagent ran from the prompt file', '--attempt', attempt]);
+ expect(ok.status, ok.stderr).toBe(0);
+ expect(ok.stdout).toMatch(new RegExp(`^BOUND: ${attempt} phase=ceo voice=native .*findings=2 .*receipt=attested`, 'm'));
+ const v = readRunJson(out).phases.ceo.native;
+ expect(v).toMatchObject({ status: 'completed', receipt: 'attested', attest_reason: 'subagent ran from the prompt file', findings_source: 'table' });
+ expect(fs.readFileSync(path.join(out, 'ceo-native.md'), 'utf8')).toBe(TABLE);
+ const rows = readJsonl(path.join(out, 'ceo-native.md.findings.jsonl'));
+ expect(rows.map(x => [x.severity, x.title, x.file ?? null, x.line ?? null])).toEqual([['High', 'Retry cap is unbounded on restart', 'lib/worker.ts', 10], ['Medium', 'Split persistence change into its own plan', null, null]]);
+ expect(readRunJson(out).deviations.some((d: string) => /attested.*ceo native/.test(d))).toBe(true);
+ });
+
+ test('a record with the receipt binds a severity-tagged list without --attempt, and `No findings.` binds zero rows', () => {
+ const out = start();
+ const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', write(out, 'l.md', list(snapshotOf(out))), '--model', 'anthropic/claude-opus-5-5']);
+ expect(ok.status, ok.stderr).toBe(0);
+ expect(ok.stdout).toMatch(/^BOUND: \S+-a1 phase=ceo voice=native .*findings=2 .*receipt=verified/m);
+ expect(readRunJson(out).phases.ceo.native).toMatchObject({ receipt: 'verified', findings_source: 'list' });
+ const none = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--record', write(out, 'none.md', `INPUT: ceo ${snapshotOf(out)}\n\nRead it all.\n\nNo findings.\n`), '--model', 'openai/gpt-6.1-sol']);
+ expect(none.status, none.stderr).toBe(0);
+ expect(none.stdout).toMatch(/^BOUND: \S+-a2 phase=ceo voice=outside .*findings=0/m);
+ expect(none.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=both/m);
+ });
+
+ test('refusals write nothing: a wrong receipt (even attested), prose with no findings shape, two shapes, an unknown severity', () => {
+ const out = start();
+ const attempt = readRunJson(out).phases.ceo.native.attempt;
+ const before = { run: fs.readFileSync(path.join(out, 'run.json'), 'utf8'), journal: readJsonl(path.join(out, 'attempts.jsonl')).length };
+ const submit = (text: string, extra: string[] = []) => r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', write(out, `bad${++n}.md`, text), '--model', 'anthropic/claude-opus-5-5', ...extra]);
+ const wrong = submit(`INPUT: ceo ${'0'.repeat(64)}\n\nNo findings.\n`, ['--attest', 'trust me', '--attempt', attempt]);
+ expect(wrong.status).toBe(1);
+ expect(wrong.stderr).toMatch(/\(RESULT_RECEIPT_MISSING\)$/m);
+ const prose = submit(`INPUT: ceo ${snapshotOf(out)}\n\nThe plan looks fine overall but I have concerns.\n`);
+ expect(prose.status).toBe(1);
+ expect(prose.stderr).toMatch(/table.*list.*No findings\..*\(RESULT_FINDINGS_MISSING\)$/m);
+ const two = submit(`${list(snapshotOf(out))}\n${TABLE}`);
+ expect(two.status).toBe(1);
+ expect(two.stderr).toMatch(/more than one findings shape.*\(RESULT_FINDINGS_MISSING\)$/m);
+ const spicy = submit(`INPUT: ceo ${snapshotOf(out)}\n\n| Severity | Title |\n|---|---|\n| Spicy | Too hot |\n`);
+ expect(spicy.status).toBe(1);
+ expect(spicy.stderr).toMatch(/row 1: severity "Spicy".*\(RESULT_FINDINGS_MISSING\)$/m);
+ expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before.run);
+ expect(readJsonl(path.join(out, 'attempts.jsonl')).length).toBe(before.journal);
+ });
+
+ test('the canonical --result path binds without --attempt when exactly one attempt is open', () => {
+ const out = start();
+ expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
+ const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']);
+ expect(ok.status, ok.stderr).toBe(0);
+ expect(ok.stdout).toMatch(/^BOUND: \S+-a1 phase=ceo voice=native .*findings=3/m);
+ });
+
+ test('a phase that next has not opened is refused with the next command', () => {
+ const out = start();
+ const res = r.ap(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--record', write(out, 'eng.md', 'No findings.\n'), '--model', 'anthropic/claude-opus-5-5']);
+ expect(res.status).toBe(3);
+ expect(res.stderr).toMatch(new RegExp(`run gstack-autoplan next --out ${out.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*\\(PHASE_NOT_AWAITING\\)$`, 'm'));
+ });
+
+ test('--voice outside --unavailable closes the phase native_only; next never redispatches it and resume never resurrects it', () => {
+ const out = start();
+ const outsideAttempt = readRunJson(out).phases.ceo.outside.attempt;
+ const un = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--unavailable', 'no codex on this host']);
+ expect(un.status, un.stderr).toBe(0);
+ expect(un.stdout).toMatch(/^OUTSIDE: ceo unavailable/m);
+ const j = readJsonl(path.join(out, 'attempts.jsonl'));
+ expect(j.find(e => e.event === 'outside_unavailable')).toMatchObject({ phase: 'ceo', attempt: outsideAttempt, detail: 'no codex on this host' });
+ expect(readRunJson(out).phases.ceo.outside).toMatchObject({ status: 'unavailable', detail: 'no codex on this host' });
+ const again = r.ap(['next', '--out', out]);
+ expect(again.status, again.stderr).toBe(0);
+ expect(again.stdout).not.toMatch(/voice=outside/);
+ expect(again.stdout).toMatch(/^ATTEMPT: \S+ phase=ceo voice=native .*\(open\)$/m);
+ const nativeAttempt = readRunJson(out).phases.ceo.native.attempt;
+ const resumed = r.ap(['resume', '--out', out]);
+ expect(resumed.stdout).toMatch(/execution_unknown=1 /);
+ expect(resumed.stdout).toContain(`EXECUTION_UNKNOWN: ${nativeAttempt}\n`);
+ expect(resumed.stdout).not.toContain(outsideAttempt);
+ expect(readRunJson(out).phases.ceo.outside.status).toBe('unavailable');
+ expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
+ const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']);
+ expect(close.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m);
+ });
+
+ test('--failed records a failed outside voice and closes the phase once native is bound', () => {
+ const out = start();
+ expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
+ expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
+ const failed = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--failed', 'runner exited 1']);
+ expect(failed.status, failed.stderr).toBe(0);
+ expect(failed.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m);
+ expect(readRunJson(out).phases.ceo.outside).toMatchObject({ status: 'failed', detail: 'runner exited 1' });
+ });
+});
diff --git a/test/autoplan-revise.test.ts b/test/autoplan-revise.test.ts
new file mode 100644
index 000000000..6ae23fc36
--- /dev/null
+++ b/test/autoplan-revise.test.ts
@@ -0,0 +1,174 @@
+/**
+ * `gstack-autoplan revise --out --plan ` (plan B1c, item 9): the run
+ * reviews a revised plan in the same run directory. Drift is the
+ * Implementation-plan hash: every opened phase whose reviewed hash differs is
+ * reopened (open attempts superseded), newly in-scope phases are added, the
+ * archives keep `.r` names with `.v` only on collision, revise at
+ * gate_pending is its own journal event and the next gate is gate_rev + 1.
+ * `next` and `answer` print the drift hints; a plan missing one of the two
+ * sections refuses with PLAN_SECTIONS_INVALID and `next --scaffold` reviews a
+ * scaffolded copy. Runs on the simulated Capy host.
+ */
+import { afterAll, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
+import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
+
+const r = capyRunner();
+afterAll(() => r.cleanup());
+let n = 0;
+const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 60 s, dead-letter after 8 attempts');
+const RECORD_ONLY = SMALL_PLAN + '\nReviewed once; notes only.\n';
+const DX_V2 = SMALL_PLAN.replace('Tests cover', 'Add a CLI flag --max-retries and an error message for a bad value. Tests cover');
+function start(args: string[] = [], text = SMALL_PLAN): { out: string; dir: string; plan: string } {
+ const dir = path.join(r.host.base, `rev${++n}`);
+ const plan = writePlan(dir, text);
+ const out = path.join(dir, 'run');
+ const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]);
+ if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`);
+ return { out, dir, plan };
+}
+const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; };
+function gated(): { out: string; dir: string; plan: string } {
+ const s = start();
+ phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
+ expect(phaseVia(r, s.out, 'eng', { native: [], outside: [] }).outside.stdout).toContain('status=gate_pending');
+ return s;
+}
+
+describe('revise', () => {
+ test('revise after ceo closes reopens ceo, and dx and eng then review the v2 snapshot', () => {
+ const s = start(['--developer-tool']);
+ phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
+ const v1 = readRunJson(s.out).phases.ceo.snapshot;
+ const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]);
+ expect(rev.status, rev.stderr).toBe(0);
+ expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo$/m);
+ expect(rev.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running');
+ for (const phase of ['ceo', 'dx', 'eng']) {
+ const p = phaseVia(r, s.out, phase, { native: [], outside: [] });
+ expect(p.next.stdout).toMatch(new RegExp(`^PHASE: ${phase} snapshot=`, 'm'));
+ expect(fs.readFileSync(path.join(s.out, `${phase}-native-prompt.md`), 'utf8')).toContain('dead-letter after 8 attempts');
+ expect(fs.readFileSync(path.join(s.out, `${phase}-outside-prompt.md`), 'utf8')).toContain('dead-letter after 8 attempts');
+ }
+ const run = readRunJson(s.out);
+ expect(run.status).toBe('gate_pending');
+ expect(new Set(['ceo', 'dx', 'eng'].map(p => run.phases[p].snapshot)).size).toBe(1);
+ expect(run.phases.ceo.snapshot).not.toBe(v1);
+ expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'plan_revised')).toHaveLength(1);
+ expect(fs.existsSync(path.join(s.out, 'ceo-native.r0.md'))).toBe(true);
+ });
+
+ test('a Review-record-only edit keeps every phase; an open phase whose hash changed has its attempts superseded', () => {
+ const s = start();
+ phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
+ const same = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, RECORD_ONLY)]);
+ expect(same.status, same.stderr).toBe(0);
+ expect(same.stdout).toMatch(/reopened=none/);
+ expect(readRunJson(s.out).phases.ceo.closed_at).toBeTruthy();
+ const eng = r.ap(['next', '--out', s.out]);
+ expect(eng.stdout).toMatch(/^PHASE: eng /m);
+ const open = readRunJson(s.out).phases.eng;
+ const changed = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]);
+ expect(changed.stdout).toMatch(/reopened=ceo,eng/);
+ const superseded = readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'superseded').map(e => e.attempt);
+ expect(superseded.sort()).toEqual([open.native.attempt, open.outside.attempt].sort());
+ });
+
+ test('revise at gate_pending is its own journal event, reruns on v2, and the next gate is gate_rev 2', () => {
+ const s = gated();
+ const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir), '--json']);
+ expect(rev.status, rev.stderr).toBe(0);
+ expect(JSON.parse(rev.stdout)).toMatchObject({ status: 'running', reopened: ['ceo', 'eng'] });
+ const j = readJsonl(path.join(s.out, 'attempts.jsonl'));
+ expect(j.find(e => e.event === 'plan_revised')).toMatchObject({ at_gate_rev: 1 });
+ expect(j.filter(e => e.event === 'answered')).toEqual([]);
+ expect(fs.existsSync(path.join(s.out, 'gate.r1.json'))).toBe(true);
+ phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
+ phaseVia(r, s.out, 'eng', { native: [], outside: [] });
+ expect(fs.readFileSync(path.join(s.out, 'eng-native-prompt.md'), 'utf8')).toContain('dead-letter after 8 attempts');
+ expect(readRunJson(s.out)).toMatchObject({ status: 'gate_pending', gate_rev: 2 });
+ });
+
+ test('a second revise never overwrites the first archive', () => {
+ const s = gated();
+ expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
+ const first = fs.readFileSync(path.join(s.out, 'ceo-native.r1.md'));
+ phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
+ expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace('jitter 10%', 'jitter 20%'))]).status).toBe(0);
+ expect(fs.readFileSync(path.join(s.out, 'ceo-native.r1.md')).equals(first)).toBe(true);
+ expect(fs.existsSync(path.join(s.out, 'ceo-native.r1.v2.md'))).toBe(true);
+ });
+
+ test('revise adds a newly in-scope phase and never removes one', () => {
+ const s = start();
+ expect(readRunJson(s.out).phase_order).toEqual(['ceo', 'eng']);
+ const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, DX_V2)]);
+ expect(rev.status, rev.stderr).toBe(0);
+ const run = readRunJson(s.out);
+ expect(run.phase_order).toEqual(['ceo', 'dx', 'eng']);
+ expect(run.required_phases).toEqual(['ceo', 'dx', 'eng']);
+ expect(run.phases.dx.skipped).toBeUndefined();
+ expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
+ expect(readRunJson(s.out).phase_order).toEqual(['ceo', 'dx', 'eng']);
+ });
+
+ test('an attested bind is refused when the plan was revised after its attempt was dispatched', () => {
+ const s = start();
+ const attempt = readRunJson(s.out).phases.ceo.native.attempt;
+ expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, RECORD_ONLY)]).stdout).toMatch(/reopened=none/);
+ const rec = path.join(s.dir, 'hand.md');
+ fs.writeFileSync(rec, 'Hand review.\n\nNo findings.\n');
+ const res = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'ran it', '--attempt', attempt]);
+ expect(res.status).toBe(1);
+ expect(res.stderr).toMatch(/revised after .*dispatched.*\(RESULT_RECEIPT_MISSING\)$/m);
+ });
+});
+
+describe('drift hints', () => {
+ test('next prints PLAN_CHANGED when the source plan changed since the snapshot', () => {
+ const s = start();
+ fs.writeFileSync(s.plan, V2);
+ const res = r.ap(['next', '--out', s.out]);
+ expect(res.stdout).toMatch(new RegExp(`^PLAN_CHANGED: ${s.plan.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} differs from the reviewed plan; run gstack-autoplan revise --out \\S+ --plan \\S+$`, 'm'));
+ });
+ test('answer prints PLAN_SOURCE_CHANGED when a reply reopens phases after the source plan changed', () => {
+ const s = gated();
+ fs.writeFileSync(s.plan, V2);
+ const res = r.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']);
+ expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\)/m);
+ expect(res.stdout).toMatch(/^PLAN_SOURCE_CHANGED: .* differs from the reviewed plan; run gstack-autoplan revise --out \S+ --plan \S+$/m);
+ });
+});
+
+describe('plan sections', () => {
+ test('only an Implementation plan: next refuses with PLAN_SECTIONS_INVALID and stdout; --scaffold reviews a copy', () => {
+ const dir = path.join(r.host.base, 'scaffold');
+ const text = '# P\n\n## Implementation plan\n\nDo the thing in lib/x.ts with a 30 s timeout.\n';
+ const plan = writePlan(dir, text);
+ const refused = r.ap(['next', '--out', path.join(dir, 'a'), '--plan', plan, '--no-ui']);
+ expect(refused.status).toBe(3);
+ expect(refused.stderr).toMatch(/no "## Review record".*--scaffold.*\(PLAN_SECTIONS_INVALID\)$/m);
+ expect(refused.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
+ const ok = r.ap(['next', '--out', path.join(dir, 'b'), '--plan', plan, '--no-ui', '--scaffold']);
+ expect(ok.status, ok.stderr).toBe(0);
+ expect(ok.stdout).toMatch(/^SCAFFOLDED: added "## Review record"/m);
+ expect(ok.stdout).toMatch(/^PHASE: ceo /m);
+ expect(fs.readFileSync(plan, 'utf8')).toBe(text);
+ expect(fs.readFileSync(path.join(dir, 'b', 'plan-source.md'), 'utf8')).toContain('## Review record');
+ });
+ test('duplicate or reversed headings refuse with the code even with --scaffold; an empty section says so', () => {
+ const dir = path.join(r.host.base, 'ambiguous');
+ for (const [name, text] of [['dup', '# P\n\n## Implementation plan\n\nA.\n\n## Implementation plan\n\nB.\n\n## Review record\n'], ['rev', '# P\n\n## Review record\n\nx\n\n## Implementation plan\n\nA.\n']] as const) {
+ const plan = writePlan(path.join(dir, name), text);
+ const res = r.ap(['next', '--out', path.join(dir, name, 'run'), '--plan', plan, '--no-ui', '--scaffold']);
+ expect(res.status).toBe(3);
+ expect(res.stderr).toMatch(/\(PLAN_SECTIONS_INVALID\)$/m);
+ }
+ const plan = writePlan(path.join(dir, 'empty'), '# P\n\n## Implementation plan\n\n## Review record\n');
+ const res = r.ap(['next', '--out', path.join(dir, 'empty', 'run'), '--plan', plan, '--no-ui']);
+ expect(res.status).toBe(3);
+ expect(res.stderr).toMatch(/Implementation plan is empty.*\(PLAN_SECTIONS_INVALID\)$/m);
+ });
+});
diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json
index 975d8b014..a905542c4 100644
--- a/test/fixtures/module-size-ratchet.json
+++ b/test/fixtures/module-size-ratchet.json
@@ -85,6 +85,10 @@
{ "file": "lib/autoplan-export.ts", "movedFrom": "(new: the unattended export file set, B2/B12)" },
{ "file": "lib/spec-review-cap.ts", "movedFrom": "(new: the spec-review loop at the cap: numbered gaps and deterministic fix verification, B8)" },
{ "file": "bin/gstack-autoplan", "movedFrom": "(new: thin entrypoint over lib/autoplan-run.ts, B2)" },
+ { "file": "lib/autoplan-plan-text.ts", "movedFrom": "(new: plan wrap, Implementation-plan review hash, scaffold and drift lines, field wave B1c)" },
+ { "file": "lib/autoplan-admission.ts", "movedFrom": "(new: install verdict and Bun minimum admission for mutating autoplan verbs, field wave B-core)" },
+ { "file": "lib/autoplan-migrate.ts", "movedFrom": "(new: gstack_version stamp and legacy decision-id migration at load, field wave B1a)" },
+ { "file": "lib/autoplan-revise.ts", "movedFrom": "(new: the revise transaction, amendments and its resume recovery, field wave B1c)" },
{ "file": "bin/gstack-outside-voice", "movedFrom": "(new: thin entrypoint over lib/outside-voice-runner.ts, B3)" },
{ "file": "lib/plan-reality.ts", "movedFrom": "(new: the reality-row table, the REALITY: line grammar and the row checker, D1)" },
{ "file": "bin/gstack-plan-reality", "movedFrom": "(new: thin entrypoint over lib/plan-reality.ts, D1)" },
diff --git a/test/helpers/autoplan-capy.ts b/test/helpers/autoplan-capy.ts
new file mode 100644
index 000000000..f4732cafa
--- /dev/null
+++ b/test/helpers/autoplan-capy.ts
@@ -0,0 +1,73 @@
+/**
+ * autoplan-capy — the unattended runner on the simulated Capy host
+ * (test/helpers/capy-host.ts): Bun 1.3.14 first on PATH unless a command is
+ * launched with the GSTACK_LAUNCH PATH (verified Bun 1.4.2 first), no codex,
+ * an ephemeral home, GSTACK_CAPY=1 and an install verdict for this checkout.
+ * Every spawn carries a timeout.
+ */
+import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { capyHost, type CapyHost } from './capy-host';
+import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture';
+
+const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_');
+const RUNNER_KIND = 'un' + 'attended';
+export const HEAD = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim();
+
+export interface CapyRunner {
+ host: CapyHost; env: Record; verdict: string;
+ ap(args: string[], extra?: Record): SpawnSyncReturns;
+ review(prompt: string, extra?: Record): SpawnSyncReturns;
+ validate(out: string): SpawnSyncReturns;
+ writeVerdict(fields: Record): void;
+ cleanup(): void;
+}
+
+/** A launched command on the Capy host: GSTACK_LAUNCH PATH, unattended, GSTACK_CAPY=1, an ok verdict for this HEAD. */
+export function capyRunner(o: { launched?: boolean; verdict?: Record | null } = {}): CapyRunner {
+ const host = capyHost({ verifiedBun: '1.4.2' });
+ const verdict = path.join(host.stateRoot, 'install-verdict');
+ const env: Record = {
+ ...host.env, PATH: o.launched === false ? host.env.PATH! : host.launchPath,
+ [SESSION_KIND_VAR]: RUNNER_KIND, GSTACK_CAPY: '1', GSTACK_INSTALL_VERDICT: verdict,
+ };
+ const writeVerdict = (fields: Record) => {
+ fs.mkdirSync(path.dirname(verdict), { recursive: true });
+ fs.writeFileSync(verdict, JSON.stringify(fields) + '\n');
+ };
+ if (o.verdict !== null) writeVerdict(o.verdict ?? { trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: HEAD, checkout: fs.realpathSync(ROOT), at: new Date().toISOString() });
+ const run = (args: string[], extra: Record = {}) =>
+ spawnSync('bun', args, { cwd: ROOT, env: { ...env, ...extra }, encoding: 'utf8', timeout: 120_000 });
+ return {
+ host, env, verdict, writeVerdict,
+ ap: (args, extra) => run([AUTOPLAN, ...args], extra),
+ review: (prompt, extra) => run([REVIEWER, prompt], extra),
+ validate: out => run([path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')]),
+ cleanup: host.cleanup,
+ };
+}
+
+export function writePlan(dir: string, text = SMALL_PLAN): string {
+ fs.mkdirSync(dir, { recursive: true });
+ const plan = path.join(dir, 'plan.md');
+ fs.writeFileSync(plan, text);
+ return plan;
+}
+export const readJsonl = (file: string): any[] => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
+export const readRunJson = (out: string): any => JSON.parse(fs.readFileSync(path.join(out, 'run.json'), 'utf8'));
+
+/** next (expects exit 0), fixture-review both prompts, submit both through --result. */
+export function phaseVia(r: CapyRunner, out: string, phase: string, findings: { native?: unknown[]; outside?: unknown[] } = {}): { next: SpawnSyncReturns; native: SpawnSyncReturns; outside: SpawnSyncReturns } {
+ const next = r.ap(['next', '--out', out]);
+ if (next.status !== 0) throw new Error(`next failed: ${next.stderr}\n${next.stdout}`);
+ for (const voice of ['native', 'outside'] as const) {
+ const f = findings[voice];
+ const rv = r.review(path.join(out, `${phase}-${voice}-prompt.md`), f ? { FIXTURE_FINDINGS: JSON.stringify(f) } : {});
+ if (rv.status !== 0) throw new Error(`fixture reviewer failed: ${rv.stderr}`);
+ }
+ const run = readRunJson(out);
+ const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']);
+ const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']);
+ return { next, native, outside };
+}