From 78ee23240c33275ff93f30e0d80c1f8d2873df3a Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 14 Oct 2026 23:56:48 +0000 Subject: [PATCH 1/4] fix(autoplan): empty findings fences, repo-relative rows, PLAN_REALITY flags, pending/--all/provider (Lane B: B3 B6 B7 B8 B9 B10 B11) B3 parseFences: the body group is optional (lazy), so an LF/CRLF empty fence is zero findings in both the canonical and record grammars; opener without closer still RESULT_FINDINGS_MISSING. B6 plan-reality/3: item/section references are not quantities; backtick phrases are exempt only from the plan-scope rule-phrase check (B-f); a measured number inside backticks stays flagged in review scope. vaguePlanTerms() feeds an advisory `flagged: PLAN_REALITY ...` line under the p1 approval item (never blocking). B7 canonical rows: absolute repo paths become repo-relative; snapshot/active-plan citations become the plan file name with the source line mapped (or dropped). B8 `status` prints `PENDING: phase=

voice= result=` per open attempt. B9 `gstack-gate render --all`; paged output says to relay every page. B10 `gstack-plan-reality check --plan `: a number in a "quoted" span copied verbatim from the reviewed plan needs no receipt (reviewer scope only). B11 submit defaults the provider to host-subagent when --runner is omitted. Lane B files: lib/autoplan-reconcile.ts lib/plan-reality.ts lib/autoplan-gate.ts lib/gate-list.ts lib/autoplan-plan-text.ts lib/autoplan-run.ts bin/gstack-gate bin/gstack-plan-reality scripts/resolvers/plan-reality.ts autoplan/sections/ceo-phase.md autoplan/sections/eng-phase.md test/plan-reality.test.ts test/autoplan-gate-artifacts.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/sections/ceo-phase.md | 2 +- autoplan/sections/eng-phase.md | 2 +- bin/gstack-gate | 10 +- bin/gstack-plan-reality | 12 ++- lib/autoplan-gate.ts | 5 +- lib/autoplan-plan-text.ts | 37 +++++++ lib/autoplan-reconcile.ts | 4 +- lib/autoplan-run.ts | 15 ++- lib/gate-list.ts | 5 +- lib/plan-reality.ts | 61 +++++++++--- scripts/resolvers/plan-reality.ts | 2 +- test/autoplan-gate-artifacts.test.ts | 139 +++++++++++++++++++++++++++ test/plan-reality.test.ts | 56 ++++++++++- 13 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 test/autoplan-gate-artifacts.test.ts diff --git a/autoplan/sections/ceo-phase.md b/autoplan/sections/ceo-phase.md index de9d0d1bd..a878f308b 100644 --- a/autoplan/sections/ceo-phase.md +++ b/autoplan/sections/ceo-phase.md @@ -176,7 +176,7 @@ Sections 1-11 — for EACH section, run the evaluation criteria from the loaded **Mandatory outputs from Phase 1:** - Reality rows: the loaded skill's `REALITY:` lines, one per applicable row; - `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo ` prints `PLAN_REALITY: … verdict=complete` before the phase closes + `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo --plan ` prints `PLAN_REALITY: … verdict=complete` before the phase closes (unattended: `incomplete` keeps it open). - "NOT in scope" section with deferred items and rationale - "What already exists" section mapping sub-problems to existing code diff --git a/autoplan/sections/eng-phase.md b/autoplan/sections/eng-phase.md index ad9da2018..d9d3856f1 100644 --- a/autoplan/sections/eng-phase.md +++ b/autoplan/sections/eng-phase.md @@ -105,7 +105,7 @@ Retain the historical review-log skill ID; add `"host":"claude","outside_provide 1. Step 0 (Scope Challenge): Read actual code referenced by the plan. Map each sub-problem to existing code. Run the complexity check. Produce concrete findings. Reality rows: the loaded skill's `REALITY:` lines, one per applicable row; - `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng ` prints `PLAN_REALITY: … verdict=complete` before the phase closes + `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng --plan ` prints `PLAN_REALITY: … verdict=complete` before the phase closes (unattended: `incomplete` keeps it open). 2. Step 0.5 (Dual Voices): Present the completed calls above under Codex SAYS diff --git a/bin/gstack-gate b/bin/gstack-gate index 6e4e10007..59e7abcd0 100755 --- a/bin/gstack-gate +++ b/bin/gstack-gate @@ -2,7 +2,7 @@ /** * gstack-gate — render and parse the one gate list (plan B9). * - * gstack-gate render [--page N] + * gstack-gate render [--page N | --all] * gstack-gate parse --reply "" --gate-rev N [--json] * gstack-gate decisions [--reply "" --gate-rev N] [--answered-at ISO] * @@ -15,7 +15,8 @@ import { resultLine } from '../lib/result-codes'; const HELP = `gstack-gate — one gate list, one reply grammar - render [--page N] numbered items with stable ids, auto/approval, the reply shape + render [--page N | --all] numbered items with stable ids, auto/approval, the reply shape; + --all prints every item on one page (relay every page) parse --reply "" --gate-rev N [--json] resolved choices as JSON; unparsed tokens are listed, never guessed decisions [--reply "" --gate-rev N] [--answered-at ISO] @@ -67,14 +68,15 @@ function parseWith(list: GateList, rest: string[], json: boolean) { function main(argv: string[]): number { const [cmd, ...rest] = argv; if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } - const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && rest[i - 1] !== '--json')); + const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && !['--json', '--all'].includes(rest[i - 1]!))); const list = loadList(positional[0]); if (typeof list === 'number') return list; if (cmd === 'render') { const page = flag(rest, '--page'); if (page !== undefined && !/^[0-9]+$/.test(page)) return usage('--page needs a number'); - process.stdout.write(renderGate(list, page ? Number(page) : 1)); + if (page !== undefined && rest.includes('--all')) return usage('--page and --all are exclusive'); + process.stdout.write(rest.includes('--all') ? renderGate(list, 1, list.items.length) : renderGate(list, page ? Number(page) : 1)); return EXIT.ok; } if (cmd === 'parse') { diff --git a/bin/gstack-plan-reality b/bin/gstack-plan-reality index a5a880a75..8ca759c60 100755 --- a/bin/gstack-plan-reality +++ b/bin/gstack-plan-reality @@ -3,7 +3,7 @@ * gstack-plan-reality — the reality rows of a plan review (plan D1). * * gstack-plan-reality rows --phase [--scope ] [--deep] [--json] - * gstack-plan-reality check --phase

[--scope ] [--session-kind ] [--json] + * gstack-plan-reality check --phase

[--scope ] [--session-kind ] [--plan ] [--json] * * Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING). */ @@ -15,8 +15,9 @@ import { resultLine } from '../lib/result-codes'; const HELP = `gstack-plan-reality — reality rows for plan reviews rows --phase

[--scope ] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope) - check --phase

[--scope ] [--session-kind ] [--json] + check --phase

[--scope ] [--session-kind ] [--plan ] [--json] parse the review's REALITY: lines; exit 1 with the missing rows + --plan: a number inside a "quoted" span copied verbatim from it needs no receipt Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha). Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair. @@ -49,13 +50,16 @@ function main(argv: string[]): number { return EXIT.ok; } if (cmd === 'check') { - const valueFlags = new Set(['--phase', '--scope', '--session-kind']); + const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']); const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!))); if (!file) return usage('a review file (or - for stdin) is required'); let text: string; try { text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${file}: ${e.message}\n`); return EXIT.fail; } const sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND; - const result = checkRealityRows(text, { phase, scopes, sessionKind }); + const planFile = flag(rest, '--plan'); + let planText: string | undefined; + try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; } + const result = checkRealityRows(text, { phase, scopes, sessionKind, planText }); if (json) process.stdout.write(JSON.stringify(result) + '\n'); else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n'); for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`); diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 23c327929..b46e895ea 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -28,6 +28,7 @@ import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, pa import type { FindingRow } from './headless-artifacts'; import { readJsonl } from './headless-artifacts'; import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief'; +import { vaguePlanTerms } from './plan-reality'; import { settle } from './spend-ledger'; export function loadOverrides(file: string | undefined): ReconciliationOverride[] { @@ -140,7 +141,9 @@ export function buildGate(state: RunState, gateRev: number): GateList { } } 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', + const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan; + const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`); + items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}), options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, { key: 'c', text: 'interrogate (ask questions; stays pending)' }, { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan )` }, { key: 'e', text: 'reject' }] }); diff --git a/lib/autoplan-plan-text.ts b/lib/autoplan-plan-text.ts index c6b41f7be..83d69cee1 100644 --- a/lib/autoplan-plan-text.ts +++ b/lib/autoplan-plan-text.ts @@ -10,6 +10,7 @@ */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; +import * as path from 'node:path'; import { extractImplementationPlan, implementationForReview, sectionHeadings } from '../bin/gstack-autoplan-snapshot'; import { extractHeading } from './autoplan-prompts'; import type { RunState } from './autoplan-state'; @@ -79,3 +80,39 @@ export function driftLine(state: RunState, code: 'PLAN_CHANGED' | 'PLAN_SOURCE_C if (!current || current === reviewed) return undefined; return `${code}: ${source} differs from the reviewed plan; run gstack-autoplan revise --out ${state.out} --plan ${source}`; } + +/** The nearest directory at or above `dir` holding `.git` (no git spawn), or undefined. */ +export function repoRoot(dir: string): string | undefined { + for (let d = path.resolve(dir); ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return undefined; + } +} + +/** Line offset of `inner` inside `outer` (0 when it starts on line 1), or undefined when it is not a verbatim block of it. */ +function blockOffset(inner: string, outer: string): number | undefined { + const at = outer.indexOf(inner); + if (at >= 0) return outer.slice(0, at).split('\n').length - 1; + const restoreLine = /^\n${fs.readFileSync(source, 'utf8')}`); + const stray = path.join(TMP, 'other-snapshot.md'); + fs.writeFileSync(stray, 'unrelated\n'); + const ctx = { cwd: path.join(repo, 'src'), planFiles: [snapshot, active, stray], planSource: source, planName: 'plan.md' }; + expect(normalizeFindingFile({ file: path.join(repo, 'src', 'worker.ts'), line: 9 }, ctx)).toEqual({ file: 'src/worker.ts', line: 9 }); + expect(normalizeFindingFile({ file: snapshot, line: 3 }, ctx)).toEqual({ file: 'plan.md', line: 6 }); + expect(normalizeFindingFile({ file: 'ceo-implementation.md', line: 2 }, ctx)).toEqual({ file: 'plan.md', line: 5 }); + expect(normalizeFindingFile({ file: active, line: 6 }, ctx)).toEqual({ file: 'plan.md', line: 5 }); + expect(normalizeFindingFile({ file: stray, line: 1 }, ctx)).toEqual({ file: 'plan.md' }); + expect(normalizeFindingFile({ file: '/elsewhere/x.ts', line: 1 }, ctx)).toEqual({ file: '/elsewhere/x.ts', line: 1 }); + expect(normalizeFindingFile({ file: 'lib/x.ts', line: 4 }, ctx)).toEqual({ file: 'lib/x.ts', line: 4 }); + }); + + test('end to end: a reviewer citing the snapshot and an absolute repo path binds repo-relative rows; the bound copy stays verbatim', () => { + const out = startSmallRun(path.join(TMP, 'paths')); + expect(autoplan(['next', '--out', out]).status).toBe(0); + const snap = readRun(out).phases.ceo.snapshot_path as string; + const findings = JSON.stringify([{ severity: 'High', title: 'cap unexplained', file: snap, line: 2 }, { severity: 'Low', title: 'abs path', file: path.join(ROOT, 'lib', 'gate-list.ts'), line: 3 }]); + review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: findings }); + const ids = attemptsOf(out, 'ceo'); + const result = readRun(out).phases.ceo.native.attempts[0].result as string; + const bind = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', result, '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native]); + expect(bind.status, bind.stderr).toBe(0); + const output = readRun(out).phases.ceo.native.output as string; + const rows = fs.readFileSync(path.join(out, `${output}.findings.jsonl`), 'utf8').trim().split('\n').map(l => JSON.parse(l)); + expect(rows.map((r: any) => [r.file, r.line])).toEqual([['plan.md', 5], ['lib/gate-list.ts', 3]]); + expect(fs.readFileSync(path.join(out, output), 'utf8')).toContain(snap); + }); +}); + +describe('B8/B11: pending attempts in status; outside voice labeled host-subagent', () => { + test('status prints PENDING per open attempt; an outside submit without --runner reads model (host-subagent)', () => { + const out = startSmallRun(path.join(TMP, 'pending')); + expect(autoplan(['next', '--out', out]).status).toBe(0); + const ids = attemptsOf(out, 'ceo'); + const run = readRun(out); + const status = autoplan(['status', '--out', out]); + expect(status.stdout).toContain(`PENDING: ${ids.native} phase=ceo voice=native result=${run.phases.ceo.native.attempts[0].result}`); + expect(status.stdout).toContain(`PENDING: ${ids.outside} phase=ceo voice=outside result=${run.phases.ceo.outside.attempts[0].result}`); + for (const voice of ['native', 'outside']) review(path.join(out, `ceo-${voice}-prompt.md`)); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); + expect(autoplan(['status', '--out', out]).stdout).not.toContain(`PENDING: ${ids.native}`); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts[0].result, '--model', 'openai/gpt-6.1-sol']).status).toBe(0); + expect(readRun(out).phases.ceo.outside.provider).toBe('host-subagent'); + expect(fs.readFileSync(path.join(out, 'ceo-consensus.md'), 'utf8')).toContain('openai/gpt-6.1-sol(host-subagent)'); + expect(autoplan(['status', '--out', out]).stdout).not.toContain('PENDING:'); + }); +}); + +describe('B9: every gate page reaches the owner', () => { + const item = (i: number) => ({ id: `d${i}`, title: `finding ${i}`, kind: 'auto' as const, recommended: 'a', options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] }); + const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [...Array.from({ length: 14 }, (_, i) => item(i + 1)), { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'a', options: [{ key: 'a', text: 'approve' }, { key: 'b', text: 'other' }] }] }; + test('14 findings span three pages; --all prints every id once and paged output says to relay every page', () => { + expect(renderGate(list)).toContain('or every page with `--all`. Relay every page.'); + const file = path.join(TMP, 'big-gate.json'); + fs.writeFileSync(file, JSON.stringify(list)); + const r = spawnSync('bun', [GATE, 'render', file, '--all'], { encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('page=1/1'); + for (const i of list.items) expect(r.stdout.match(new RegExp(`\\[${i.id}\\]`, 'g'))?.length).toBe(1); + const pages = [1, 2, 3].map(p => renderGate(list, p)).join('\n'); + for (const i of list.items) expect(pages).toContain(`[${i.id}]`); + expect(spawnSync('bun', [GATE, 'render', file, '--all', '--page', '2'], { encoding: 'utf8', timeout: 30_000 }).status).toBe(2); + }); +}); + +describe('B6: PLAN_REALITY flag beside the approval item', () => { + test('the plan names a minimum effect with no number: gate.json p1 carries the flag and the render prints it; nothing blocks', () => { + const dir = path.join(TMP, 'vague'); + fs.mkdirSync(dir, { recursive: true }); + const plan = path.join(dir, 'plan.md'); + fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover the cap', 'Ship once the minimum effect shows in the retry metrics. Tests cover the cap')); + const out = path.join(dir, 'run'); + expect(autoplan(['next', '--out', out, '--plan', plan, '--no-ui']).status).toBe(0); + for (const phase of ['ceo', 'eng']) { const r = runPhase(out, phase); expect(r.outside.status, r.outside.stderr).toBe(0); } + const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList; + const p1 = gate.items.find(i => i.id === 'p1')!; + expect(p1.flags).toEqual([expect.stringMatching(/^PLAN_REALITY "minimum effect" has no number, arm list or rule body \(plan\.md:6: .*minimum effect.*\) \(report only\)$/)]); + expect(renderGate(gate)).toMatch(/\n flagged: PLAN_REALITY "minimum effect"/); + expect(readRun(out).status).toBe('gate_pending'); + }); +}); diff --git a/test/plan-reality.test.ts b/test/plan-reality.test.ts index 9bccc7a6b..ecdf21d55 100644 --- a/test/plan-reality.test.ts +++ b/test/plan-reality.test.ts @@ -13,7 +13,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality'; +import { PLAN_REALITY_CHECKER, REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows, vaguePlanTerms } from '../lib/plan-reality'; import { extractHeading } from '../lib/autoplan-prompts'; import { RESULT_CODES } from '../lib/result-codes'; import { generatePlanRealityRows } from '../scripts/resolvers/plan-reality'; @@ -311,3 +311,57 @@ describe('field wave item 12: a receipt per measured number, the rule text for a expect(r.unreceipted_numbers.length).toBeLessThanOrEqual(6); }); }); + +describe('next wave B6/B10: PLAN_REALITY flags without false positives; plan quotes need no receipt', () => { + const others = (id: string) => Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive).join('\n'); + + test('B6: item and section references are not quantities; a phrase in backticks is named, not claimed (plan scope)', () => { + const plan = "B1. `rec`: one word takes every recommendation (item 5).\nB6. When the plan names `minimum effect`, `margin`, `decision rule` or a dollar cost, the gate flags it (items 10, 14–19; section 3).\n"; + const r = checkPlanReality(plan); + expect(r.unreceipted_numbers).toEqual([]); + expect(r.unquoted).toEqual([]); + expect(checkPlanReality('Startup takes roughly 9 s on a cold machine.\n').unreceipted_numbers).toEqual(['Startup takes roughly 9 s on a cold machine.']); + expect(checkPlanReality('The decision rule was written first.\n').unquoted).toEqual(['The decision rule was written first.']); + expect(PLAN_REALITY_CHECKER).toBe('plan-reality/3'); + }); + + test('B-f negative control: a measured number inside backticks in review scope stays flagged; a command in backticks still receipts', () => { + const quiet = 'REALITY: numbers pass the cold install took `86 s` on Capy'; + expect(checkRealityRows(`${others('numbers')}\n${quiet}`, { phase: 'eng' }).unreceipted_numbers).toEqual(['the cold install took `86 s` on Capy']); + const cmd = 'REALITY: numbers pass the cold install took 86 s `bun run bench --json` on Capy'; + expect(checkRealityRows(`${others('numbers')}\n${cmd}`, { phase: 'eng' }).unreceipted_numbers).toEqual([]); + }); + + test('B10: in reviewer scope a number quoted verbatim from the reviewed plan needs no receipt; a reworded or unknown quote does', () => { + const plan = 'Add exponential backoff: base 500 ms, factor 2, cap 30 s, jitter 10%.\n'; + const quoted = 'REALITY: numbers pass the plan says "base 500 ms, factor 2, cap 30 s" and gives no reason for the cap (plan.md:1)'; + expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual([]); + expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng' }).unreceipted_numbers.length).toBeGreaterThan(0); + const reworded = 'REALITY: numbers pass the plan says "a 45 s cap" and gives no reason'; + expect(checkRealityRows(`${others('numbers')}\n${reworded}`, { phase: 'eng', planText: plan }).unreceipted_numbers.length).toBe(1); + const own = 'REALITY: numbers pass the plan says "cap 30 s"; retries took 900 ms in my run'; + expect(checkRealityRows(`${others('numbers')}\n${own}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual(['retries took 900 ms in my run']); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-reality-quote-')); + fs.writeFileSync(path.join(dir, 'plan.md'), plan); + const bin = run(['check', '--phase', 'eng', '--plan', path.join(dir, 'plan.md'), '-'], `${others('numbers')}\n${quoted}`); + expect(bin.code, bin.err).toBe(EXIT.ok); + expect(run(['check', '--phase', 'eng', '-'], `${others('numbers')}\n${quoted}`).code).toBe(EXIT.fail); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('B6: vaguePlanTerms flags minimum effect, margin, decision rule and dollar cost only without a number, arm list or rule body', () => { + const plan = [ + 'Ship when the minimum effect is reached.', + 'Keep a safety margin on the spend.', + 'A margin of 15% over the pilot price.', + 'The decision rule:', + '- arm A: keep the default', + '- arm B: switch', + 'We price the dollar cost later (item 9).', + 'The decision rule is "stop when recall drops below the base".', + 'Name `minimum effect` in the template.', + '```', 'margin inside a fence', '```', + ].join('\n'); + expect(vaguePlanTerms(plan).map(v => [v.phrase, v.line])).toEqual([['minimum effect', 1], ['margin', 2], ['dollar cost', 7]]); + }); +}); From 77ab13967510e5bd32c303abb106f16cce9d9345 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 15 Oct 2026 00:37:42 +0000 Subject: [PATCH 2/4] fix(autoplan): one-word rec, verified pairing, sealed attempt results, deferred reopen (Lane B: B1 B2 B4 B5) B1/B-b/B-c `rec` (aliases: all, approve all, accept all, take all recommendations) takes every recommendation, plan changes included; `as-is` keeps every direction. The render offers `rec` first and prints what it resolves to with gate_rev and rec_hash. `gstack-autoplan recommend` changes one recommendation before relay (provenance recorded, p1 and decisions follow) and bumps rec_rev, so a `rec` without the new --rec-hash is refused (GATE_REC_STALE). An agent answer needs the owner's words verbatim (--owner-reply |-, --owner-reply-file; OWNER_REPLY_REQUIRED); a parseable owner reply that resolves differently from the relay is refused (GATE_OWNER_REPLY_MISMATCH). Supersedes field-wave R1 per the owner's gate_rev 1 decision. B2/B-a reconcile associates by title first; a file:line-only match is `unverified` (associated, never confirmed). `b` is recommended only for a confirmed pair with the same `changes` and one shared proposal (override) or the same normalized remedy; the basis says which. Negative control and positives copied from run-20261014225954 (test/fixtures/autoplan-pairing). B4/B-e each attempt writes -..md; submit refuses another attempt's file (ATTEMPT_MISMATCH) and binds a sealed 0444 -..bound.md copy, so a late write changes nothing bound; reopen keeps sealed copies in place. B5/B-d answer records accepted changes as amendments (status amend_pending) and reopens nothing; revise reopens what changed. `revise --settle ` discharges a packaging/ordering change on an unchanged plan (AMENDMENT_NOT_SETTLEABLE otherwise). APPLY/NEXT/status lines say which. DRIVER.md and the reply table move to the rec grammar. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/DRIVER.md | 38 ++-- autoplan/DRIVER.md.tmpl | 14 +- bin/gstack-autoplan | 59 ++++-- bin/gstack-doctor-components.sh | 4 + docs/troubleshooting.md | 52 ++++- lib/autoplan-export.ts | 2 +- lib/autoplan-gate.ts | 182 +++++++++++++----- lib/autoplan-prompts.ts | 11 +- lib/autoplan-reconcile.ts | 61 +++--- lib/autoplan-revise.ts | 71 +++++-- lib/autoplan-run.ts | 29 ++- lib/autoplan-state.ts | 6 +- lib/gate-list.ts | 84 ++++---- lib/headless-artifacts.ts | 12 +- lib/result-codes.ts | 22 ++- scripts/resolvers/autoplan-driver.ts | 14 +- test/autoplan-answer-invalidation.test.ts | 37 ++-- test/autoplan-capy-e2e.test.ts | 10 +- test/autoplan-driver.test.ts | 2 +- test/autoplan-gate-judgment.test.ts | 156 +++++++++------ test/autoplan-pairing.test.ts | 39 ++++ test/autoplan-record.test.ts | 11 +- test/autoplan-residuals.test.ts | 119 ++++++++---- test/autoplan-revise.test.ts | 12 +- test/autoplan-run-concurrency.test.ts | 8 +- test/autoplan-run-recovery.test.ts | 71 +++++-- .../autoplan-pairing/run-20261014-pairs.json | 94 +++++++++ test/gate-list.test.ts | 39 +++- test/helpers/autoplan-capy.ts | 6 +- test/helpers/autoplan-run-fixture.ts | 13 +- 30 files changed, 937 insertions(+), 341 deletions(-) create mode 100644 test/autoplan-pairing.test.ts create mode 100644 test/fixtures/autoplan-pairing/run-20261014-pairs.json diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 3f29a58c0..c31483744 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -28,12 +28,13 @@ line yourself; gstack never writes the plan. a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=`); render it with - `gstack-gate render

/gate.json`. You never approve the gate for the owner: relay it. + `gstack-gate render /gate.json --all` and relay every page. You never approve the + gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. 4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or - `--by agent:` (you relayed or decided them); without it an unattended run refuses - (`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's - own token changes their stated direction. `APPLY: -> <change>` lines are - accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until + `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an + unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are + accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed + `NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. @@ -51,16 +52,20 @@ line yourself; gstack never writes the plan. bind a canonical result (the RESULT FORMAT below). - `gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"` close the phase native-only when no outside voice can run. -- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"` - answer the gate with the reply grammar below. +- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]` + answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim. +- `gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"` + change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`). - `gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>` bind the amended plan; reopens every phase whose reviewed plan changed, Eng last. +- `gstack-autoplan revise --out <dir> --settle <decision id|d4>` + discharge a packaging or ordering change with no plan edit; reopens nothing. - `gstack-autoplan revise --out <dir> --withdraw <decision id>` drop a pending amendment instead of applying it. - `gstack-autoplan export --out <dir>` write the artifact set; then `gstack-artifact validate <dir>/run.json`. - `gstack-autoplan status|resume --out <dir>` - read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). + read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). ## Reviewer result format @@ -73,22 +78,23 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c ```gstack-findings {"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"} ``` - 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": "<who>" 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") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" 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: <result file> ## Gate replies A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unparsed, never guessed. -- `all`: every item takes its recommendation (an agreed change prints APPLY: and needs revise). -- `all as-is`: keep every direction; no plan change. +- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias. +- `as-is`: keep every direction; no plan change. - `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default. -- `all except d3b`: recommendations, with the listed overrides. +- `rec except d3b`: recommendations, with the listed overrides. ## Run directory Runner state: `run.json`, `attempts.jsonl`, `run.lock`, `spend.json`, `plan-active.md`, `restore.md`, `plan-source.md`. Reviewer files: `<phase>-native-prompt.md`, -`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`, +`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed +`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`, `gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`, `tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json` is the completion check. Copy the whole directory, never single files. @@ -108,7 +114,11 @@ is the completion check. Copy the whole directory, never single files. - `CONSENSUS_MISSING`: resume the run so the phase closes through reconciliation; the Eng prompt is never sent without every prior phase summary - `GATE_REV_STALE`: render the current gate list, read it, and reply with its gate_rev - `GATE_REPLY_UNPARSED`: reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous -- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` +- `GATE_REC_STALE`: render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash` +- `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>` +- `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree +- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` +- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan - `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade ## Coordinator contract (inherited by every lane) diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 46a0ef6ce..258f8226b 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -25,12 +25,13 @@ line yourself; gstack never writes the plan. a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with - `gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it. + `gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the + gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. 4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or - `--by agent:<id>` (you relayed or decided them); without it an unattended run refuses - (`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's - own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are - accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until + `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an + unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are + accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed + `NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. @@ -54,7 +55,8 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa ## Run directory {{AUTOPLAN_DRIVER_FILES}} Reviewer files: `<phase>-native-prompt.md`, -`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`, +`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed +`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`, `gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`, `tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json` is the completion check. Copy the whole directory, never single files. diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index eb91f849a..154fc5a9e 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -7,10 +7,11 @@ * gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>] * --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json] * gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json] - * gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--json] review a revised plan in this run + * gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--settle <id>] [--json] review a revised plan in this run * gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] * [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices - * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" [--json] + * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json] + * gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] * gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes @@ -20,12 +21,12 @@ * Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer), * lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts. * Workflow commands end with GSTACK_RESULT: skill=autoplan status=<s> run=<dir>; the run is never approved here. - * Mutating verbs (next, submit, answer, revise, export, run) pass admission first (lib/autoplan-admission.ts) + * Mutating verbs (next, submit, answer, recommend, 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 { answerGate, applyGuidance, recommendGate } from '../lib/autoplan-gate'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; import { exportRun } from '../lib/autoplan-export'; import { migrateRun } from '../lib/autoplan-migrate'; @@ -55,12 +56,17 @@ DRIVER: ${driverFile()} without its INPUT line it binds only with --attest <reason> and --attempt (receipt: attested) submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> the outside voice could not run: closes the phase native_only once native is bound - revise --out <dir> --plan <file> [--incorporates <decision id,...>] [--withdraw <decision id>] - 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 + revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] | --settle <id,...> + review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (Eng last); + --incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one; + --settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4) run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--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 <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" --by <human|agent:<id>> [--json] + answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json] + --by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim; + --rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending + recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] + set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id> contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) @@ -87,10 +93,13 @@ function num(args: string[], name: string): number | undefined { } 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. */ +/** The NEXT line after an answer or revise: per pending change, settle it or edit the plan and revise; 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}`; + const pending = pendingAmendments(state); + const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision); + const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision); + if (!pending.length) return `NEXT: gstack-autoplan next --out ${out}`; + return `NEXT: ${[edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : '', settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : ''].filter(Boolean).join('; ')}`; } /** @@ -166,12 +175,12 @@ function doSubmit(out: string, rest: string[], json: boolean): number { } 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 <file> (and optionally --incorporates <id,...>), or --withdraw <decision id>'); + const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle'); + if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>'); if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>'); - const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, cwd: process.cwd() }); + const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() }); const lines = [...r.lines]; - if (r.state.status === 'running') lines.push(nextLine(r.state, out)); + if (r.state.status === 'running' || r.state.status === 'amend_pending') 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; @@ -249,17 +258,31 @@ async function main(argv: string[]): Promise<number> { const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by'); if (reply === undefined) return usage('answer needs --reply "<text>"'); 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; } + const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply'); + if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive'); + const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag; return mutating(out, json, () => { const state = readState(out); - const r = answerGate(state, Number(rev), reply, by); + const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply }); writeState(state); const lines = [...r.lines]; - if (r.outcome === 'reopened') lines.push(nextLine(state, out)); + if (r.outcome === 'reopened' || r.outcome === 'amend_pending') 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, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out }); return EXIT.ok; }); } + if (cmd === 'recommend') { + const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis'); + if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"'); + return mutating(out, json, () => { + const state = readState(out); + const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent'); + writeState(state); + emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out }); + return EXIT.ok; + }); + } if (cmd === 'export') { return mutating(out, json, () => { const state = readState(out); @@ -283,7 +306,7 @@ async function main(argv: string[]): Promise<number> { } 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', 'revise'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n'); + if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend'].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/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 181410d40..f3d9c7962 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -184,6 +184,10 @@ gstack_result_anchor() { PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;; PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;; AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; + AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;; + GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;; + OWNER_REPLY_REQUIRED) printf '%s' 'owner-reply-required' ;; + GATE_OWNER_REPLY_MISMATCH) printf '%s' 'gate-owner-reply-mismatch' ;; GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;; ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;; UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f5073c73f..bd10459f6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2700,7 +2700,10 @@ incorporated or withdrawn; `export` refuses a `complete` run with one pending. **Fix.** Edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`. `revise` binds each named id to the old and new Implementation-plan hashes and refuses when the Implementation plan did -not change; an id you do not name stays pending. To drop an accepted change +not change; an id you do not name stays pending. A packaging or ordering change +(its `APPLY:` line ends `no plan edit needed: revise --settle <id>`) is +discharged with `gstack-autoplan revise --out <dir> --settle <id>` and reopens +nothing. To drop an accepted change instead, `gstack-autoplan revise --out <dir> --withdraw <decision id>` records the withdrawal in the journal. @@ -2716,6 +2719,53 @@ amendments), or it was already incorporated or withdrawn. Nothing was changed. pending, revise without `--incorporates`: `gstack-autoplan revise --out <dir> --plan <file>`. +<a id="amendment-not-settleable"></a> +### `AMENDMENT_NOT_SETTLEABLE` + +**Meaning.** `gstack-autoplan revise --settle <id>` discharges an accepted change +without a plan edit, which is honest only for a packaging or ordering change +(the gate item's `changes` is `packaging` or `ordering`) on a plan nobody has +edited since it was reviewed. The named change alters scope, cost or direction, +names no `changes` value, or the plan file's Implementation plan no longer +matches the reviewed snapshot. Nothing was changed. + +**Fix.** Edit the plan, then +`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`. +The `APPLY:` line for each change says which path it takes. + +<a id="gate-rec-stale"></a> +### `GATE_REC_STALE` + +**Meaning.** `gstack-autoplan recommend` changed a recommendation after the gate +was rendered, so a `rec` (or `all`) reply the owner gave to the earlier render +would take recommendations they never saw. Every render prints +`rec_hash=<h>`; once `recommend` has run, a `rec` answer must name the hash of +the render the owner answered. + +**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it, +and answer with `--rec-hash <h>` from that render. + +<a id="owner-reply-required"></a> +### `OWNER_REPLY_REQUIRED` + +**Meaning.** An agent answered the gate (`--by agent:<id>`) without recording +what the owner actually said. Nothing was applied. + +**Fix.** Pass the owner's words verbatim: `--owner-reply "<text>"`, +`--owner-reply -` to read them from stdin, or `--owner-reply-file <path>`. They +are stored on every decision row; when they parse under the gate grammar they +must resolve to the same choices as `--reply`. + +<a id="gate-owner-reply-mismatch"></a> +### `GATE_OWNER_REPLY_MISMATCH` + +**Meaning.** The owner's verbatim reply is itself a gate reply (for example +`rec`), and it resolves to different choices than the tokens the agent relayed. +Nothing was applied. + +**Fix.** Relay what the owner chose; passing their reply as `--reply` is always +consistent. When the owner's words and your tokens disagree, ask the owner. + <a id="gstack-version-changed"></a> ### `GSTACK_VERSION_CHANGED` diff --git a/lib/autoplan-export.ts b/lib/autoplan-export.ts index cf876bde8..c88e274c5 100644 --- a/lib/autoplan-export.ts +++ b/lib/autoplan-export.ts @@ -45,7 +45,7 @@ export function aggregateFindings(state: RunState): FindingRow[] { for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null }); for (const row of voiceRows(state, phase, 'outside')) { const r = byOutside.get(row.id); - out.push({ ...row, native_counterpart: r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow); + out.push({ ...row, native_counterpart: r?.disposition === 'unverified' ? null : r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow); } } return out; diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index b46e895ea..ad099e598 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -20,11 +20,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; import { driftLine } from './autoplan-plan-text'; -import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile'; +import { consensusRecord, reconcile, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile'; import type { Phase } from './autoplan-prompts'; -import { FILES, 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 Amendment, type RunState, type VoiceState } from './autoplan-state'; import { phaseClose } from './autoplan-timing'; -import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list'; +import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list'; import type { FindingRow } from './headless-artifacts'; import { readJsonl } from './headless-artifacts'; import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief'; @@ -57,7 +57,7 @@ export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: s const md = renderConsensus(consensus, [...native, ...outside]); fs.writeFileSync(path.join(state.out, `${phase}-consensus.json`), JSON.stringify(consensus, null, 2) + '\n'); fs.writeFileSync(path.join(state.out, `${phase}-consensus.md`), md); - ps.consensus = { confirmed: consensus.confirmed, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage }; + ps.consensus = { confirmed: consensus.confirmed, unverified: consensus.unverified, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage }; ps.closed_at = now(); const closed = phaseClose(path.join(state.out, 'timing.json'), state.run, phase, { outside_s: seconds(ps.outside), native_s: seconds(ps.native), session_kind: state.session_kind }); journalRow(state.out, { event: 'phase_closed', phase, consensus: ps.consensus, wall_s: closed.entry.wall_s, analytics: closed.analytics }); @@ -71,25 +71,31 @@ const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design', 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 }); +const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + /** - * 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. + * A user challenge both voices raised recommends the change only with an + * explicit, coherent shared proposal: the finding reconciled `confirmed`, + * both name the same `changes`, and either the parent's reconcile override + * states one proposal for both or both voices wrote the same remedy. Title + * similarity and file:line proximity only associate the findings; anything + * weaker keeps the plan's direction, and the basis says why. Option b shows + * the remedies verbatim. */ -function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } { +export 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 === 'unverified') return keep('same location, agreement unverified'); 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 }; + if (remedyKey(nf.resolution) !== remedyKey(of.resolution)) return keep('remedies differ; no shared proposal'); + return { recommended: 'b', basis: 'both voices; same remedy', text }; } /** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */ @@ -140,19 +146,54 @@ export function buildGate(state: RunState, gateRev: number): GateList { cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id); } } - const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`); - const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan; - const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`); - items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}), - options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, - { key: 'c', text: 'interrogate (ask questions; stays pending)' }, - { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] }); + items.push(planApproval(state, items)); 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; } +/** The p1 plan-approval item: recommends `b` when any item's recommendation changes the plan, with PLAN_REALITY flags. */ +function planApproval(state: RunState, items: GateItem[]): GateItem { + const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`); + const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan; + const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`); + return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}), + options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, + { key: 'c', text: 'interrogate (ask questions; stays pending)' }, + { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] }; +} + +/** + * `gstack-autoplan recommend`: the relaying parent sets one item's recommendation before relay, with its basis and + * provenance. The plan-approval item and decisions.jsonl follow, and `rec_rev` bumps so a `rec` rendered earlier is + * refused (GATE_REC_STALE) until the gate is rendered and relayed again. + */ +export function recommendGate(state: RunState, gateRev: number, itemId: string, option: string, basis: string, by: string): string[] { + 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; + if (gateRev !== list.gate_rev) throw new RunError('GATE_REV_STALE', `--gate-rev ${gateRev}, current gate_rev=${list.gate_rev}`, 3); + const item = list.items.find(i => i.id === itemId); + if (!item || item.id === 'p1' || !item.options.some(o => o.key === option)) throw new RunError('GATE_REPLY_UNPARSED', `${itemId}${option} names no item option a recommendation can take (p1 follows the items)`); + Object.assign(item, { recommended: option, recommended_basis: basis, recommended_by: by }); + const p1 = planApproval(state, list.items); + list.items = list.items.map(i => (i.id === 'p1' ? p1 : i)); + list.rec_rev = (list.rec_rev ?? 0) + 1; + fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n'); + const updated = new Map([item, p1].map(i => [decisionId(list.run, gateRev, i.id), i])); + writeDecisions(state.out, readDecisions(state.out).map(r => { + const i = updated.get(r.id); + if (!i) return r; + return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: i.recommended } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) }; + })); + const hash = recHash(list); + journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash }); + exportRun(state); + return [`RECOMMENDED: ${itemId}${option} by=${by} basis=${JSON.stringify(basis)} p1=${p1.recommended} rec_hash=${hash}`, + `RELAY: gstack-gate render ${gateFile} --all (a rec rendered before this is refused; answer with --rec-hash ${hash})`]; +} + /** 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; @@ -183,32 +224,61 @@ export function finishGate(state: RunState): GateList { // --------------------------------------------------------------------------- // answer // --------------------------------------------------------------------------- -export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected'; lines: string[] } +export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected' | 'amend_pending'; lines: string[] } +export interface AnswerOptions { + /** The rec_hash of the render the owner saw; required for a `rec` reply once `recommend` changed the gate. */ + recHash?: string; + /** The owner's verbatim words; required for every `--by agent:<id>` answer. */ + ownerReply?: string; +} + +/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */ +export function applyGuidance(a: Pick<Amendment, 'changes' | 'item'>): string { + return a.changes && SETTLEABLE_CHANGES.includes(a.changes) ? `no plan edit needed: revise --settle ${a.item}` : 'edit the plan, then revise'; +} + +/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */ +function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean { + const owner = parseReply(ownerReply, list, gateRev, prior); + if (!owner.valid) return false; + const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort()); + if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) { + throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3); + } + return true; +} /** * 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`). + * `changes_plan` is an accepted plan change: it prints `APPLY:` and becomes a + * pending amendment (status `amend_pending`); nothing reopens here. `revise` + * reopens exactly the phases whose reviewed plan changed, and `revise + * --settle` discharges a packaging/ordering change without an edit. */ -export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string): AnswerResult { +export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string, o: AnswerOptions = {}): 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); if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3); if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3); + if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3); const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; const rows = readDecisions(state.out); const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!])); 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 currentHash = recHash(list); + if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3); + if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3); + const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined; + const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {}; const 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(); - const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy).map(r => { + const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => { const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined; return c ? { ...r, chosen_text: c.text } : r; })); @@ -225,15 +295,10 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ 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<Phase>(); - if (plan.chosen === 'd') state.phase_order.forEach(p => reopenSet.add(p)); - 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'); } - } + const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []); record(); - state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}) }; - journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}) }); + state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance }; + journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance }); 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 }; @@ -246,30 +311,44 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ 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}`); + const item = itemOf(c.id); + const amendment: Amendment = { decision: decisionId(state.run, gateRev, c.id), title: item.title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt, + item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}) }; + (state.amendments ??= []).push(amendment); + journalRow(state.out, { event: 'amendment_pending', decision: amendment.decision, gate_rev: gateRev, ...(item.changes ? { changes: item.changes } : {}) }); + lines.push(`APPLY: ${amendment.decision} ${item.title} -> ${amendment.text}; ${applyGuidance(amendment)}`); } - if (reopenSet.size === 0) { - state.status = 'complete'; - lines.push(...closeDraftDirections(state, answeredAt.slice(0, 10))); + if (reopenSet.size) { + const reopened = state.phase_order.filter(p => reopenSet.has(p)); + reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: revise`); + if (changing.length) state.status = 'amend_pending'; + lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=revise`); + const drift = driftLine(state, 'PLAN_SOURCE_CHANGED'); + if (drift) lines.push(drift); + return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines }; + } + if (changing.length) { + state.status = 'amend_pending'; exportRun(state); - 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 }; + lines.push(`AMEND_PENDING: ${changing.length} accepted change(s) not yet in the plan; nothing reopens until revise`); + return { state, applied: true, pending: [], reopened: [], outcome: 'amend_pending', lines }; } - const reopened = state.phase_order.filter(p => reopenSet.has(p)); - 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 }; + lines.push(...approve(state, answeredAt)); + 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 }; +} + +/** The run is approved: status complete, draft directions closed, files exported. */ +export function approve(state: RunState, at: string): string[] { + state.status = 'complete'; + const lines = closeDraftDirections(state, at.slice(0, 10)); + exportRun(state); + return lines; } /** E3 phase-close operation: every `draft direction stands until the owner decides` line in the active plan becomes the decided option and date. */ -function closeDraftDirections(state: RunState, date: string): string[] { +export function closeDraftDirections(state: RunState, date: string): string[] { const loaded = loadBriefInputs(state.out); if ('error' in loaded) return []; const r = closeDraftDirectionsFile(state.active_plan, loaded.inputs, date); @@ -308,7 +387,8 @@ export function planReopen(state: RunState, phases: Phase[], gateRev: number, re 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]; } + // A sealed copy is attempt-owned and immutable: it stays in place and bound; only a pre-seal output (bound at the result path) is archived. + if (output && !output.endsWith('.bound.md')) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; } archive(`${phase}-${voice}-prompt.md`); } archive(`${phase}-consensus.json`); archive(`${phase}-consensus.md`); diff --git a/lib/autoplan-prompts.ts b/lib/autoplan-prompts.ts index d5f952bf4..3863ced13 100644 --- a/lib/autoplan-prompts.ts +++ b/lib/autoplan-prompts.ts @@ -112,7 +112,7 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c \`\`\`gstack-findings {"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"} \`\`\` - 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": "<who>" 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") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" 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} `; @@ -177,6 +177,15 @@ export function writePrompts(inp: PromptInputs): { native: string; outside: stri return { native, outside, nativeResult, outsideResult }; } +/** An attempt-owned file in the run directory: `<phase>-<voice>.<attempt>.md` (the reviewer writes it) or `.bound.md` (the sealed copy). */ +export const attemptFile = (phase: string, voice: string, shortAttempt: string, ext: 'md' | 'bound.md') => `${phase}-${voice}.${shortAttempt}.${ext}`; + +/** Point a prompt's RESULT FORMAT at the attempt's own result path, so a superseded reviewer never writes where its successor does. */ +export function bindPromptResult(promptFile: string, resultPath: string): void { + const text = fs.readFileSync(promptFile, 'utf8'); + fs.writeFileSync(promptFile, text.replace(/^3\. Write the whole result to this file: .*$/m, `3. Write the whole result to this file: ${resultPath}`)); +} + /** The installed review skill for a phase: this checkout's generated SKILL.md (same layout as ~/.claude/skills/gstack/<skill>/SKILL.md). */ export function reviewSkillFile(phase: Phase): string { const file = path.join(ROOT, REVIEW_SKILL[phase], 'SKILL.md'); diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts index 1deb6a8d4..7dc47302b 100644 --- a/lib/autoplan-reconcile.ts +++ b/lib/autoplan-reconcile.ts @@ -26,10 +26,12 @@ const SEVERITY_ALIASES: Record<string, Severity> = { 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). */ + /** What a user challenge changes: scope, cost or direction (plan B2a), or packaging/ordering (settled without a plan edit). */ changes?: Changes; } -export const CHANGES = ['scope', 'cost', 'direction'] as const; +export const CHANGES = ['scope', 'cost', 'direction', 'packaging', 'ordering'] as const; +/** Accepted changes of these kinds need no Implementation-plan edit: `revise --settle` records them. */ +export const SETTLEABLE_CHANGES: readonly string[] = ['packaging', 'ordering']; export type Changes = typeof CHANGES[number]; export interface ParsedReviewResult { receipt: { ok: boolean; phase?: string; sha256?: string; line?: string }; @@ -186,7 +188,8 @@ export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string; } as FindingRow)); } -export type ReconciliationDisposition = 'confirmed' | 'disagree' | 'new'; +/** `unverified`: the same file:line with no other shared evidence (titles unalike): associated, agreement unverified. */ +export type ReconciliationDisposition = 'confirmed' | 'unverified' | 'disagree' | 'new'; export interface ReconciliationRow { outside_id: string; native_id: string | null; disposition: ReconciliationDisposition; /** Why this disposition: the matched location or title, or the parent's override reason. */ @@ -208,11 +211,14 @@ function jaccard(a: Set<string>, b: Set<string>): number { const level = (s: Severity) => SEVERITIES.indexOf(s); /** - * Structural reconciliation: a file:line match (±3 lines) is `confirmed` - * unless severities differ by two or more levels (`disagree`); otherwise a - * title overlap of at least half the words is `confirmed`; else `new`. Each - * native finding is matched at most once. Overrides replace the structural - * row for their outside id and must name ids from these two lists. + * Structural reconciliation. Findings associate by a title overlap of at least + * half the words, or by file:line (±3 lines). A title association is + * `confirmed` unless severities differ by two or more levels (`disagree`); a + * location-only association is `unverified` (same location, agreement + * unverified: two findings near one line are not one finding); else `new`. + * Each native finding is matched at most once. Overrides (the parent's + * semantic reconciliation, with an optional shared `proposal`) replace the + * structural row for their outside id and must name ids from these two lists. */ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides: ReconciliationOverride[] = []): { rows: ReconciliationRow[]; native_only: string[]; errors: string[] } { const errors: string[] = []; @@ -222,7 +228,7 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides for (const o of overrides) { if (!outsideIds.has(o.outside_id)) { errors.push(`override names unknown outside id ${o.outside_id}`); continue; } if (o.native_id && !nativeIds.has(o.native_id)) { errors.push(`override for ${o.outside_id} names unknown native id ${o.native_id}`); continue; } - if (!['confirmed', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|disagree|new`); continue; } + if (!['confirmed', 'unverified', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|unverified|disagree|new`); continue; } if (o.disposition !== 'new' && !o.native_id) { errors.push(`override for ${o.outside_id}: ${o.disposition} needs a native_id`); continue; } if (typeof o.resolution !== 'string' || !o.resolution.trim()) { errors.push(`override for ${o.outside_id}: resolution is required`); continue; } byOutside.set(o.outside_id, o); @@ -238,28 +244,29 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides ...(typeof override.proposal === 'string' && override.proposal.trim() ? { proposal: override.proposal.trim() } : {}) }); continue; } + const near = (n: FindingRow) => !!o.file && o.line !== undefined && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line) <= 3; + const ot = tokens(o.title); let match: FindingRow | undefined; - let basis: ReconciliationRow['basis'] = 'none'; - if (o.file && o.line !== undefined) { - match = native.find(n => !used.has(n.id) && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line!) <= 3); - if (match) basis = 'file_line'; - } - if (!match) { - const ot = tokens(o.title); - let best = 0; - for (const n of native) { - if (used.has(n.id)) continue; - const score = jaccard(ot, tokens(n.title)); - if (score >= 0.5 && score > best) { best = score; match = n; } - } - if (match) basis = 'title'; + let best = 0; + for (const n of native) { + if (used.has(n.id)) continue; + const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0); + if (score >= 0.5 && score > best) { best = score; match = n; } } + const titled = !!match; + match ??= native.find(n => !used.has(n.id) && near(n)); if (!match) { rows.push({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } }); continue; } used.add(match.id); + const basis: ReconciliationRow['basis'] = near(match) ? 'file_line' : 'title'; + const where = near(match) ? `${o.file}:${o.line}` : 'same title'; + if (!titled) { + rows.push({ outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same location, agreement unverified (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity } }); + continue; + } const delta = Math.abs(level(match.severity) - level(o.severity)); rows.push({ outside_id: o.id, native_id: match.id, disposition: delta >= 2 ? 'disagree' : 'confirmed', - resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'})` : `both voices (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'}): ${match.title}`, + resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${where})` : `both voices (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity }, }); } @@ -271,6 +278,8 @@ export interface ConsensusRecord { native: { model: string | null; findings: number; by_severity: Record<Severity, number> } | null; outside: { model: string | null; provider: string | null; findings: number; by_severity: Record<Severity, number> } | null; confirmed: number; disagree: number; new: number; native_only: number; + /** Same location, agreement unverified (absent on records written before it existed). */ + unverified?: number; rows: ReconciliationRow[]; native_only_ids: string[]; /** CONFIRMED requires both voices completed. */ coverage: 'both' | 'native_only' | 'outside_only' | 'none'; @@ -288,7 +297,7 @@ export function consensusRecord(phase: string, native: { rows: FindingRow[]; mod schema_version: 1, phase, native: native ? { model: native.model ?? null, findings: native.rows.length, by_severity: bySeverity(native.rows) } : null, outside: outside ? { model: outside.model ?? null, provider: outside.provider ?? null, findings: outside.rows.length, by_severity: bySeverity(outside.rows) } : null, - confirmed: rows.filter(r => r.disposition === 'confirmed').length, disagree: rows.filter(r => r.disposition === 'disagree').length, + confirmed: rows.filter(r => r.disposition === 'confirmed').length, unverified: rows.filter(r => r.disposition === 'unverified').length, disagree: rows.filter(r => r.disposition === 'disagree').length, new: rows.filter(r => r.disposition === 'new').length, native_only: native && outside ? rec.native_only.length : (native?.rows.length ?? 0), rows, native_only_ids: native && outside ? rec.native_only : (native?.rows.map(r => r.id) ?? []), coverage: native && outside ? 'both' : native ? 'native_only' : outside ? 'outside_only' : 'none', @@ -306,7 +315,7 @@ export function renderConsensus(c: ConsensusRecord, findings: FindingRow[]): str ...SEVERITIES.map(s => ` ${s.padEnd(15)} ${String(c.native?.by_severity[s] ?? '—').padEnd(28)} ${c.outside?.by_severity[s] ?? '—'}`), ` Findings ${String(c.native?.findings ?? '—').padEnd(28)} ${c.outside?.findings ?? '—'}`, c.coverage === 'both' - ? `CONFIRMED ${c.confirmed} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.` + ? `CONFIRMED ${c.confirmed} · SAME LOCATION, UNVERIFIED ${c.unverified ?? 0} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.` : `Consensus: N/A (voice coverage: ${c.coverage.replace('_', ' ')}); confirmed counts require both voices.`, '', 'Reconciliation (each outside finding against the native result):', diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index f3452b52f..785a2601c 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -16,13 +16,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; -import { applyRenames, planReopen } from './autoplan-gate'; +import { applyRenames, approve, closeDraftDirections, planReopen } from './autoplan-gate'; +import { SETTLEABLE_CHANGES } from './autoplan-reconcile'; import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text'; import { parseScopeTokens } from './autoplan-scope'; 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 ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; settle?: 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'}`; @@ -41,37 +42,74 @@ function revisionText(state: RunState, file: string): string { return diag.text ?? text; } -function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> { +function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn' | 'settled', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> { 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 }; + return { event: `amendment_${status}`, decision: id, ...extra }; }); } +/** A `--settle`/`--incorporates`/`--withdraw` id: the decision id, or the short item id (`d4`) at the current gate_rev. */ +function resolveIds(state: RunState, ids: string[]): string[] { + return ids.map(id => pendingAmendments(state).find(a => a.decision === id || (a.item === id && a.gate_rev === state.gate_rev))?.decision ?? id); +} + +/** + * `--settle` evidence: a packaging/ordering change on a plan whose reviewed Implementation plan is unchanged (the + * active plan and the owner's plan file both hash to the snapshot the phases reviewed). A real change never settles. + */ +function settleEvidence(state: RunState, ids: string[]): { plan_sha256: string; changes: Record<string, string> } { + const reviewed = state.phase_order.map(p => state.phases[p]?.snapshot).find(Boolean); + const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); + let input = active; + try { if (state.plan_input && fs.existsSync(state.plan_input)) input = reviewHash(revisionText(state, state.plan_input)); } catch { input = ''; } + const changes: Record<string, string> = {}; + for (const id of ids) { + const a = pendingAmendments(state).find(x => x.decision === id)!; + if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3); + changes[id] = a.changes; + } + if (!reviewed || active !== reviewed || input !== reviewed) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the reviewed plan changed (reviewed ${String(reviewed).slice(0, 12)}, active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan instead`, 3); + return { plan_sha256: reviewed, changes }; +} + +/** Every accepted change is discharged and nothing reopened: the answered gate stands and the run is complete. */ +function finishAmendments(state: RunState, at: string): string[] { + if (state.status !== 'amend_pending' || pendingAmendments(state).length) return []; + journal(state.out, { event: 'amendments_settled', gate_rev: state.gate_rev ?? null }); + return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`]; +} + 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 amending = state.status === 'amend_pending'; const pending = pendingAmendments(state).map(a => a.decision); - for (const id of [...(o.incorporates ?? []), ...(o.withdraw ?? [])]) { + const incorporates = resolveIds(state, o.incorporates ?? []), withdraw = resolveIds(state, o.withdraw ?? []), settle = resolveIds(state, o.settle ?? []); + for (const id of [...incorporates, ...withdraw, ...settle]) { if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3); } + const evidence = settle.length ? settleEvidence(state, settle) : undefined; const at = now(); if (!o.plan) { - for (const row of settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })) journal(out, row); + for (const row of settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' })) journal(out, row); + for (const row of settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 })) journal(out, { ...row, changes: evidence!.changes[row.decision as string] }); + const lines = [...settle.map(id => `SETTLED: ${id} (${evidence!.changes[id]}; plan ${evidence!.plan_sha256.slice(0, 12)} unchanged; nothing reopens)`), amendmentLine(state), ...finishAmendments(state, at)]; writeState(state); - return { state, reopened: [], lines: [amendmentLine(state)] }; + return { state, reopened: [], lines }; } 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); + if (incorporates.length && newHash === oldHash) { + throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${incorporates.join(', ')} needs the edit in ${file}`, 3); } + if (settle.length && newHash !== oldHash) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}); incorporate ${settle.join(', ')} instead of settling`, 3); const k = (state.plan_revision ?? 0) + 1; const staging = path.join(out, `.revise-v${k}`); fs.rmSync(staging, { recursive: true, force: true }); @@ -87,8 +125,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult { 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 ?? [] }); + journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : []; for (const p of added) state.phases[p] = {}; @@ -97,11 +134,19 @@ export function reviseRun(o: ReviseOptions): ReviseResult { 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 settled = [...settleAmendments(state, incorporates, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' }), + ...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] }))]; + if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : 'complete'; 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)] }; + const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)]; + if (state.status === 'complete') { + lines.push(...closeDraftDirections(state, at.slice(0, 10)), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`); + exportRun(state); + writeState(state); + } + return { state, reopened: reopen, lines }; } /** The commit half of the transaction; recoverRevise replays it from the stage. Every step is idempotent. */ diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 41f24d371..1f06ebd9c 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -19,10 +19,10 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile'; -import { closePhase, finishGate } from './autoplan-gate'; +import { applyGuidance, closePhase, finishGate } from './autoplan-gate'; import { GSTACK_VERSION } from './autoplan-migrate'; import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text'; -import { PHASES, SNAPSHOT_TOOL, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts'; +import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts'; 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 { checkPlanReality, checkRealityRows, PLAN_REALITY_CHECKER } from './plan-reality'; @@ -178,7 +178,8 @@ export function nextStep(o: NextOptions): NextResult { 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 <id>)`, 3); + const each = pendingAmendments(state).map(a => `APPLY ${a.item ?? a.decision}: ${applyGuidance(a)}`).join('; '); + throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --settle <id> for a packaging/ordering change, --withdraw <id>)`, 3); } const phase = currentPhase(state); if (!phase) { finishGate(state); writeState(state); return result; } @@ -210,7 +211,8 @@ export function nextStep(o: NextOptions): NextResult { const estimate = o.estimateUsd ?? 0; try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); } catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; } - const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: prompts[voice === 'native' ? 'nativeResult' : 'outsideResult'], estimate_usd: estimate, dispatched_at: now() }; + const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate, dispatched_at: now() }; + bindPromptResult(row.prompt, row.result); journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate }); (v.attempts ??= []).push(row); v.attempt = attempt; v.status = 'running'; @@ -292,6 +294,10 @@ export function submitResult(o: SubmitOptions): SubmitResult { } const open = resolveAttempt(v, o.attempt, phase, o.voice); const file = (o.record ?? o.result)!; + const owner = new RegExp(`^${phase}-${o.voice}\\.(a\\d+)\\.md$`).exec(path.basename(file)); + if (owner && path.dirname(path.resolve(file)) === state.out && `${state.run}-${owner[1]}` !== open.attempt) { + throw new RunError('ATTEMPT_MISMATCH', `${path.basename(file)} is the result path of ${state.run}-${owner[1]}, not of the open attempt ${open.attempt} (result=${open.result})`, 3); + } if (!fs.existsSync(file)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${file}`); const text = fs.readFileSync(file, 'utf8'); let bind: ReturnType<typeof readRecord>; @@ -309,11 +315,13 @@ export function submitResult(o: SubmitOptions): SubmitResult { 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. - const bound = open.result; + // Bind: a sealed copy of the reviewer's bytes under an attempt-owned name the reviewer never writes, per-voice + // canonical rows, journal, ledger, manifest. Nothing above this line wrote; a later edit of `file` changes nothing bound. + const bound = path.join(state.out, attemptFile(phase, o.voice, open.attempt.slice(state.run.length + 1), 'bound.md')); const tmp = `${bound}.${process.pid}.tmp`; - fs.writeFileSync(tmp, text); + fs.writeFileSync(tmp, text, process.platform === 'win32' ? {} : { mode: 0o444 }); fs.renameSync(tmp, bound); + if (process.env.GSTACK_AUTOPLAN_FAULT === 'submit-after-seal') throw new Error('fault injected: submit interrupted after sealing (GSTACK_AUTOPLAN_FAULT, tests only)'); const planFiles = [ps.snapshot_path, ps.snapshot_path && path.join(path.dirname(ps.snapshot_path), 'source-implementation.md'), state.active_plan].filter((p): p is string => !!p); const pathCtx = { cwd: o.cwd, planFiles, planSource: state.plan_source, planName: path.basename(state.plan_input ?? state.plan_source) }; const rows = canonicalRows(bind.findings.map(f => { const { file, line, ...rest } = f; return { ...rest, ...normalizeFindingFile({ file, line }, pathCtx) }; }), { run: state.run, phase, voice: o.voice, model }); @@ -340,7 +348,7 @@ export function submitResult(o: SubmitOptions): SubmitResult { function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] { const close = closePhase(state, phase, { overridesFile: reconcile }); if (!currentPhase(state)) finishGate(state); else state.status = 'running'; - return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`]; + return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`]; } /** @@ -419,7 +427,7 @@ export function resumeRun(out: string): ResumeResult { if (!ps?.snapshot || ps.closed_at) continue; if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {}); } - if (!currentPhase(state) && !['gate_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state); + if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state); else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') { const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v)))); state.status = anyOpen ? 'awaiting_result' : 'running'; @@ -440,6 +448,7 @@ export function statusLines(state: RunState): string[] { const v = (voice: Voice) => { const s = ps[voice]; return `${voice}=${s?.status ?? 'pending'}${s?.model ? `:${s.model}` : ''}${s?.attempt ? `@${s.attempt}` : ''}`; }; lines.push(`PHASE: ${p} ${ps.closed_at ? 'closed' : ps.snapshot ? 'open' : 'pending'} ${v('native')} ${v('outside')}${ps.consensus ? ` confirmed=${(ps.consensus as any).confirmed} disagree=${(ps.consensus as any).disagree} new=${(ps.consensus as any).new}` : ''}`); } + for (const a of pendingAmendments(state)) lines.push(`AMEND_PENDING: ${a.decision} ${a.item ?? ''}${a.changes ? ` changes=${a.changes}` : ''} -> ${applyGuidance(a)}`); for (const p of state.phase_order) for (const voice of ['native', 'outside'] as const) { const open = state.phases[p]?.[voice] && openAttempt(state.phases[p]![voice]!); if (open) lines.push(`PENDING: ${open.attempt} phase=${p} voice=${voice} result=${open.result}`); @@ -447,6 +456,6 @@ export function statusLines(state: RunState): string[] { if (fs.existsSync(path.join(state.out, FILES.spend))) lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend))))); return lines; } -export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'incomplete', 'refused', 'interrupted']; +export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted']; export { SNAPSHOT_TOOL, RunError, FILES, readState, writeState, readJournal, journal }; export type { RunState, Voice, VoiceState, AttemptState }; diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts index 0085bc8fb..c71d51e36 100644 --- a/lib/autoplan-state.ts +++ b/lib/autoplan-state.ts @@ -20,7 +20,11 @@ export interface VoiceState extends VoiceOutcome { receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string; } /** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */ -export interface Amendment { decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn'; at: string; settled_at?: string } +export interface Amendment { + decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn' | 'settled'; at: string; settled_at?: string; + /** The gate item's `changes` and phase: packaging/ordering changes settle without a plan edit (`revise --settle`). */ + changes?: string; phase?: string; item?: string; +} export interface PhaseState { snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string; native?: VoiceState; outside?: VoiceState; consensus?: Record<string, unknown>; skipped?: string; diff --git a/lib/gate-list.ts b/lib/gate-list.ts index dc4a9c48e..680fbde5c 100644 --- a/lib/gate-list.ts +++ b/lib/gate-list.ts @@ -3,18 +3,21 @@ * A gate list is numbered items, each with a stable id, a recommended default * and lettered options; `auto` items take their recommendation when * unanswered, `approval` items stay pending until answered. One reply - * grammar: `all` (every item takes its recommendation), `<id><option>` tokens - * (`d3b uc1a`), or `all except <tokens>` (the same operation as listing the - * overrides, so there is no exclusion-vs-override ambiguity). Bare yes/no is - * ambiguous across multi-option items and is reported as unparsed, never - * guessed. A reply binds to the gate revision it answers (`gate_rev`). - * `all as-is` keeps every direction: each item takes its option that does not - * change the plan (`changes_plan: false`), so agreeing user challenges whose - * recommendation is the change can be declined in one token. Earlier answers - * at the same revision (`prior`) are kept unless a token names the item again: - * only unresolved items default. + * grammar: `rec` (every item takes its recommendation, plan changes + * included), `<id><option>` tokens (`d3b uc1a`), or `rec except <tokens>` + * (the same operation as listing the overrides). `all`, `approve all`, + * `accept all` and `take all recommendations` are silent aliases of `rec`; + * `as-is` (alias `all as-is`) keeps every direction: each item takes its + * option that does not change the plan (`changes_plan: false`). Bare yes/no + * is ambiguous across multi-option items and is reported as unparsed, never + * guessed. A reply binds to the gate revision it answers (`gate_rev`), and a + * `rec` reply to the recommendations it saw (`rec_hash`, bumped by + * `gstack-autoplan recommend`). Earlier answers at the same revision + * (`prior`) are kept unless a token names the item again: only unresolved + * items default. * Rendered by bin/gstack-gate; rows land in decisions.jsonl (lib/headless-artifacts.ts). */ +import { createHash } from 'node:crypto'; import type { DecisionRow } from './headless-artifacts'; /** `changes_plan`: choosing this option is an accepted plan change (reopens its phase plus Eng, prints APPLY:). */ @@ -24,8 +27,10 @@ export interface GateItem { phase?: string; cost?: string; why?: string; /** false: an agent decision; no option reopens a phase. */ reopens?: boolean; - /** Why `recommended` (e.g. `both voices; remedies shown`, `plan direction; voices disagree`). */ + /** Why `recommended` (e.g. `both voices; shared proposal`, `plan direction; voices disagree`). */ recommended_basis?: string; changes?: string; + /** Set when the relaying parent chose this recommendation (`gstack-autoplan recommend`); shown in the render. */ + recommended_by?: string; /** Stable carry-forward identity (phase, finding ids, phase close, snapshot, option text). */ origin?: string; /** Advisory lines rendered under the item (PLAN_REALITY: a plan term with no number, arm list or rule body). */ @@ -33,15 +38,21 @@ export interface GateItem { /** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */ carried?: { from: string; chosen: string; gate_rev: number }; } -export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string } +export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string; + /** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */ + rec_rev?: number } export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean } export interface ParsedReply { valid: boolean; gate_rev: number; reply: string; choices: GateChoice[]; pending: string[]; unparsed: string[]; + /** `rec`: the reply took the recommendations (any alias, with or without `except`). */ + mode?: 'rec' | 'as-is' | 'tokens'; error?: 'GATE_REV_STALE' | 'GATE_REPLY_UNPARSED' | 'GATE_EMPTY'; } export const PAGE_SIZE = 7; +/** Reply openers that take every recommendation: `rec` and its silent aliases. */ +const REC_ALIASES: string[][] = [['rec'], ['all'], ['approve', 'all'], ['accept', 'all'], ['take', 'all', 'recommendations']]; const ID_RE = /^[a-z]*[0-9]+$/; export function validateGateList(list: unknown): string[] { @@ -70,25 +81,17 @@ export function validateGateList(list: unknown): string[] { return problems; } -/** - * An approval item whose recommended option changes the plan (both voices - * recommend the change) is answered only by its own token: `all` must never - * flip the owner's stated direction on their behalf (field gate rev 1, d3). - */ -export function heldFromAll(item: GateItem): boolean { - return item.kind === 'approval' && item.options.some(o => o.key === item.recommended && o.changes_plan === true); +/** The recommendations a `rec` reply takes, as a short hash the render prints and `answer --rec-hash` checks. */ +export function recHash(list: GateList): string { + const recs = list.items.map(i => [i.id, i.recommended, i.recommended_basis ?? '', i.recommended_by ?? '']); + return createHash('sha256').update(JSON.stringify([list.gate_rev, list.rec_rev ?? 0, recs])).digest('hex').slice(0, 12); } -/** The `all` resolution: every item takes its recommendation, except the ones `heldFromAll` leaves to the owner; shown under the list so the reply shape is explicit. */ -export function resolveAll(list: GateList): Record<string, string> { - return Object.fromEntries(list.items.filter(i => !heldFromAll(i)).map(i => [i.id, i.recommended])); -} - -/** The `GATE_PENDING` hint: exact tokens per pending item, and the `all` rule when any pending item is held from it. */ +/** The `GATE_PENDING` hint: exact tokens per pending item, or `rec`. */ export function pendingHint(list: GateList, pending: string[]): string { const items = pending.map(id => list.items.find(i => i.id === id)).filter((i): i is GateItem => !!i); const tokens = items.map(i => i.options.map(o => `${i.id}${o.key}`).join('|')).join(' '); - return `reply with ${tokens || 'p1a..p1e'}${items.some(heldFromAll) ? '; all does not answer items recommended b' : ' or all'}`; + return `reply with ${tokens || 'p1a..p1e'} or rec`; } export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): string { @@ -100,7 +103,7 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri lines.push(''); list.items.slice(start, start + pageSize).forEach((item, i) => { const n = start + i + 1; - lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); + lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); if (item.why) lines.push(` ${item.why}`); if (item.carried) lines.push(` decided at gate_rev ${item.carried.gate_rev}: ${item.id}${item.carried.chosen} (carried forward; name it again to change it)`); for (const o of item.options) lines.push(` ${item.id}${o.key}) ${o.text}${o.key === item.recommended ? ' (recommended)' : ''}`); @@ -109,10 +112,10 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri }); lines.push(''); if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`, or every page with \`--all\`. Relay every page.`); - lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).'); + lines.push('Reply with exactly one of: `rec` (default: take every recommendation, plan changes included) · `rec except <tokens>` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `as-is` (the plan as written).'); lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.'); - for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`); - lines.push(`Reply binds to gate_rev=${list.gate_rev}. \`all\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, choices: resolveAll(list) })}`); + const rec = parseReply('rec', list); + lines.push(`Reply binds to gate_rev=${list.gate_rev} rec_hash=${recHash(list)}. \`rec\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, rec_hash: recHash(list), choices: Object.fromEntries(rec.choices.map(c => [c.id, c.chosen])) })}`); return lines.join('\n') + '\n'; } @@ -122,7 +125,7 @@ function exampleTokens(list: GateList): string { /** Tokenize a reply: lowercase, strip separators, merge `<id> <option>` pairs into one token. */ export function tokenize(reply: string, list: GateList): string[] { - const raw = reply.toLowerCase().replace(/[,;:=)]/g, ' ').split(/\s+/).filter(Boolean); + const raw = reply.toLowerCase().replace(/[,;:=)`"'\u2018\u2019\u201c\u201d]/g, ' ').split(/\s+/).filter(Boolean); const ids = new Set(list.items.map(i => i.id)); const out: string[] = []; for (let i = 0; i < raw.length; i++) { @@ -145,13 +148,14 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio const tokens = tokenize(reply, list); if (tokens.length === 0) return { ...base, error: 'GATE_EMPTY' }; - let all = false; + let rec = false; let asIs = false; let rest = tokens; - if (tokens[0] === 'all') { - all = true; - rest = tokens.slice(1); - if (rest[0] === 'as-is') { asIs = true; rest = rest.slice(1); } + const lead = REC_ALIASES.find(a => a.every((w, i) => tokens[i] === w)); + if (lead && tokens[lead.length] === 'as-is' && lead.length === 1 && lead[0] === 'all') { asIs = true; rest = tokens.slice(2); } + else if (lead) { rec = true; rest = tokens.slice(lead.length); } + else if (tokens[0] === 'as-is') { asIs = true; rest = tokens.slice(1); } + if (rec || asIs) { if (rest[0] === 'except') rest = rest.slice(1); else if (rest.length > 0) return { ...base, unparsed: rest, error: 'GATE_REPLY_UNPARSED' }; } @@ -176,11 +180,11 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio if (chosen !== undefined) choices.push({ id: item.id, chosen, kind: item.kind, how: how(item, chosen) }); else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true }); else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); } - else if (all && !heldFromAll(item)) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' }); + else if (rec) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' }); else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' }); else pending.push(item.id); } - return { ...base, valid: true, choices, pending }; + return { ...base, valid: true, choices, pending, mode: rec ? 'rec' : asIs ? 'as-is' : 'tokens' }; } /** The option that keeps the plan's direction: the first with `changes_plan: false`; plan approval `a`; else the recommendation. */ @@ -208,14 +212,14 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}): /** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */ export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/; -export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string): DecisionRow[] { +export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string, extra: Partial<DecisionRow> = {}): DecisionRow[] { if (!parsed.valid) return rows; const byId = new Map(parsed.choices.map(c => [c.id, c])); return rows.map(row => { const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, '')); if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row; return { - ...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), + ...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), ...extra, status: choice.how === 'override' ? 'overridden' : 'approved', }; }); diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 846cddaad..90ac727ee 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -23,7 +23,7 @@ export type DecisionKind = 'auto' | 'approval' | 'user_challenge'; export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected'; export type TaskStatus = 'pending' | 'ready' | 'blocked_on_dependency' | 'blocked_on_decision' | 'in_progress' | 'done'; export type VoiceStatus = 'completed' | 'unavailable' | 'disabled' | 'skipped' | 'failed' | 'pending' | 'running'; -export type ResultStatus = 'complete' | 'gate_pending' | 'incomplete' | 'interrupted' | 'refused'; +export type ResultStatus = 'complete' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused'; export type RunStatus = ResultStatus | 'awaiting_result' | 'running'; export interface FindingRow { @@ -39,6 +39,8 @@ export interface DecisionRow { answered_by?: string; source?: string; cost?: string; /** Why the recommendation (gate items built by gstack-autoplan), what a user challenge changes, and the carry-forward identity. */ recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string; + /** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */ + owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string; } export interface TaskRow { schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[]; @@ -84,8 +86,8 @@ export interface ShipReceipt { issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> }; } -export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused']; -const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'refused', 'incomplete']; +export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; +const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'amend_pending', 'refused', 'incomplete']; const TERMINAL_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed']; const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed']; @@ -131,7 +133,7 @@ export const ARTIFACT_SCHEMAS = { voice: { enum: ['native', 'outside'] }, model: str, severity: { enum: ['Critical', 'High', 'Medium', 'Low', 'Informational'] }, title: str, file: str, line: { type: 'integer' }, disposition: { enum: ['open', 'accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'] }, resolution: str, plan_items: strList, native_counterpart: nullableStr, source: str, - changes: { enum: ['scope', 'cost', 'direction'] }, + changes: { enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] }, }, }, decisions: { @@ -169,7 +171,7 @@ export const ARTIFACT_SCHEMAS = { required: ['schema_version', 'run', 'status', 'required_phases', 'phases', 'artifacts'], properties: { schema_version: { const: 1 }, run: str, - status: { enum: ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] }, + status: { enum: ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] }, base: str, host: str, session_kind: str, required_phases: strList, phases: { type: 'object', description: 'phase id → { snapshot, native, outside }', properties: {} }, counts: { type: 'object', description: 'decisions, decisions_pending, findings, tasks: each must equal the rows in its file' }, diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 7a600bc97..3877c4b97 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -509,13 +509,33 @@ export const RESULT_CODES = { PLAN_AMEND_PENDING: { anchor: 'plan-amend-pending', summary: 'the owner accepted plan changes at the gate that the plan has not absorbed yet', - fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`', + fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`', }, AMENDMENT_UNKNOWN: { anchor: 'amendment-unknown', summary: '`revise --incorporates` or `--withdraw` named a decision that is not a pending amendment (never accepted as a plan change, or already incorporated or withdrawn)', fix: 'name only the ids the refusal lists after `pending:`; with none pending, drop --incorporates and run `gstack-autoplan revise --out <dir> --plan <file>`', }, + AMENDMENT_NOT_SETTLEABLE: { + anchor: 'amendment-not-settleable', + summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the reviewed plan changed since the gate', + fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan', + }, + GATE_REC_STALE: { + anchor: 'gate-rec-stale', + summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes', + fix: 'render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`', + }, + OWNER_REPLY_REQUIRED: { + anchor: 'owner-reply-required', + summary: 'an agent answered the gate (`--by agent:<id>`) without the owner\'s verbatim reply', + fix: 'pass the owner\'s words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`', + }, + GATE_OWNER_REPLY_MISMATCH: { + anchor: 'gate-owner-reply-mismatch', + summary: 'the owner\'s verbatim reply parses under the gate grammar but resolves to different choices than the relayed tokens', + fix: 'relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree', + }, GSTACK_VERSION_CHANGED: { anchor: 'gstack-version-changed', summary: 'the run directory was written by a different gstack version', diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index 8266ea5b1..973080e73 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -22,24 +22,26 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' }, { cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>', does: 'bind a canonical result (the RESULT FORMAT below)' }, { cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' }, - { cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"', does: 'answer the gate with the reply grammar below' }, + { cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' }, + { cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' }, { cmd: 'gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' }, + { cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4>', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' }, { cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' }, { cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' }, - { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, + { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, ]; /** The reply grammar `gstack-gate` parses, one form per line. */ export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [ - { reply: 'all', means: 'every item takes its recommendation (an agreed change prints APPLY: and needs revise)' }, - { reply: 'all as-is', means: 'keep every direction; no plan change' }, + { reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias' }, + { reply: 'as-is', means: 'keep every direction; no plan change' }, { reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default' }, - { reply: 'all except d3b', means: 'recommendations, with the listed overrides' }, + { reply: 'rec except d3b', means: 'recommendations, with the listed overrides' }, ]; /** Result codes the loop can print, in the order a run meets them. */ export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH', - 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'PLAN_AMEND_PENDING', 'GSTACK_VERSION_CHANGED'] as const; + 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'GSTACK_VERSION_CHANGED'] as const; /** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */ const PENDING_FIX: Record<string, string> = { PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither', diff --git a/test/autoplan-answer-invalidation.test.ts b/test/autoplan-answer-invalidation.test.ts index 0b14209a7..9fd46a63d 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, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture'; +import { attemptsOf, autoplan, journal, readRun, resultOf, 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 })); @@ -31,8 +31,8 @@ function gatedRun(name: string): string { review(path.join(out, 'ceo-native-prompt.md')); review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: disagree }); const ids = attemptsOf(out, 'ceo'); - expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0); - const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'gpt-5.5', '--attempt', ids.outside]); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0); + const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'gpt-5.5', '--attempt', ids.outside]); expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=1 disagree=1 new=1/); const eng = runPhase(out, 'eng'); expect(eng.outside.stdout).toContain('status=gate_pending'); @@ -112,26 +112,31 @@ describe('answer', () => { expect(readRun(out).attempt_counter).toBe(counter); }); - test('a non-recommended choice on a ceo item reopens ceo and eng only, archives their bound files, and the next gate is gate_rev 2', () => { + test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => { const out = gatedRun('reopen'); const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']); expect(r.status, r.stderr).toBe(0); const runId = readRun(out).run; - expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] }); + expect(JSON.parse(r.stdout)).toMatchObject({ status: 'amend_pending', outcome: 'amend_pending', reopened: [], amendments: [`${runId}-d1`] }); + expect(readRun(out).phases.ceo.closed_at).toBeTruthy(); + expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]); + // the accepted change is applied to the plan and named; revise reopens the phases that reviewed the old plan, once + const revised = incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.'); + expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m); const run = readRun(out); expect(run.phases.ceo).toEqual({}); expect(run.phases.eng).toEqual({}); expect(run.phases.design.skipped).toBeTruthy(); expect(run.history.map((h: any) => [h.phase, h.gate_rev])).toEqual([['ceo', 1], ['eng', 1]]); - for (const f of ['ceo-native.r1.md', 'ceo-outside.r1.md', 'ceo-consensus.r1.json', 'eng-native.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true); - for (const f of ['ceo-native.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false); - expect(Object.keys(run.artifacts)).not.toContain('ceo-native.md'); + expect(run.status).toBe('running'); + for (const f of ['ceo-native-prompt.r1.md', 'ceo-outside-prompt.r1.md', 'ceo-consensus.r1.json', 'eng-native-prompt.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true); + for (const f of ['ceo-native-prompt.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false); + // sealed copies are attempt-owned: they stay in place and bound across the reopen + const sealed = run.history.flatMap((h: any) => ['native', 'outside'].map(v => h.previous[v].output)); + expect(sealed.every((o: string) => /\.a\d+\.bound\.md$/.test(o) && fs.existsSync(path.join(out, o)) && run.artifacts[o])).toBe(true); 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); @@ -160,16 +165,16 @@ describe('answer', () => { expect(valid.status).toBe(0); }); - test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => { + test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => { const out = gatedRun('eng-only'); const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']); const runId = readRun(out).run; - expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] }); + expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], 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 + // the revised plan changes the hash both phases reviewed, so revise (not the answer) reruns ceo, then eng, on v2 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(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m); + expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['ceo', expect.stringContaining('revise')], ['eng', 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'); diff --git a/test/autoplan-capy-e2e.test.ts b/test/autoplan-capy-e2e.test.ts index 0c174d1f6..f559646b8 100644 --- a/test/autoplan-capy-e2e.test.ts +++ b/test/autoplan-capy-e2e.test.ts @@ -12,7 +12,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; -import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan, type CapyRunner } from './helpers/autoplan-capy'; const r = capyRunner(); @@ -138,14 +138,14 @@ describe('conformance: one run through every mechanism', () => { 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'])); + ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(s.out, 'ceo', 'native'), '--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'])); + ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(s.out, 'eng', 'native'), '--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 @@ -166,7 +166,7 @@ describe('conformance: one run through every mechanism', () => { 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'])); + for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', resultOf(s.out, 'ceo', voice as 'native' | 'outside'), '--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', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d'])); @@ -181,6 +181,6 @@ describe('conformance: one run through every mechanism', () => { 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); + expect(fs.readFileSync(boundOf(s.out, 'ceo', 'native'), 'utf8')).toContain(final.phases.eng.snapshot); }); }); diff --git a/test/autoplan-driver.test.ts b/test/autoplan-driver.test.ts index 6131fd7b0..40adbad48 100644 --- a/test/autoplan-driver.test.ts +++ b/test/autoplan-driver.test.ts @@ -42,7 +42,7 @@ describe('autoplan/DRIVER.md content comes from the code', () => { test('the plan-specified commands and the amendment interlock are present', () => { for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed', - 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`all as-is`', 'PLAN_AMEND_PENDING', 'APPLY:']) { + 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', 'PLAN_AMEND_PENDING', 'APPLY:']) { expect(doc).toContain(token); } }); diff --git a/test/autoplan-gate-judgment.test.ts b/test/autoplan-gate-judgment.test.ts index 0baf3ba19..9c147f7ea 100644 --- a/test/autoplan-gate-judgment.test.ts +++ b/test/autoplan-gate-judgment.test.ts @@ -1,24 +1,23 @@ /** - * 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; `all` leaves an item - * recommended `b` pending (the owner's own token changes their direction; - * field gate rev 1, d3) and the GATE_PENDING line names the tokens. 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. + * Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments and + * next-wave B1/B2/B5): findings carry `changes`; a user challenge both voices + * raised on a confirmed finding with the same `changes` recommends `b` only + * with one shared proposal (the parent's override, or the same remedy from + * both), rendering both remedies verbatim and the basis; anything weaker keeps + * `a` with its basis. APPLY keys on the chosen option's `changes_plan`; + * accepted changes are pending amendments (status amend_pending, nothing + * reopens at answer) that `next` refuses past (PLAN_AMEND_PENDING) until + * `revise --incorporates` binds them to a changed plan or `--withdraw` drops + * them. `as-is` keeps every direction; `rec` (alias `all`) takes every + * recommendation. Partial replies resolve against persisted decisions. On + * reruns a challenge without `changes` is an agent decision that reopens + * nothing, and rerun prompts carry the decided items. */ 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 { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; const r = capyRunner(); @@ -33,6 +32,14 @@ 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 SAME = { + native: [{ id: 'F4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'Cap stored attempts at 50 per tenant.' }], + outside: [{ id: 'O4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'cap stored attempts at 50 per tenant' }], +}; +const PACKAGING = { + native: [{ id: 'F5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'land the schedule change as its own PR' }], + outside: [{ id: 'O5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'Land the schedule change as its own PR.' }], +}; 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' }], @@ -54,34 +61,37 @@ const v = (dir: string, text: string) => { const f = path.join(dir, `v${++n}.md` 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 }); + expect(readJsonl(`${boundOf(g.out, 'ceo', 'native')}.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' }); + expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] }); + expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' }); 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(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'naming' }]) }).status).toBe(0); + const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--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); + expect(bad.stderr).toMatch(/changes "naming" is not one of scope\|cost\|direction\|packaging\|ordering.*\(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); + test('one shared remedy recommends b; different remedies show both verbatim and keep a; disagreement and mismatched changes keep a with the basis', () => { + const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' })), ...SAME.native], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' })), ...SAME.outside] }, DISAGREE); const items = gate(g.out).items; + const same = items.find((i: any) => i.title.startsWith('Cap the retry storage')); + expect(same).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; same remedy' }); 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).toMatchObject({ kind: 'approval', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' }); 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(new RegExp(`\\[${same.id}\\] .*recommended: b \\(both voices; same remedy\\)`)); 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`); + expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${same.id}b`); + expect(p1.options.find((o: any) => o.key === p1.recommended).text).not.toContain(`${agree.id}b`); }); test('a parent reconcile override naming one shared proposal renders that proposal as option b', () => { @@ -91,10 +101,10 @@ describe('changes and user-challenge defaults', () => { 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); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--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); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--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' }); @@ -103,23 +113,27 @@ describe('changes and user-challenge defaults', () => { }); describe('accepted changes are amendments', () => { - test('`all` leaves the agreeing change pending; its own token accepts it: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => { + test('an accepted change prints APPLY and NEXT revise and leaves amend_pending without reopening; next refuses until revise --incorporates binds it; revise reports what reopened', () => { const g = gated(AGREE, {}); - const held = answer(g.out, 1, 'all'); + const held = answer(g.out, 1, 'uc1b'); expect(held.status, held.stderr).toBe(0); - expect(held.stdout).toMatch(/^GATE_PENDING: uc1 unanswered \(reply with uc1a\|uc1b; all does not answer items recommended b\)$/m); + expect(held.stdout).toMatch(/^GATE_PENDING: uc2, p1 unanswered \(reply with uc2a\|uc2b p1a\|p1b\|p1c\|p1d\|p1e or rec\)$/m); expect(held.stdout).not.toMatch(/^APPLY:/m); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ status: 'pending' }); - const res = answer(g.out, 1, 'uc1b'); + const res = answer(g.out, 1, 'rec'); 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' })]); + expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend.*; edit the plan, then revise$`, 'm')); + expect(res.stdout).not.toMatch(/^REOPENED:/m); + expect(res.stdout).toMatch(new RegExp(`^NEXT: edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm')); + expect(res.stdout).toContain('status=amend_pending'); + const before = readRunJson(g.out); + expect(before.status).toBe('amend_pending'); + expect(before.phases.ceo.closed_at).toBeTruthy(); + expect(before.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending', item: 'uc1', changes: 'scope' })]); + expect(r.ap(['status', '--out', g.out]).stdout).toMatch(new RegExp(`^AMEND_PENDING: ${esc(g.run)}-uc1 uc1 changes=scope -> edit the plan, then revise$`, 'm')); + expect(r.validate(g.out).status).toBe(0); 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')); + expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1 \\(APPLY uc1: edit the plan, then revise\\).*\\(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'); @@ -127,35 +141,66 @@ describe('accepted changes are amendments', () => { 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`]); + const unchanged = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--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`]); + const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc1']); expect(bound.status, bound.stderr).toBe(0); + expect(bound.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m); expect(bound.stdout).toMatch(/^AMENDMENTS: pending=none$/m); + expect(bound.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, '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(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'plan_revised')).toMatchObject({ reopened: ['ceo', 'eng'] }); + expect(readRunJson(g.out).status).toBe('running'); expect(r.ap(['next', '--out', g.out]).status).toBe(0); }); - test('`all as-is` keeps every direction; --withdraw is a journaled owner decision', () => { + test('`as-is` keeps every direction; --withdraw is a journaled owner decision that completes the run when nothing else is pending', () => { const g = gated(AGREE, {}); - const asIs = answer(g.out, 1, 'all as-is'); + const asIs = answer(g.out, 1, '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' }); + expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'approved' }); const w = gated(AGREE, {}); - expect(answer(w.out, 1, 'all except uc1b').stdout).toMatch(/^APPLY:/m); + expect(answer(w.out, 1, 'rec except uc1b').stdout).toMatch(/^APPLY:/m); const withdraw = r.ap(['revise', '--out', w.out, '--withdraw', `${w.run}-uc1`]); expect(withdraw.status, withdraw.stderr).toBe(0); + expect(withdraw.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m); 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); + expect(readRunJson(w.out).status).toBe('complete'); + expect(r.validate(w.out).status).toBe(0); + }); +}); + +describe('record-only changes settle without a plan edit', () => { + test('a packaging change: APPLY says settle, revise --settle reopens nothing and completes, export and validate pass; a scope change never settles', () => { + const g = gated(PACKAGING, {}); + expect(gate(g.out).items.find((i: any) => i.id === 'uc1')).toMatchObject({ recommended: 'b', changes: 'packaging' }); + const res = answer(g.out, 1, 'rec'); + expect(res.status, res.stderr).toBe(0); + expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 .*; no plan edit needed: revise --settle uc1$`, 'm')); + expect(readRunJson(g.out).status).toBe('amend_pending'); + expect(r.ap(['next', '--out', g.out]).stderr).toMatch(/\(PLAN_AMEND_PENDING\)$/m); + const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc1']); + expect(settled.status, settled.stderr).toBe(0); + expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc1 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm')); + expect(settled.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m); + expect(settled.stdout).not.toMatch(/^REOPENED:/m); + const run = readRunJson(g.out); + expect(run.status).toBe('complete'); + expect(run.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'settled', changes: 'packaging' })]); + expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_settled')).toMatchObject({ decision: `${g.run}-uc1`, changes: 'packaging' }); + expect(r.ap(['export', '--out', g.out]).status).toBe(0); + expect(r.validate(g.out).status).toBe(0); + const scope = gated(AGREE, {}); + expect(answer(scope.out, 1, 'rec except uc1b').stdout).toMatch(/; edit the plan, then revise$/m); + const refused = r.ap(['revise', '--out', scope.out, '--settle', 'uc1']); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/changes scope; only packaging or ordering changes settle.*\(AMENDMENT_NOT_SETTLEABLE\)$/m); + expect(readRunJson(scope.out).status).toBe('amend_pending'); }); }); @@ -167,7 +212,7 @@ describe('partial replies resolve against persisted decisions', () => { 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(JSON.parse(second.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], status: 'amend_pending' }); expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'b', status: 'overridden' }); }); @@ -184,11 +229,12 @@ describe('partial replies resolve against persisted decisions', () => { }); 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', () => { + test('rerun prompts carry the decided items, 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); + expect(res.stdout).not.toMatch(/^REOPENED:/m); + expect(r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc2']).stdout).toMatch(/reopened=ceo,eng/); + phaseVia(r, g.out, 'ceo', {}); 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' }], @@ -198,8 +244,6 @@ describe('reruns', () => { 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 }); diff --git a/test/autoplan-pairing.test.ts b/test/autoplan-pairing.test.ts new file mode 100644 index 000000000..063636bce --- /dev/null +++ b/test/autoplan-pairing.test.ts @@ -0,0 +1,39 @@ +/** + * Pairing negative control (next-wave B2/B-a): finding pairs copied verbatim + * from a real autoplan run. File:line proximity associates findings but never + * confirms them; only a title match or the parent's override does, and `b` + * needs one shared proposal or the same remedy. + */ +import { describe, expect, test } from 'bun:test'; +import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json'; +import { reconcile } from '../lib/autoplan-reconcile'; +import { challengeDefault } from '../lib/autoplan-gate'; + +const pair = (name: string) => fixture.pairs.find(p => p.name.startsWith(name))! as any; +const challenge = (rows: any[], changes = 'direction') => rows.map(r => ({ ...r, user_challenge: true, changes })); + +describe('pairing negative control from a real run', () => { + test('two findings three lines apart on one file are associated but never confirmed, and never recommend b', () => { + const p = pair('ceo r1 outside-1'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row).toMatchObject({ disposition: 'unverified', basis: 'file_line', native_id: p.native[0].id }); + expect(row!.resolution).toStartWith('same location, agreement unverified (bin/gstack-capy-install:197)'); + const [n] = challenge(p.native), [o] = challenge(p.outside); + expect(challengeDefault(row!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; same location, agreement unverified' }); + }); + + test('a location match with different titles stays unverified until the parent confirms it with one proposal', () => { + const p = pair('dx r2 outside-2'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row!.disposition).toBe('unverified'); + const [confirmed] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'same trailer grammar break', proposal: 'keep `gstack: fail <components>` and specify the pins exit' }]).rows; + expect(challengeDefault(confirmed!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; shared proposal' }); + }); + + test('the same problem on different files is new until the parent associates it; different remedies keep a', () => { + const p = pair('dx r2 native-2'); + expect(reconcile(p.native, p.outside).rows[0]).toMatchObject({ disposition: 'new', native_id: null }); + const [row] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'both: a recommendation edit changes what a seen reply resolves to' }]).rows; + expect(challengeDefault(row!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' }); + }); +}); diff --git a/test/autoplan-record.test.ts b/test/autoplan-record.test.ts index 9de8316c6..345eef799 100644 --- a/test/autoplan-record.test.ts +++ b/test/autoplan-record.test.ts @@ -14,6 +14,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; import { capyRunner, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; +import { boundOf, resultOf } from './helpers/autoplan-run-fixture'; const r = capyRunner(); afterAll(() => r.cleanup()); @@ -64,8 +65,8 @@ describe('submit --record', () => { 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(fs.readFileSync(boundOf(out, 'ceo', 'native'), 'utf8')).toBe(TABLE); + const rows = readJsonl(`${boundOf(out, 'ceo', 'native')}.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); }); @@ -107,7 +108,7 @@ describe('submit --record', () => { 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']); + const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--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); }); @@ -139,14 +140,14 @@ describe('submit --record', () => { 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']); + const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--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); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--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); diff --git a/test/autoplan-residuals.test.ts b/test/autoplan-residuals.test.ts index 43ee6497b..54d57769f 100644 --- a/test/autoplan-residuals.test.ts +++ b/test/autoplan-residuals.test.ts @@ -1,23 +1,27 @@ /** * Field-wave residuals (gstack fix wave review, gate revs 1–2, 2026-10-12/13): - * R1 `all` never flips the owner's stated direction: a user-challenge item - * whose recommended option changes the plan stays pending under `all` and - * is answered only by its own token; the rendering says which ids `all` - * leaves open. + * R1 (superseded by next-wave B1, owner decision gate_rev 1): `rec` and its + * silent alias `all` take every recommendation, plan changes included; + * `as-is` keeps every direction; the render prints the `rec` resolution + * from the parser that applies it. * R2 every later phase sees its predecessors' consensus: the DX prompt * carries the CEO consensus, not only the Eng prompt. * R3 lib code spawns the Bun that runs it (`process.execPath`), never `bun` * by PATH name, so the snapshot tool and restamp cannot silently run the * Capy image's 1.3.14 when a parent forgot the launch PATH. * R5 gate answers carry provenance: `answer --by <human|agent:<id>>` is - * required in an unattended run and lands on every decision row. + * required in an unattended run and lands on every decision row; an agent + * answer also carries the owner's verbatim reply (next-wave B-c), and a + * parseable owner reply that resolves differently from the relay is refused. + * B-b `recommend` changes what `rec` resolves to, so it bumps rec_rev and a + * `rec` must carry the rec_hash of the render the owner saw. */ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { parseReply, renderGate, type GateList } from '../lib/gate-list'; -import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { attemptsOf, autoplan, readRun, resultOf, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-residuals-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -34,7 +38,7 @@ const agreed: GateList = { }; const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); -/** Both voices challenge the same item with the same `changes` and a remedy each: the gate recommends `b`. */ +/** Both voices challenge the same item with the same `changes` and the same remedy: the gate recommends `b`. */ function agreedRun(name: string): string { const out = startSmallRun(path.join(TMP, name)); const native = JSON.stringify([ @@ -42,51 +46,59 @@ function agreedRun(name: string): string { { id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }]); const outside = JSON.stringify([ { id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL' }, - { id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes A then B' }]); + { id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'Stack the lanes.' }]); for (const phase of ['ceo', 'eng']) { expect(autoplan(['next', '--out', out]).status).toBe(0); review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native }); review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside }); const ids = attemptsOf(out, phase); - expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0); - expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0); } expect(readRun(out).status).toBe('gate_pending'); return out; } -describe('R1: `all` never flips the owner\'s stated direction', () => { - test('an approval item recommended b (changes the plan) stays pending under `all`; its own token answers it', () => { - const r = parseReply('all', agreed); - expect(r.valid).toBe(true); - expect(r.pending).toEqual(['uc1']); - expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc2a', 'd1a', 'p1a']); - const explicit = parseReply('all except uc1b', agreed); - expect(explicit.pending).toEqual([]); - expect(explicit.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'b', how: 'recommended' }); - const kept = parseReply('all except uc1a', agreed); - expect(kept.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); - expect(parseReply('all as-is', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' }); +describe('R1/B1: `rec` (and its alias `all`) takes every recommendation, plan changes included', () => { + test('an approval item recommended b is answered by `rec`/`all`; `rec except uc1a` and `as-is` keep the direction', () => { + for (const reply of ['rec', 'all', 'approve all', 'Accept all', 'take all recommendations']) { + const r = parseReply(reply, agreed); + expect(r.valid).toBe(true); + expect(r.mode).toBe('rec'); + expect(r.pending).toEqual([]); + expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc1b', 'uc2a', 'd1a', 'p1a']); + } + expect(parseReply('rec except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); + expect(parseReply('all except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); + for (const reply of ['as-is', 'all as-is']) { + const r = parseReply(reply, agreed); + expect(r.mode).toBe('as-is'); + expect(r.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' }); + } + for (const bare of ['yes', 'no', 'rec d1', 'rec yes']) expect(parseReply(bare, agreed).valid).toBe(false); }); - test('the rendering names the ids `all` leaves open and their exact tokens', () => { + test('the rendering offers `rec` first and prints its resolution from the same parser, with the rec_hash', () => { const text = renderGate(agreed); - expect(text).toMatch(/`all` does not answer uc1 \(recommended b changes the plan\); reply uc1a or uc1b/); - expect(text).toMatch(/"choices":\{"uc2":"a","d1":"a","p1":"a"\}/); + expect(text).toMatch(/Reply with exactly one of: `rec` \(default: take every recommendation, plan changes included\)/); + expect(text).toMatch(/"choices":\{"uc1":"b","uc2":"a","d1":"a","p1":"a"\}/); + expect(text).toMatch(/rec_hash=[0-9a-f]{12}/); + expect(text).not.toMatch(/does not answer/); }); - test('end to end: both voices agree, the gate recommends b, and `all` keeps the gate pending on that item', () => { + test('end to end: both voices agree with one remedy, the gate recommends b, and `rec` accepts it as a pending amendment', () => { const out = agreedRun('agreed'); const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList; const uc = gate.items.filter(i => i.id.startsWith('uc')); expect(uc.length).toBeGreaterThan(0); - expect(uc.every(i => i.recommended === 'b')).toBe(true); - const all = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all', '--by', 'human']); - expect(all.status, all.stderr).toBe(0); - expect(all.stdout).toMatch(new RegExp(`GATE_PENDING: ${uc.map(i => i.id).join(', ')} unanswered \\(reply with ${uc.map(i => `${i.id}a|${i.id}b`).join(' ')}; all does not answer items recommended b\\)`)); - expect(readRun(out).status).toBe('gate_pending'); + expect(uc.every(i => i.recommended === 'b' && i.recommended_basis === 'both voices; same remedy')).toBe(true); + const rec = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human']); + expect(rec.status, rec.stderr).toBe(0); + for (const i of uc) expect(rec.stdout).toMatch(new RegExp(`^APPLY: ${readRun(out).run}-${i.id} .*; edit the plan, then revise$`, 'm')); + expect(rec.stdout).toContain('GSTACK_RESULT: skill=autoplan status=amend_pending'); + expect(readRun(out).status).toBe('amend_pending'); const rows = decisions(out); - for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase()).status).toBe('pending'); + for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase())).toMatchObject({ status: 'approved', chosen: 'b' }); }); }); @@ -101,15 +113,54 @@ describe('R5: gate answers carry provenance', () => { const bad = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'me']); expect(bad.status).toBe(3); expect(bad.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)/); - const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']); + const noOwner = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']); + expect(noOwner.status).toBe(3); + expect(noOwner.stderr).toMatch(/\(OWNER_REPLY_REQUIRED\)/); + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29', '--owner-reply', 'keep the plan as written']); expect(ok.status, ok.stderr).toBe(0); const answered = decisions(out).filter(r => r.status !== 'pending'); expect(answered.length).toBeGreaterThan(0); - for (const r of answered) expect(r.answered_by).toBe('agent:GSTA-29'); + for (const r of answered) expect(r).toMatchObject({ answered_by: 'agent:GSTA-29', owner_reply: 'keep the plan as written', owner_reply_parsed: false }); expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/answered by agent:GSTA-29/); }); }); +describe('B-b/B-c: recommend binds rec to the render the owner saw; the owner reply must match the relay', () => { + test('recommend bumps rec_rev and rewrites p1 and decisions; a rec without the new rec_hash is refused; a mismatched owner reply is refused', () => { + const out = agreedRun('recommend'); + const gateFile = path.join(out, 'gate.json'); + const uc = (JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList).items.find(i => i.id.startsWith('uc'))!; + const oldHash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!; + const rec = autoplan(['recommend', '--out', out, '--gate-rev', '1', '--item', uc.id, '--option', 'a', '--basis', 'the lanes already split by file', '--by', 'parent']); + expect(rec.status, rec.stderr).toBe(0); + const hash = rec.stdout.match(/^RECOMMENDED: .* rec_hash=([0-9a-f]{12})$/m)![1]!; + expect(hash).not.toBe(oldHash); + const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; + expect(gate.rec_rev).toBe(1); + expect(gate.items.find(i => i.id === uc.id)).toMatchObject({ recommended: 'a', recommended_basis: 'the lanes already split by file', recommended_by: 'parent' }); + expect(renderGate(gate)).toContain(`rec_hash=${hash}`); + expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ recommended: 'a', recommended_by: 'parent' }); + const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'); + for (const extra of [[], ['--rec-hash', oldHash]]) { + const stale = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human', ...extra]); + expect(stale.status).toBe(3); + expect(stale.stderr).toMatch(/\(GATE_REC_STALE\)/); + } + const mismatch = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply', `${uc.id}b`]); + expect(mismatch.status).toBe(3); + expect(mismatch.stderr).toMatch(/\(GATE_OWNER_REPLY_MISMATCH\)/); + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const ownerFile = path.join(TMP, 'owner-reply.txt'); + fs.writeFileSync(ownerFile, 'rec'); + const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply-file', ownerFile]); + expect(ok.status, ok.stderr).toBe(0); + expect(ok.stdout).not.toMatch(new RegExp(`^APPLY: \\S+-${uc.id} `, 'm')); + expect(ok.stdout).toMatch(/^APPLY: \S+-uc2 /m); + expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ chosen: 'a', owner_reply: 'rec', owner_reply_parsed: true, recommended_by: 'parent' }); + }); +}); + describe('R2: later phases see their predecessors\' consensus', () => { test('the DX prompt carries the CEO consensus block', () => { const out = startSmallRun(path.join(TMP, 'dx-consensus'), ['--developer-tool']); diff --git a/test/autoplan-revise.test.ts b/test/autoplan-revise.test.ts index 9b00f151a..d3566df66 100644 --- a/test/autoplan-revise.test.ts +++ b/test/autoplan-revise.test.ts @@ -58,7 +58,11 @@ describe('revise', () => { 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); + expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r0.md'))).toBe(true); + const sealed = run.history.find((h: any) => h.phase === 'ceo').previous.native.output; + expect(sealed).toMatch(/^ceo-native\.a\d+\.bound\.md$/); + expect(fs.existsSync(path.join(s.out, sealed))).toBe(true); + expect(run.artifacts[sealed]).toBeTruthy(); }); test('a Review-record-only edit keeps every phase; an open phase whose hash changed has its attempts superseded', () => { @@ -95,11 +99,11 @@ describe('revise', () => { 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')); + const first = fs.readFileSync(path.join(s.out, 'ceo-native-prompt.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); + expect(fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')).equals(first)).toBe(true); + expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r1.v2.md'))).toBe(true); }); test('revise adds a newly in-scope phase and never removes one', () => { diff --git a/test/autoplan-run-concurrency.test.ts b/test/autoplan-run-concurrency.test.ts index b8ac8aa68..b2d6a4340 100644 --- a/test/autoplan-run-concurrency.test.ts +++ b/test/autoplan-run-concurrency.test.ts @@ -10,7 +10,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-concurrency-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -44,7 +44,7 @@ describe('two writers', () => { const out = startSmallRun(path.join(TMP, 'submit'), ['--estimate-usd', '1']); const ids = attemptsOf(out, 'ceo'); review(path.join(out, 'ceo-native-prompt.md')); - const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; + const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; const results = await parallel(Array.from({ length: 6 }, () => args)); const ok = results.filter(r => r.status === 0); expect(ok).toHaveLength(1); @@ -78,8 +78,8 @@ describe('two writers', () => { review(path.join(out, 'ceo-outside-prompt.md')); const before = readRun(out).revision; const results = await parallel([ - ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native], - ['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside], + ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native], + ['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside], ['status', '--out', out], ]); const bound = results.slice(0, 2).filter(r => r.status === 0).length; diff --git a/test/autoplan-run-recovery.test.ts b/test/autoplan-run-recovery.test.ts index e67885016..d2c0db080 100644 --- a/test/autoplan-run-recovery.test.ts +++ b/test/autoplan-run-recovery.test.ts @@ -7,13 +7,16 @@ * refuses a wrong receipt, a missing findings fence, a same-family outside * model and a stale attempt; the whole wave plan runs through the loop with a * fixture reviewer and validates. Pins tokens, codes and exit codes. + * Next-wave B4: each attempt writes `<phase>-<voice>.<attempt>.md`, and submit + * binds a sealed `.bound.md` copy, so a late write or an interruption between + * sealing and the journal binds nothing twice. */ import { afterAll, describe, expect, test } from 'bun:test'; 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 { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-recovery-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -46,36 +49,36 @@ describe('next/submit loop', () => { const ids = attemptsOf(out, 'ceo'); const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8'); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'receipt' }); - let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_RECEIPT_MISSING\)$/m); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'fence' }); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'row' }); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(ATTEMPT_MISMATCH\)$/m); - r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before); expect(journal(out).filter(j => j.event === 'submitted')).toEqual([]); // a good native, then an outside model from the same family review(path.join(out, 'ceo-native-prompt.md')); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status, r.stderr).toBe(0); expect(r.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=native model=claude-opus-4-7 findings=3/m); review(path.join(out, 'ceo-outside-prompt.md')); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/both anthropic.*\(MODEL_FAMILY_CONFLICT\)$/m); // exactly once: the bound native attempt cannot be submitted again - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); }); @@ -140,7 +143,7 @@ describe('recovery', () => { expect(readRun(out).attempt_counter).toBe(2); // the provider had finished after all: the late result binds under the same attempt id review(path.join(out, 'ceo-native-prompt.md')); - const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(late.status, late.stderr).toBe(0); // the outside attempt is redispatched only when named explicitly; its charge stays as unknown, never zero const redo = autoplan(['next', '--out', out, '--redispatch', ids.outside, '--estimate-usd', '1']); @@ -157,7 +160,7 @@ describe('recovery', () => { const ids = attemptsOf(out, 'ceo'); review(path.join(out, 'ceo-native-prompt.md')); const snapshotBefore = fs.readFileSync(path.join(out, 'run.json'), 'utf8'); - const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(ok.status, ok.stderr).toBe(0); // simulate the crash: the journal and the ledger have the submit, run.json does not fs.writeFileSync(path.join(out, 'run.json'), snapshotBefore); @@ -173,6 +176,52 @@ describe('recovery', () => { expect(spend.reserved.map((r: any) => r.attempt)).toEqual([ids.outside]); }); + test('killed after sealing, before the journal: nothing is bound, resume reports the attempt unknown, and the same submit then binds once', () => { + const out = startSmallRun(path.join(TMP, 'kill-sealed')); + const ids = attemptsOf(out, 'ceo'); + review(path.join(out, 'ceo-native-prompt.md')); + const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; + const faulted = autoplan(args, { env: env({ GSTACK_AUTOPLAN_FAULT: 'submit-after-seal' }) }); + expect(faulted.status).not.toBe(0); + const sealed = path.join(out, `ceo-native.${ids.native.split('-').pop()}.bound.md`); + expect(fs.existsSync(sealed)).toBe(true); + expect(journal(out).some(r => r.event === 'submitted')).toBe(false); + expect(readRun(out).phases.ceo.native.status).toBe('running'); + const resumed = JSON.parse(autoplan(['resume', '--out', out, '--json']).stdout); + expect(resumed).toMatchObject({ rebound: [], execution_unknown: [ids.native, ids.outside] }); + const ok = autoplan(args); + expect(ok.status, ok.stderr).toBe(0); + expect(journal(out).filter(r => r.event === 'submitted')).toHaveLength(1); + expect(readRun(out).phases.ceo.native).toMatchObject({ status: 'completed', output: path.basename(sealed), attempt: ids.native }); + }); + + test('the bound result is a sealed attempt-owned copy: a late write to the reviewer file changes nothing bound, and another attempt\'s file is refused', () => { + const out = startSmallRun(path.join(TMP, 'sealed')); + runPhase(out, 'ceo'); + const result = resultOf(out, 'ceo', 'native'); + const run = readRun(out); + const bound = path.join(out, run.phases.ceo.native.output); + expect(path.basename(bound)).toMatch(/^ceo-native\.a\d+\.bound\.md$/); + expect(fs.readFileSync(bound, 'utf8')).toBe(fs.readFileSync(result, 'utf8')); + if (process.platform !== 'win32') expect(fs.statSync(bound).mode & 0o222).toBe(0); + fs.appendFileSync(result, '\nLate edit after submit.\n'); + runPhase(out, 'eng'); + expect(readRun(out).status).toBe('gate_pending'); + const v = validate(out); + expect(v.status, v.stdout + v.stderr).toBe(0); + expect(fs.readFileSync(bound, 'utf8')).not.toContain('Late edit'); + const eng = attemptsOf(out, 'eng'); + expect(path.basename(resultOf(out, 'eng', 'native'))).toBe(`eng-native.${eng.native.split('-').pop()}.md`); + const other = startSmallRun(path.join(TMP, 'sealed-other')); + const otherIds = attemptsOf(other, 'ceo'); + review(path.join(other, 'ceo-native-prompt.md')); + const foreign = path.join(other, `ceo-native.a999.md`); + fs.copyFileSync(resultOf(other, 'ceo', 'native'), foreign); + const refused = autoplan(['submit', '--out', other, '--phase', 'ceo', '--voice', 'native', '--result', foreign, '--model', 'claude-opus-4-7', '--attempt', otherIds.native]); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/\(ATTEMPT_MISMATCH\)/); + }); + test('a runner process killed mid-command leaves no lock behind that a later command cannot reclaim; a live owner is never stolen', () => { const out = startSmallRun(path.join(TMP, 'lock')); fs.writeFileSync(path.join(out, 'run.lock'), JSON.stringify({ pid: 999999, host: os.hostname(), at: new Date().toISOString() })); diff --git a/test/fixtures/autoplan-pairing/run-20261014-pairs.json b/test/fixtures/autoplan-pairing/run-20261014-pairs.json new file mode 100644 index 000000000..d0fac6775 --- /dev/null +++ b/test/fixtures/autoplan-pairing/run-20261014-pairs.json @@ -0,0 +1,94 @@ +{ + "source": "autoplan run run-20261014225954 (next-wave plan review, 2026-10-14): ceo gate_rev 1 findings (*.findings.r1.jsonl) and dx gate_rev 2 findings (*.findings.jsonl), copied verbatim", + "pairs": [ + { + "name": "ceo r1 outside-1 vs native-11", + "expect": "negative", + "why": "same file, lines 3 apart; different problems and remedies (project-aware Bun launching vs a single verdict owner plus a fresh-machine job); reconciled confirmed on file:line alone at 1.91.101.0", + "outside": [ + { + "id": "run-20261014225954-ceo-outside-1", + "phase": "ceo", + "voice": "outside", + "severity": "High", + "title": "A fixed home-wide Bun link cannot preserve per-project runtime ownership", + "file": "bin/gstack-capy-install", + "line": 197, + "resolution": "Replace A2's mutable fixed-version global link with project-aware launching; bind install verdicts to the calling project and resolved runtime, reconcile pregate's separate resolver, and exercise alternating exact-pin projects plus unrelated Bun commands.", + "user_challenge": true, + "changes": "direction" + } + ], + "native": [ + { + "id": "run-20261014225954-ceo-native-11", + "phase": "ceo", + "voice": "native", + "severity": "Medium", + "title": "Fourth install-honesty wave in about two weeks: add a single verdict owner and a pre-merge fresh-machine acceptance job instead of a post-ship manual rerun", + "file": "bin/gstack-capy-install", + "line": 200, + "resolution": "Route install, upgrade, repair and session-start through one final step: gstack-capy-install --check from ORIG_PWD, printing only its trailer. Make the GBRA-60 reproduction (image Bun on PATH, verified Bun off PATH, project pinned to 1.4.3, fresh shells) an automated job on a fresh Capy or Ubicloud machine that gates Lane A's merge." + } + ] + }, + { + "name": "dx r2 outside-2 vs native-1", + "expect": "positive", + "why": "both find that the revised trailer grammar breaks the existing fail head and leaves the pins exit unspecified", + "outside": [ + { + "id": "run-20261014225954-dx-outside-2", + "phase": "dx", + "voice": "outside", + "severity": "Medium", + "title": "The revised failure trailer has conflicting shapes and an unspecified pins exit code", + "file": "bin/gstack-doctor-check.sh", + "line": 459, + "resolution": "Define one backward-compatible status grammar with failed-component information, specify A7's numeric pins-readiness exit and partial-upgrade state, and synchronize text/JSON/verdict/docs with success and multi-failure note-composition tests." + } + ], + "native": [ + { + "id": "run-20261014225954-dx-native-1", + "phase": "dx", + "voice": "native", + "severity": "High", + "title": "A9's trailer grammar would replace the existing 'gstack: fail <components>' head that the Capy skill (and its lagging Drive copies) dispatch repairs on", + "file": "bin/gstack-doctor-check.sh", + "line": 460, + "resolution": "Scope the grammar to `gstack: ok <rev> for=<skill>[ (<notes>)]`. Keep failures as `gstack: fail <component>[,<component>] \u2014 <detail>; fix: <cmd> (<CODE>; <url>#<anchor>)`, with notes appended in the same fixed order. Add a test that runs the current capy/skill/gstack/SKILL.md dispatch examples (fail revision, fail runtime,pins, fail pins) against the new output." + } + ] + }, + { + "name": "dx r2 native-2 vs outside-4", + "expect": "positive", + "why": "same problem by meaning: a recommendation edit after the owner saw the gate changes what the reply resolves to; different files and titles", + "outside": [ + { + "id": "run-20261014225954-dx-outside-4", + "phase": "dx", + "voice": "outside", + "severity": "High", + "title": "Recommendation edits can change a displayed gate's defaults without invalidating its reply", + "file": "lib/autoplan-gate.ts", + "line": 154, + "resolution": "Version semantic recommendation changes, reject old replies, and refresh plan-approval summaries, decisions and artifact hashes; return the new gate identity and test concurrent/repeated edits, answered/carried choices and restore." + } + ], + "native": [ + { + "id": "run-20261014225954-dx-native-2", + "phase": "dx", + "voice": "native", + "severity": "High", + "title": "The new recommend verb can change what rec resolves to after the owner has seen the gate", + "file": "lib/gate-list.ts", + "line": 112, + "resolution": "Hash the rendered recommendations into the relay (for example rec_hash in the render's resolution line). Make answer reject a rec whose hash no longer matches (re-render and re-relay). Make recommend refuse after a render has been relayed unless it bumps to a fresh render. Test recommend-after-relay, then rec." + } + ] + } + ] +} \ No newline at end of file diff --git a/test/gate-list.test.ts b/test/gate-list.test.ts index 10a72c836..c4e1aa775 100644 --- a/test/gate-list.test.ts +++ b/test/gate-list.test.ts @@ -9,7 +9,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 { applyReply, decisionRows, parseReply, renderGate, resolveAll, validateGateList, type GateList } from '../lib/gate-list'; +import { applyReply, decisionRows, parseReply, recHash, renderGate, validateGateList, type GateList } from '../lib/gate-list'; import { EXIT, validateFile } from '../lib/headless-artifacts'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -116,7 +116,7 @@ describe('reply grammar', () => { }); describe('rendering', () => { - test('ids and numbering are stable across pages; each page prints the reply shape and the `all` resolution', () => { + test('ids and numbering are stable across pages; each page prints the reply shape and the `rec` resolution with its rec_hash', () => { const p1 = renderGate(big, 1); const p5 = renderGate(big, 5); expect(p1).toContain('page=1/5'); @@ -127,10 +127,10 @@ describe('rendering', () => { expect(p5).toContain('29. [d29] decision 29'); expect(p5).toContain('33. [d33] decision 33'); for (const page of [p1, p5]) { - expect(page).toContain('`all`'); - expect(page).toContain('`all except <tokens>`'); - expect(page).toContain(`gate_rev=1`); - expect(page).toContain(JSON.stringify({ gate_rev: 1, choices: resolveAll(big) })); + expect(page).toContain('`rec`'); + expect(page).toContain('`rec except <tokens>`'); + expect(page).toContain(`gate_rev=1 rec_hash=${recHash(big)}`); + expect(page).toContain(JSON.stringify({ gate_rev: 1, rec_hash: recHash(big), choices: Object.fromEntries(big.items.map(i => [i.id, i.recommended])) })); } expect(renderGate(list)).toContain(' d1a) option A (recommended)'); expect(renderGate(list)).not.toContain('page=1/2'); @@ -145,6 +145,33 @@ describe('rendering', () => { }); }); +describe('next wave B1/B-b: rec, aliases, quotes and the rec_hash', () => { + const held: GateList = { ...list, items: [{ ...list.items[0]!, recommended: 'b', options: [{ key: 'a', text: 'keep', changes_plan: false }, { key: 'b', text: 'change', changes_plan: true }, { key: 'c', text: 'other' }] }, ...list.items.slice(1)] }; + test('every alias resolves like `rec`, plan changes included; `except` and earlier answers combine; quotes, backticks and newlines are separators', () => { + const want = { uc1: 'b', d1: 'a', d2: 'a', d3: 'a' }; + for (const reply of ['rec', 'REC', 'all', 'approve all', 'accept all', 'take all recommendations', '`rec`', '"rec"', '\u201crec\u201d', 'rec\n']) { + const p = parseReply(reply, held, 1); + expect(p.valid, reply).toBe(true); + expect(p.mode).toBe('rec'); + expect(chosen(p)).toEqual(want); + } + expect(chosen(parseReply('rec except uc1a d3b', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'b' }); + expect(chosen(parseReply('rec', held, 1, { d1: 'c' }))).toEqual({ uc1: 'b', d1: 'c', d2: 'a', d3: 'a' }); + expect(parseReply('rec', held, 1, { d1: 'c' }).choices.find(c => c.id === 'd1')!.prior).toBe(true); + expect(chosen(parseReply('as-is', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'a' }); + expect(parseReply('d3b', held, 1).pending).toEqual(['uc1', 'd1']); + for (const bad of ['approve', 'accept', 'take all', 'rec rec', 'rec as-is', 'yes']) expect(parseReply(bad, held, 1).valid, bad).toBe(false); + }); + test('rec_hash changes with a recommendation, its basis or rec_rev, and the render shows who set it', () => { + const h = recHash(held); + expect(recHash({ ...held })).toBe(h); + expect(recHash({ ...held, rec_rev: 1 })).not.toBe(h); + const moved = { ...held, items: held.items.map(i => (i.id === 'd1' ? { ...i, recommended: 'b', recommended_basis: 'both reviewers name it', recommended_by: 'agent:relay' } : i)) }; + expect(recHash(moved)).not.toBe(h); + expect(renderGate(moved)).toContain('[d1] Delivery shape (approval; recommended: b (both reviewers name it; set by agent:relay))'); + }); +}); + describe('decisions.jsonl rows', () => { test('pending rows carry gate_rev, kind, recommended and the auto default; applying a reply sets chosen/status', () => { const rows = decisionRows(list); diff --git a/test/helpers/autoplan-capy.ts b/test/helpers/autoplan-capy.ts index fbf2c54d4..00e6bd589 100644 --- a/test/helpers/autoplan-capy.ts +++ b/test/helpers/autoplan-capy.ts @@ -11,7 +11,7 @@ import * as path from 'node:path'; import { capyHost, type CapyHost } from './capy-host'; import { SKILL_REQUIREMENTS } from '../../lib/doctor-components'; import type { InstallVerdict } from '../../lib/install-verdict'; -import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture'; +import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN, resultOf } from './autoplan-run-fixture'; const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_'); const RUNNER_KIND = 'un' + 'attended'; @@ -75,7 +75,7 @@ export function phaseVia(r: CapyRunner, out: string, phase: string, findings: { 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']); + const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--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', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']); return { next, native, outside }; } diff --git a/test/helpers/autoplan-run-fixture.ts b/test/helpers/autoplan-run-fixture.ts index 1d478423b..c602886f6 100644 --- a/test/helpers/autoplan-run-fixture.ts +++ b/test/helpers/autoplan-run-fixture.ts @@ -49,6 +49,15 @@ export function readRun(out: string): any { export function journal(out: string): any[] { return fs.readFileSync(path.join(out, 'attempts.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); } +/** The result path the open (else the latest) attempt of a voice owns: `<phase>-<voice>.<attempt>.md`. */ +export function resultOf(out: string, phase: string, voice: 'native' | 'outside'): string { + const attempts: Array<{ state: string; result: string }> = readRun(out).phases[phase]?.[voice]?.attempts ?? []; + const attempt = attempts.find(a => a.state === 'dispatched' || a.state === 'execution_unknown') ?? attempts[attempts.length - 1]; + if (!attempt) throw new Error(`${phase} ${voice} has no attempt in ${out}`); + return attempt.result; +} +/** The sealed copy a completed voice is bound to. */ +export const boundOf = (out: string, phase: string, voice: 'native' | 'outside'): string => path.join(out, readRun(out).phases[phase][voice].output); export function attemptsOf(out: string, phase: string): { native: string; outside: string } { const run = readRun(out); return { native: run.phases[phase].native.attempt, outside: run.phases[phase].outside.attempt }; @@ -65,8 +74,8 @@ export function runPhase(out: string, phase: string, o: PhaseRunOptions = {}): { if (r.status !== 0) throw new Error(`fixture reviewer failed: ${r.stderr}`); } const ids = attemptsOf(out, phase); - const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']); - const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]); + const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']); + const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]); return { next, native, outside }; } From 8eba8b322ec5fef160127a1a76f9d3024502f25b Mon Sep 17 00:00:00 2001 From: garrytan <garrytan@gmail.com> Date: Thu, 15 Oct 2026 00:38:19 +0000 Subject: [PATCH 3/4] feat(autoplan): durable checkpoint and resume --from; Capy checklist (Lane B: B12) With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every command that leaves a run at rest (gate_pending, amend_pending, complete, incomplete) publishes <dir>/<run>/: checkpoint.json listing every file's sha256, the run under run/, the owner's plan file under plan-input/ when it lives outside the run, and restore.md as the human index. Publication swaps in a fully written sibling. Checkpointing at every rest state, not only gate_pending, keeps accepted decisions from rolling back on restore. `gstack-autoplan resume --out <new dir> --from <checkpoint>` checks containment (no absolute or .. paths, regular files only) and every hash before publishing the run with one rename. It restores a missing plan file into the run, journals `restored` with both hosts, and takes the lock. CHECKPOINT_INVALID and CHECKPOINT_TARGET_EXISTS each get a result-codes row and a troubleshooting anchor. Journal and revise staging paths are now run-relative, so a moved run recovers. DRIVER.md and the Capy skill's autoplan section get a short Capy checklist: always drive gstack-autoplan, never present a hand review as an autoplan run, relay every gate page with rec as the default, pass --owner-reply, and point the checkpoint at the private project-user Drive folder. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/DRIVER.md | 10 +++ autoplan/DRIVER.md.tmpl | 7 ++ bin/gstack-autoplan | 15 +++- bin/gstack-doctor-components.sh | 2 + capy/skill/gstack/SKILL.md | 2 + docs/troubleshooting.md | 23 ++++++ lib/autoplan-checkpoint.ts | 115 +++++++++++++++++++++++++++ lib/autoplan-revise.ts | 9 ++- lib/autoplan-run.ts | 15 ++-- lib/result-codes.ts | 10 +++ scripts/resolvers/autoplan-driver.ts | 3 +- test/autoplan-checkpoint.test.ts | 90 +++++++++++++++++++++ test/autoplan-driver.test.ts | 2 +- 13 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 lib/autoplan-checkpoint.ts create mode 100644 test/autoplan-checkpoint.test.ts diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index c31483744..818ac1893 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -38,6 +38,13 @@ line yourself; gstack never writes the plan. then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. +### On Capy + +- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by + hand is not an autoplan run: do not present it as one. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest + survives machine replacement; restore with `resume --out <new dir> --from <dir>/<run>`. + ### Commands - `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--scaffold]` @@ -66,6 +73,8 @@ line yourself; gstack never writes the plan. write the artifact set; then `gstack-artifact validate <dir>/run.json`. - `gstack-autoplan status|resume --out <dir>` read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). +- `gstack-autoplan resume --out <new dir> --from <checkpoint>` + restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership. ## Reviewer result format @@ -119,6 +128,7 @@ is the completion check. Copy the whole directory, never single files. - `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree - `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` - `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan +- `CHECKPOINT_INVALID`: restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (its `restore.md` names the command); nothing was written to --out - `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade ## Coordinator contract (inherited by every lane) diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 258f8226b..11b00c2ee 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -35,6 +35,13 @@ line yourself; gstack never writes the plan. then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. +### On Capy + +- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by + hand is not an autoplan run: do not present it as one. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest + survives machine replacement; restore with `resume --out <new dir> --from <dir>/<run>`. + ### Commands {{AUTOPLAN_DRIVER_COMMANDS}} diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 154fc5a9e..e2b8766f4 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -12,7 +12,7 @@ * [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json] * gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] - * gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN + * gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes * gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran @@ -27,6 +27,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { answerGate, applyGuidance, recommendGate } from '../lib/autoplan-gate'; +import { checkpointRun, restoreCheckpoint } from '../lib/autoplan-checkpoint'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; import { exportRun } from '../lib/autoplan-export'; import { migrateRun } from '../lib/autoplan-migrate'; @@ -67,7 +68,9 @@ DRIVER: ${driverFile()} --rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards - resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id> + resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first + (GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command leaving the run at rest writes <dir>/<run>/ and prints CHECKPOINT:) + status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id> contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) The first next needs --plan. The native voice is dispatched by the parent from the printed prompt file (a subagent on the @@ -125,6 +128,8 @@ function mutating(out: string, json: boolean, fn: () => number): number { 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); } + const checkpoint = code === EXIT.ok ? checkpointRun(out) : undefined; + if (checkpoint) process.stderr.write(checkpoint + '\n'); return code; }); } @@ -247,9 +252,13 @@ async function main(argv: string[]): Promise<number> { return EXIT.ok; } if (cmd === 'resume') { + const from = flag(rest, '--from'); + const restored = from !== undefined ? restoreCheckpoint(from, out) : []; return withRunLock(out, () => { const r = resumeRun(out); - const lines = [...r.lines, resultLineFor(r.state.status, out)]; + const checkpoint = checkpointRun(out); + if (checkpoint) process.stderr.write(checkpoint + '\n'); + const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)]; emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out }); return EXIT.ok; }, { steal: true }); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index f3d9c7962..32d6ef5e0 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -186,6 +186,8 @@ gstack_result_anchor() { AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;; GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;; + CHECKPOINT_INVALID) printf '%s' 'checkpoint-invalid' ;; + CHECKPOINT_TARGET_EXISTS) printf '%s' 'checkpoint-target-exists' ;; OWNER_REPLY_REQUIRED) printf '%s' 'owner-reply-required' ;; GATE_OWNER_REPLY_MISMATCH) printf '%s' 'gate-owner-reply-mismatch' ;; GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;; diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 3be1861e6..5abdf1daa 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -38,6 +38,8 @@ If no specific skill was requested, use the router's task-to-skill mapping. Read For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract. +Capy checklist for `/autoplan`: always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`, and never present a plan you reviewed by hand as an autoplan run. Relay the gate with `gstack-gate render <dir>/gate.json --all` (every page), offer `rec` (take every recommendation, plan changes included) as the one-word default, and answer with `--by agent:<id> --owner-reply "<their words>"`. Launch every `gstack-autoplan` command with `GSTACK_AUTOPLAN_CHECKPOINT_DIR=/home/user/.capy/drive/<project-user scope>/autoplan-checkpoints` (the private project-user Drive folder) so a run at rest survives machine replacement; on a new machine, `gstack-autoplan resume --out <new dir> --from <that dir>/<run>` restores it (each checkpoint's `restore.md` names the command). + ## Before any push or pull request In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bd10459f6..269c80838 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2745,6 +2745,29 @@ the render the owner answered. **Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with `--rec-hash <h>` from that render. +<a id="checkpoint-invalid"></a> +### `CHECKPOINT_INVALID` + +**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` refused +the checkpoint: `checkpoint.json` is unreadable or not a schema_version 1 +checkpoint, or a listed file is missing, is not a regular file, escapes the +checkpoint directory, or does not match its recorded sha256. Nothing was +written to `--out`. + +**Fix.** Restore from an intact copy. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` +set, every command that leaves the run at rest rewrites +`<dir>/<run>/` (its `restore.md` names the exact command); a copy that was +edited or partially synced fails here by design. + +<a id="checkpoint-target-exists"></a> +### `CHECKPOINT_TARGET_EXISTS` + +**Meaning.** `resume --from` restores only into a missing or empty `--out` +directory, and this one already holds files. + +**Fix.** Pass a new `--out` directory, or run `gstack-autoplan resume --out +<dir>` without `--from` to resume the run already there. + <a id="owner-reply-required"></a> ### `OWNER_REPLY_REQUIRED` diff --git a/lib/autoplan-checkpoint.ts b/lib/autoplan-checkpoint.ts new file mode 100644 index 000000000..687c4a28c --- /dev/null +++ b/lib/autoplan-checkpoint.ts @@ -0,0 +1,115 @@ +/** + * autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave + * B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that + * leaves the run at rest (gate_pending, amend_pending, complete, incomplete) + * publishes `<dir>/<run>/`: `checkpoint.json` (every file's sha256), the run + * files under `run/`, the owner's plan file under `plan-input/` when it lives + * outside the run, and `restore.md`, the human index. Publication swaps a + * fully written sibling in, so a reader sees the old copy or the new one. + * `restoreCheckpoint` verifies containment and every hash before it publishes + * the run directory with one rename; the caller then resumes and owns it. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { sha256File } from './headless-artifacts'; +import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state'; + +const MANIFEST = 'checkpoint.json'; +const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete']; +interface CheckpointManifest { + schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string; + files: Record<string, string>; + plan_input?: { original: string; file: string; sha256: string }; +} + +function runFiles(dir: string, rel = ''): string[] { + return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) return /^\.revise-v\d+$/.test(e.name) ? [] : runFiles(dir, r); + return e.isFile() && r !== FILES.lock && !r.endsWith('.tmp') ? [r] : []; + }); +} + +const copy = (from: string, to: string) => { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); }; +const inside = (root: string, p: string) => !path.relative(root, p).startsWith('..') && !path.isAbsolute(path.relative(root, p)); + +function restoreIndex(m: CheckpointManifest, target: string): string { + return [`# autoplan checkpoint: ${m.run}`, '', + `Written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}; ${Object.keys(m.files).length} files, each listed with its sha256 in checkpoint.json.`, '', + 'Restore on any machine (the target directory must not hold a run):', '', + ` gstack-autoplan resume --out <new run dir> --from ${target}`, '', + 'resume verifies every hash, publishes the run directory, takes ownership and prints the next step.', + ...(m.plan_input ? ['', `The owner's plan file was ${m.plan_input.original}; a copy is in ${m.plan_input.file} and is restored into the run directory when the original is missing.`] : []), ''].join('\n'); +} + +/** Publish the checkpoint when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */ +export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + const root = env.GSTACK_AUTOPLAN_CHECKPOINT_DIR; + if (!root || !fs.existsSync(path.join(out, FILES.run))) return undefined; + const state = readState(out); + if (!AT_REST.includes(state.status)) return undefined; + const target = path.join(path.resolve(root), state.run); + const tmp = `${target}.${process.pid}.tmp`; + fs.rmSync(tmp, { recursive: true, force: true }); + const files: Record<string, string> = {}; + for (const rel of runFiles(state.out)) { copy(path.join(state.out, rel), path.join(tmp, 'run', rel)); files[rel] = sha256File(path.join(tmp, 'run', rel)); } + const input = state.plan_input && !inside(state.out, state.plan_input) && fs.existsSync(state.plan_input) ? state.plan_input : undefined; + const planFile = input ? `plan-input/${path.basename(input)}` : undefined; + if (input) copy(input, path.join(tmp, planFile!)); + const m: CheckpointManifest = { schema_version: 1, run: state.run, status: state.status, gate_rev: state.gate_rev ?? 0, revision: state.revision, at: now(), host: os.hostname(), + ...(state.gstack_version ? { gstack_version: state.gstack_version } : {}), files, ...(input ? { plan_input: { original: input, file: planFile!, sha256: sha256File(path.join(tmp, planFile!)) } } : {}) }; + fs.writeFileSync(path.join(tmp, 'restore.md'), restoreIndex(m, target)); + fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n'); + const old = `${target}.${process.pid}.old`; + if (fs.existsSync(target)) fs.renameSync(target, old); + fs.renameSync(tmp, target); + fs.rmSync(old, { recursive: true, force: true }); + return `CHECKPOINT: ${target} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(files).length}`; +} + +/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every hash matches. */ +export function restoreCheckpoint(from: string, out: string): string[] { + const src = path.resolve(from); + const bad = (detail: string): never => { throw new RunError('CHECKPOINT_INVALID', `${src}: ${detail}`, 3); }; + let m: CheckpointManifest; + try { m = JSON.parse(fs.readFileSync(path.join(src, MANIFEST), 'utf8')); } catch (e) { return bad(`no readable ${MANIFEST} (${(e as Error).message})`); } + if (m.schema_version !== 1 || typeof m.run !== 'string' || !m.files || typeof m.files !== 'object' || !m.files[FILES.run]) bad(`${MANIFEST} is not a schema_version 1 checkpoint with run.json listed`); + const target = path.resolve(out); + if (fs.existsSync(target) && fs.readdirSync(target).length) throw new RunError('CHECKPOINT_TARGET_EXISTS', `${target} is not empty`, 3); + const verify = (rel: string, sha: string, base: string) => { + const file = path.resolve(base, rel); + if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..') || !inside(base, file)) bad(`${rel} escapes the checkpoint`); + let st: fs.Stats; + try { st = fs.lstatSync(file); } catch { return bad(`${rel} is listed but missing`); } + if (!st.isFile()) bad(`${rel} is not a regular file`); + if (sha256File(file) !== sha) bad(`${rel} does not match its sha256 in ${MANIFEST}`); + return file; + }; + const staging = `${target}.restore-${process.pid}`; + fs.rmSync(staging, { recursive: true, force: true }); + try { + for (const [rel, sha] of Object.entries(m.files)) copy(verify(rel, sha, path.join(src, 'run')), path.join(staging, rel)); + for (const [rel, sha] of Object.entries(m.files)) verify(rel, sha, staging); + const lines: string[] = []; + if (m.plan_input) { + const copyOf = verify(m.plan_input.file, m.plan_input.sha256, src); + if (!fs.existsSync(m.plan_input.original)) { + const restored = path.join(staging, m.plan_input.file); + copy(copyOf, restored); + const state = JSON.parse(fs.readFileSync(path.join(staging, FILES.run), 'utf8')) as RunState; + state.plan_input = path.join(target, m.plan_input.file); + fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify(state, null, 2) + '\n'); + lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${state.plan_input}`); + } else if (sha256File(m.plan_input.original) !== m.plan_input.sha256) lines.push(`PLAN_INPUT: ${m.plan_input.original} differs from the checkpoint copy; kept the file on disk`); + } + if (fs.existsSync(target)) fs.rmdirSync(target); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.renameSync(staging, target); + const state = readState(target); + journal(target, { event: 'restored', from: src, checkpoint_at: m.at, checkpoint_host: m.host, host: os.hostname(), files: Object.keys(m.files).length }); + return [`RESTORED: run=${state.run} from=${src} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(m.files).length} (written ${m.at} on ${m.host})`, ...lines]; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +} diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index 785a2601c..52cee58a6 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -125,7 +125,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult { 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; - journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); + journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : []; for (const p of added) state.phases[p] = {}; @@ -169,15 +169,16 @@ 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); + const staging = path.resolve(out, intent.staging); + const stagedRun = path.join(staging, FILES.run); if (!fs.existsSync(stagedRun)) { journal(out, { event: 'revise_abandoned', k: intent.k }); - fs.rmSync(intent.staging, { recursive: true, force: true }); + fs.rmSync(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<string, unknown>; settled: Array<Record<string, unknown>> }; 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); + publish(state, 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 1f06ebd9c..bec1b801e 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -167,7 +167,7 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void { ps.outside = state.outside_enabled ? { status: 'pending', attempts: [] } : notRun ? { status: 'disabled', outcome: 'not_run', reason: notRun.reason, detail: notRun.detail } : { status: 'disabled' }; ps.prompts = prompts; - journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts }); + journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) }); } export function nextStep(o: NextOptions): NextResult { @@ -213,7 +213,7 @@ export function nextStep(o: NextOptions): NextResult { catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; } const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate, dispatched_at: now() }; bindPromptResult(row.prompt, row.result); - journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate }); + journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), result: path.relative(state.out, row.result), estimate_usd: estimate }); (v.attempts ??= []).push(row); v.attempt = attempt; v.status = 'running'; result.attempts.push({ attempt, voice, prompt: row.prompt, result: row.result, model_family: familyHint(state, voice), new: true }); @@ -329,7 +329,7 @@ export function submitResult(o: SubmitOptions): SubmitResult { const sha = sha256File(bound); const provider = o.runner ?? 'host-subagent'; 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: provider, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt }); + journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: path.basename(bound), result_sha256: sha, model, runner: provider, 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, provider, output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>); @@ -406,17 +406,18 @@ export function resumeRun(out: string): ResumeResult { if (terminal.has(r.attempt)) { // A submit that journaled but died before run.json: re-bind from the journal and the file on disk. 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) { + const result = sub && path.resolve(out, sub.result); + if (sub && result && a && a.state !== 'submitted' && fs.existsSync(result) && sha256File(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(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 }; + state.artifacts[path.basename(result)] = { path: path.basename(result), sha256: sub.result_sha256 }; if (r.voice === 'native') state.native_model = sub.model; else state.outside_model = sub.model; rebound.push(r.attempt); } continue; } - if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: r.prompt, result: r.result, estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; } + if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: path.resolve(out, r.prompt), result: path.resolve(out, r.result), estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; } else if (a.state === 'dispatched') a.state = 'execution_unknown'; else continue; journal(out, { event: 'execution_unknown', attempt: r.attempt, phase: r.phase, voice: r.voice, by: 'resume' }); diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 3877c4b97..cbc72ad75 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -526,6 +526,16 @@ export const RESULT_CODES = { summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes', fix: 'render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`', }, + CHECKPOINT_INVALID: { + anchor: 'checkpoint-invalid', + summary: 'the autoplan checkpoint cannot be restored: its manifest is unreadable, a listed file is missing, escapes the checkpoint or does not match its sha256', + fix: 'restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (its `restore.md` names the command); nothing was written to --out', + }, + CHECKPOINT_TARGET_EXISTS: { + anchor: 'checkpoint-target-exists', + summary: '`gstack-autoplan resume --from` restores only into an empty or missing --out directory', + fix: 'pass a new --out directory, or resume the run already there without --from', + }, OWNER_REPLY_REQUIRED: { anchor: 'owner-reply-required', summary: 'an agent answered the gate (`--by agent:<id>`) without the owner\'s verbatim reply', diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index 973080e73..75690fba4 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -29,6 +29,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' }, { cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' }, { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, + { cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership' }, ]; /** The reply grammar `gstack-gate` parses, one form per line. */ @@ -41,7 +42,7 @@ export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [ /** Result codes the loop can print, in the order a run meets them. */ export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH', - 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'GSTACK_VERSION_CHANGED'] as const; + 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'CHECKPOINT_INVALID', 'GSTACK_VERSION_CHANGED'] as const; /** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */ const PENDING_FIX: Record<string, string> = { PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither', diff --git a/test/autoplan-checkpoint.test.ts b/test/autoplan-checkpoint.test.ts new file mode 100644 index 000000000..264b1a1c4 --- /dev/null +++ b/test/autoplan-checkpoint.test.ts @@ -0,0 +1,90 @@ +/** + * Durable checkpoint (next-wave B12): with GSTACK_AUTOPLAN_CHECKPOINT_DIR set, + * a run at rest is copied, hash-listed, to <dir>/<run>/; journal paths are + * run-relative, so `resume --from` restores the run on a new directory (even + * with the owner's plan file gone), takes ownership, and the run answers and + * exports a directory `gstack-artifact validate` accepts. A tampered, + * escaping or misplaced restore writes nothing. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +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 { ROOT, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-checkpoint-')); +afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); +const validate = (out: string) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); + +function checkpointed(name: string): { dir: string; out: string; cp: string; run: string; e: Record<string, string> } { + const dir = path.join(TMP, name); + const cpRoot = path.join(dir, 'drive'); + const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: cpRoot }); + const out = startSmallRun(dir); + runPhase(out, 'ceo'); + const next = autoplan(['next', '--out', out], { env: e }); + expect(next.stderr).not.toContain('CHECKPOINT:'); + for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`)); + const run = readRun(out); + expect(autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(out, 'eng', 'native'), '--model', 'anthropic/claude-opus-4-7'], { env: e }).status).toBe(0); + const gated = autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'outside', '--result', resultOf(out, 'eng', 'outside'), '--model', 'openai/gpt-6-astra'], { env: e }); + expect(gated.stdout).toContain('status=gate_pending'); + const cp = path.join(cpRoot, run.run); + expect(gated.stderr).toMatch(new RegExp(`^CHECKPOINT: ${cp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} status=gate_pending gate_rev=1 files=\\d+$`, 'm')); + return { dir, out, cp, run: run.run, e }; +} + +describe('checkpoint and restore', () => { + test('write at gate_pending, delete the run and the plan file, restore, resume, answer and export: validate passes', () => { + const c = checkpointed('roundtrip'); + const manifest = JSON.parse(fs.readFileSync(path.join(c.cp, 'checkpoint.json'), 'utf8')); + expect(manifest).toMatchObject({ schema_version: 1, run: c.run, status: 'gate_pending', gate_rev: 1 }); + expect(Object.keys(manifest.files)).toEqual(expect.arrayContaining(['run.json', 'attempts.jsonl', 'gate.json', 'decisions.jsonl'])); + expect(Object.keys(manifest.files)).not.toContain('run.lock'); + expect(fs.readFileSync(path.join(c.cp, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.cp}`); + for (const r of journal(c.out)) for (const k of ['prompt', 'result', 'staging']) if (typeof r[k] === 'string') expect(path.isAbsolute(r[k])).toBe(false); + fs.rmSync(c.out, { recursive: true, force: true }); + fs.rmSync(path.join(c.dir, 'plan.md')); + const moved = path.join(c.dir, 'elsewhere', 'run'); + const resumed = autoplan(['resume', '--out', moved, '--from', c.cp], { env: c.e }); + expect(resumed.status, resumed.stderr).toBe(0); + expect(resumed.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=.* status=gate_pending gate_rev=1 files=\\d+ `, 'm')); + expect(resumed.stdout).toMatch(/^PLAN_INPUT: .*plan\.md is missing; restored the checkpoint copy to .*elsewhere\/run\/plan-input\/plan\.md$/m); + expect(resumed.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${moved}`); + expect(journal(moved).find(r => r.event === 'restored')).toMatchObject({ host: os.hostname() }); + const state = readRun(moved); + expect(state.plan_input).toBe(path.join(moved, 'plan-input', 'plan.md')); + expect(state.active_plan).toBe(path.join(moved, 'plan-active.md')); + const answered = autoplan(['answer', '--out', moved, '--gate-rev', '1', '--reply', 'as-is', '--by', 'human'], { env: c.e }); + expect(answered.status, answered.stderr).toBe(0); + expect(answered.stdout).toContain('status=complete'); + expect(answered.stderr).toMatch(/^CHECKPOINT: .* status=complete gate_rev=1 /m); + expect(autoplan(['export', '--out', moved]).status).toBe(0); + const v = validate(moved); + expect(v.status, v.stdout + v.stderr).toBe(0); + }); + + test('a tampered file, an escaping path and a non-empty target are refused and write nothing', () => { + const c = checkpointed('refusals'); + const nonEmpty = path.join(c.dir, 'busy'); + fs.mkdirSync(nonEmpty, { recursive: true }); + fs.writeFileSync(path.join(nonEmpty, 'keep.txt'), 'x'); + const busy = autoplan(['resume', '--out', nonEmpty, '--from', c.cp]); + expect(busy.status).toBe(3); + expect(busy.stderr).toMatch(/\(CHECKPOINT_TARGET_EXISTS\)/); + expect(fs.readdirSync(nonEmpty)).toEqual(['keep.txt']); + fs.appendFileSync(path.join(c.cp, 'run', 'gate.json'), ' '); + const target = path.join(c.dir, 'restored'); + const tampered = autoplan(['resume', '--out', target, '--from', c.cp]); + expect(tampered.status).toBe(3); + expect(tampered.stderr).toMatch(/gate\.json does not match its sha256.*\(CHECKPOINT_INVALID\)/); + expect(fs.existsSync(target)).toBe(false); + const m = JSON.parse(fs.readFileSync(path.join(c.cp, 'checkpoint.json'), 'utf8')); + fs.writeFileSync(path.join(c.cp, 'checkpoint.json'), JSON.stringify({ ...m, files: { ...m.files, '../../escape.md': m.files['run.json'] } })); + const escaping = autoplan(['resume', '--out', target, '--from', c.cp]); + expect(escaping.status).toBe(3); + expect(escaping.stderr).toMatch(/escapes the checkpoint.*\(CHECKPOINT_INVALID\)/); + expect(fs.existsSync(target)).toBe(false); + }); +}); diff --git a/test/autoplan-driver.test.ts b/test/autoplan-driver.test.ts index 40adbad48..b7173dcdd 100644 --- a/test/autoplan-driver.test.ts +++ b/test/autoplan-driver.test.ts @@ -42,7 +42,7 @@ describe('autoplan/DRIVER.md content comes from the code', () => { test('the plan-specified commands and the amendment interlock are present', () => { for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed', - 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', 'PLAN_AMEND_PENDING', 'APPLY:']) { + 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', '--from <checkpoint>', 'PLAN_AMEND_PENDING', 'APPLY:']) { expect(doc).toContain(token); } }); From 46df7eb03f183c9f0f2c86d81e3d6afaa70ca76f Mon Sep 17 00:00:00 2001 From: garrytan <garrytan@gmail.com> Date: Thu, 15 Oct 2026 00:41:35 +0000 Subject: [PATCH 4/4] docs(autoplan): unattended contract and reply-unparsed fix move to rec, sealed results, settle and checkpoints (Lane B) docs/unattended.md: rec/as-is/rec except grammar, --owner-reply and recommend/--rec-hash, unverified pairing and the shared-proposal rule for b, amend_pending and revise --settle, attempt-owned result paths with the sealed .bound.md copy, render --all, and the checkpoint/resume --from paragraph. GATE_REPLY_UNPARSED's fix names rec/as-is (DRIVER.md regenerated). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/DRIVER.md | 2 +- docs/unattended.md | 75 ++++++++++++++++++++++++++------------------- lib/result-codes.ts | 2 +- 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 818ac1893..1e8fc3571 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -122,7 +122,7 @@ is the completion check. Copy the whole directory, never single files. - `MODEL_FAMILY_CONFLICT`: pass `--model <id>` of a different family (anthropic, openai, google, xai, deepseek, qwen, zai, moonshot, meta, mistral); a same-family result is not an outside voice - `CONSENSUS_MISSING`: resume the run so the phase closes through reconciliation; the Eng prompt is never sent without every prior phase summary - `GATE_REV_STALE`: render the current gate list, read it, and reply with its gate_rev -- `GATE_REPLY_UNPARSED`: reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous +- `GATE_REPLY_UNPARSED`: reply with `rec`, `as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `rec except <tokens>`; bare yes/no is ambiguous - `GATE_REC_STALE`: render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash` - `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>` - `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree diff --git a/docs/unattended.md b/docs/unattended.md index 8b866b11f..bf5919c9f 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -111,14 +111,14 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`, 3. **List what is pending.** ```bash jq -c 'select(.status=="pending")' <dir>/decisions.jsonl - gstack-gate render <dir>/gate.json # the numbered list and reply grammar + gstack-gate render <dir>/gate.json --all # the numbered list and reply grammar, every page ``` -4. **Answer the gate** by composing the reply grammar (`all`, `<id><option>` - tokens such as `d3b uc1a`, or `all except <tokens>`; bare yes/no is - rejected) and feeding it back with the gate revision you read: +4. **Answer the gate** by composing the reply grammar (`rec`, `as-is`, + `<id><option>` tokens such as `d3b uc1a`, or `rec except <tokens>`; bare + yes/no is rejected) and feeding it back with the gate revision you read: ```bash - gstack-gate parse <dir>/gate.json --reply "all except d3b" --gate-rev 1 --json - gstack-gate decisions <dir>/gate.json --reply "all except d3b" --gate-rev 1 >> <dir>/decisions.jsonl + gstack-gate parse <dir>/gate.json --reply "rec except d3b" --gate-rev 1 --json + gstack-gate decisions <dir>/gate.json --reply "rec except d3b" --gate-rev 1 >> <dir>/decisions.jsonl ``` A stale revision is `GATE_REV_STALE` and a reply with unparsed tokens is `GATE_REPLY_UNPARSED`; both exit `1` and write nothing. @@ -150,8 +150,8 @@ $AP next --out <dir> --plan <plan.md> [--ui|--no-ui] [--developer-tool] [--no-dx # SCOPE: ui=yes (...) dx=yes (detected: ...) source=ui:detected,dx:detected # DRIVER: <root>/autoplan/DRIVER.md # Skipped sections: none (scope: ui=yes,dx=yes,source=ui:detected,dx:detected; checklist=loaded) -# ATTEMPT: <run>-a1 phase=ceo voice=native prompt=<dir>/ceo-native-prompt.md result=<dir>/ceo-native.md model_family=... -# ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.md model_family=differs from the native reviewer +# ATTEMPT: <run>-a1 phase=ceo voice=native prompt=<dir>/ceo-native-prompt.md result=<dir>/ceo-native.a1.md model_family=... +# ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.a2.md model_family=differs from the native reviewer # SPEND: spent=0.00 reserved=0.00 unknown=0(0.00) worst_case=0.00 cap=none # GSTACK_RESULT: skill=autoplan status=awaiting_result run=<dir> ``` @@ -173,18 +173,21 @@ the plan. `release --attempt <id>` returns its reservation). 2. **Submit each result exactly once.** ```bash - $AP submit --out <dir> --phase ceo --voice native --result <dir>/ceo-native.md --model <id> --attempt <run>-a1 - $AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.md --model <id> --attempt <run>-a2 [--runner host-subagent] [--usage-usd <n>] + $AP submit --out <dir> --phase ceo --voice native --result <dir>/ceo-native.a1.md --model <id> --attempt <run>-a1 + $AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.a2.md --model <id> --attempt <run>-a2 [--runner host-subagent (default)] [--usage-usd <n>] # BOUND: <run>-a1 phase=ceo voice=native model=<id> findings=7 sha256=<result hash prefix> # PHASE_CLOSED: ceo confirmed=4 disagree=1 new=2 native_only=1 coverage=both ``` + Submit seals the bytes it bound as `<phase>-<voice>.<attempt>.bound.md` + (read-only); a later write to the reviewer's file changes nothing bound, + and another attempt's result file is refused (`ATTEMPT_MISMATCH`). Binding checks the receipt line against the phase snapshot (`RESULT_RECEIPT_MISSING`), parses the canonical findings fence (`RESULT_FINDINGS_MISSING`), refuses an outside model from the native family (`MODEL_FAMILY_CONFLICT`), and refuses a second bind of the same attempt or a bind while the phase is not awaiting it (`ATTEMPT_MISMATCH`, `PHASE_NOT_AWAITING`, exit 3). Both voices in closes the phase: the - reconciliation (`confirmed` / `disagree` / `new` per outside finding, with + reconciliation (`confirmed` / `unverified` / `disagree` / `new` per outside finding, with disagreement and resolution kept separate) is written to `<phase>-consensus.{json,md}`, and every later phase's prompts (DX, design and Eng alike) carry each closed predecessor's consensus as data; a phase @@ -194,40 +197,50 @@ the plan. 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 <dir> --gate-rev 1 --reply "all" --by human # every recommendation the owner may delegate; agreeing plan changes stay pending - $AP answer --out <dir> --gate-rev 1 --reply "all as-is" --by human # APPROVED: keeps every direction, no new paid phase - $AP answer --out <dir> --gate-rev 1 --reply "all except d1b" --by human # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ... + $AP answer --out <dir> --gate-rev 1 --reply "rec" --by human # every recommendation, plan changes included (APPLY: ...; amend_pending) + $AP answer --out <dir> --gate-rev 1 --reply "as-is" --by human # APPROVED: keeps every direction, no new paid phase + $AP answer --out <dir> --gate-rev 1 --reply "rec except d1b" --by agent:<id> --owner-reply "<owner's words>" ``` `--by human` means the owner typed the tokens; `--by agent:<thread or id>` - means an agent relayed or decided them. An unattended run refuses an answer - without it (`ANSWER_BY_REQUIRED`, exit 3) and records the value as + means an agent relayed them, and then `--owner-reply` (or `-` for stdin, + `--owner-reply-file <path>`) stores the owner's words verbatim + (`OWNER_REPLY_REQUIRED`); owner words that parse to different choices are + refused (`GATE_OWNER_REPLY_MISMATCH`). An unattended run refuses an answer + without `--by` (`ANSWER_BY_REQUIRED`, exit 3) and records the value as `answered_by` on every decision row and in the review record's gate line, so a gate an agent closed is distinguishable from one the owner closed. - 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`. `all` never answers an item recommended `b`: - that would change the owner's stated direction on their behalf, so those - items stay pending (`GATE_PENDING: uc1 unanswered (reply with uc1a|uc1b; all - does not answer items recommended b)`) until their own token arrives, and - the rendered gate names them under the reply grammar. Every accepted plan change prints + `gstack-autoplan recommend` changes one recommendation before relay; it + bumps the gate's `rec_rev`, and a later `rec` needs the `--rec-hash` from + the render the owner saw (`GATE_REC_STALE`). + A user challenge both voices raised on a confirmed finding (a title match, + not file:line proximity alone, which is `unverified`) with the same + `changes` value is recommended as `b` only with one shared proposal: a + `--reconcile` record naming it, or the same remedy from both voices. + Different or missing remedies keep `a`, and option b shows both verbatim + with the basis. `rec` (alias `all`) takes every recommendation, so it + accepts the items recommended `b`. Every accepted plan change prints `APPLY: <decision id> <title> -> <chosen text>` and becomes a pending amendment: `next` refuses with `PLAN_AMEND_PENDING` and `export` refuses a - `complete` run until `revise --incorporates` names it (see below). + `complete` run until `revise --incorporates` names it, or `revise --settle + <id>` settles a packaging or ordering change that needs no plan edit. 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 later reply never overwrites an earlier answer. A choice - that changes the plan reopens that phase plus Eng, archives their bound - files as `*.r<rev>.*`, and the next gate is `gate_rev + 1`; `p1d` reruns - every phase on the current plan; `p1e` ends `incomplete`. + keeps the gate; a later reply never overwrites an earlier answer. An + accepted change reopens nothing at answer (status `amend_pending`); `revise` + reopens each phase whose reviewed plan changed plus Eng, archives their + prompts and consensus as `*.r<rev>.*` (sealed `*.bound.md` results stay in + place), 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 <dir>` writes `plan.md` (with the `## URGENT, outside this plan` block first), `review-record.md`, `tasks.jsonl`, `decisions.jsonl`, `findings.jsonl`, `timing.json` and `run.json`; then `gstack-artifact validate <dir>/run.json` and the consumption steps above apply unchanged. `$AP status --out <dir>` and - `resume` work after the run directory moves (paths are re-rooted). + `resume` work after the run directory moves (paths are re-rooted; journal + paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, a run + at rest is copied, hash-listed, to `<dir>/<run>/`; `$AP resume --out <new + dir> --from <dir>/<run>` verifies and restores it (`CHECKPOINT_INVALID`). Concurrency and recovery: every state write is a locked read-modify-write (`run.lock`; a second writer sees `RUN_LOCKED`), attempts are journaled to diff --git a/lib/result-codes.ts b/lib/result-codes.ts index cbc72ad75..8b2207a58 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -112,7 +112,7 @@ export const RESULT_CODES = { GATE_REPLY_UNPARSED: { anchor: 'gate-reply-unparsed', summary: 'part of the reply matched no gate item or option, so nothing was applied', - fix: 'reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous', + fix: 'reply with `rec`, `as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `rec except <tokens>`; bare yes/no is ambiguous', }, ANSWER_BY_REQUIRED: { anchor: 'answer-by-required',