/** * `gstack-autoplan revise --out --plan ` (plan B1c, item 9): the run * reviews a revised plan in the same run directory. Drift is the * Implementation-plan hash: every opened phase whose reviewed hash differs is * reopened (open attempts superseded), newly in-scope phases are added, the * archives keep `.r` names with `.v` only on collision, revise at * gate_pending is its own journal event and the next gate is gate_rev + 1. * `next` and `answer` print the drift hints; a plan missing one of the two * sections refuses with PLAN_SECTIONS_INVALID and `next --scaffold` reviews a * scaffolded copy. Runs on the simulated Capy host. */ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; import { SMALL_PLAN } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; const r = capyRunner(); afterAll(() => r.cleanup()); let n = 0; const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 60 s, dead-letter after 8 attempts'); const RECORD_ONLY = SMALL_PLAN + '\nReviewed once; notes only.\n'; const DX_V2 = SMALL_PLAN.replace('Tests cover', 'Add a CLI flag --max-retries and an error message for a bad value. Tests cover'); function start(args: string[] = [], text = SMALL_PLAN): { out: string; dir: string; plan: string } { const dir = path.join(r.host.base, `rev${++n}`); const plan = writePlan(dir, text); const out = path.join(dir, 'run'); const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]); if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`); return { out, dir, plan }; } const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; }; function gated(): { out: string; dir: string; plan: string } { const s = start(); phaseVia(r, s.out, 'ceo', { native: [], outside: [] }); expect(phaseVia(r, s.out, 'eng', { native: [], outside: [] }).outside.stdout).toContain('status=gate_pending'); return s; } describe('revise', () => { test('revise after ceo closes reopens ceo, and dx and eng then review the v2 snapshot', () => { const s = start(['--developer-tool']); phaseVia(r, s.out, 'ceo', { native: [], outside: [] }); const v1 = readRunJson(s.out).phases.ceo.snapshot; const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]); expect(rev.status, rev.stderr).toBe(0); expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo$/m); expect(rev.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running'); for (const phase of ['ceo', 'dx', 'eng']) { const p = phaseVia(r, s.out, phase, { native: [], outside: [] }); expect(p.next.stdout).toMatch(new RegExp(`^PHASE: ${phase} snapshot=`, 'm')); expect(fs.readFileSync(path.join(s.out, `${phase}-native-prompt.md`), 'utf8')).toContain('dead-letter after 8 attempts'); expect(fs.readFileSync(path.join(s.out, `${phase}-outside-prompt.md`), 'utf8')).toContain('dead-letter after 8 attempts'); } const run = readRunJson(s.out); expect(run.status).toBe('gate_pending'); expect(new Set(['ceo', 'dx', 'eng'].map(p => run.phases[p].snapshot)).size).toBe(1); expect(run.phases.ceo.snapshot).not.toBe(v1); expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'plan_revised')).toHaveLength(1); expect(fs.existsSync(path.join(s.out, 'ceo-native-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', () => { const s = start(); phaseVia(r, s.out, 'ceo', { native: [], outside: [] }); const same = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, RECORD_ONLY)]); expect(same.status, same.stderr).toBe(0); expect(same.stdout).toMatch(/reopened=none/); expect(readRunJson(s.out).phases.ceo.closed_at).toBeTruthy(); const eng = r.ap(['next', '--out', s.out]); expect(eng.stdout).toMatch(/^PHASE: eng /m); const open = readRunJson(s.out).phases.eng; const changed = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]); expect(changed.stdout).toMatch(/reopened=ceo,eng/); const superseded = readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'superseded').map(e => e.attempt); expect(superseded.sort()).toEqual([open.native.attempt, open.outside.attempt].sort()); }); test('revise at gate_pending is its own journal event, reruns on v2, and the next gate is gate_rev 2', () => { const s = gated(); const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir), '--json']); expect(rev.status, rev.stderr).toBe(0); expect(JSON.parse(rev.stdout)).toMatchObject({ status: 'running', reopened: ['ceo', 'eng'] }); const j = readJsonl(path.join(s.out, 'attempts.jsonl')); expect(j.find(e => e.event === 'plan_revised')).toMatchObject({ at_gate_rev: 1 }); expect(j.filter(e => e.event === 'answered')).toEqual([]); expect(fs.existsSync(path.join(s.out, 'gate.r1.json'))).toBe(true); phaseVia(r, s.out, 'ceo', { native: [], outside: [] }); phaseVia(r, s.out, 'eng', { native: [], outside: [] }); expect(fs.readFileSync(path.join(s.out, 'eng-native-prompt.md'), 'utf8')).toContain('dead-letter after 8 attempts'); expect(readRunJson(s.out)).toMatchObject({ status: 'gate_pending', gate_rev: 2 }); }); test('a second revise never overwrites the first archive', () => { const s = gated(); expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0); const first = fs.readFileSync(path.join(s.out, 'ceo-native-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-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', () => { const s = start(); expect(readRunJson(s.out).phase_order).toEqual(['ceo', 'eng']); const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, DX_V2)]); expect(rev.status, rev.stderr).toBe(0); const run = readRunJson(s.out); expect(run.phase_order).toEqual(['ceo', 'dx', 'eng']); expect(run.required_phases).toEqual(['ceo', 'dx', 'eng']); expect(run.phases.dx.skipped).toBeUndefined(); expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0); expect(readRunJson(s.out).phase_order).toEqual(['ceo', 'dx', 'eng']); }); test('an attested bind is refused when the plan was revised after its attempt was dispatched', () => { const s = start(); const attempt = readRunJson(s.out).phases.ceo.native.attempt; expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, RECORD_ONLY)]).stdout).toMatch(/reopened=none/); const rec = path.join(s.dir, 'hand.md'); fs.writeFileSync(rec, 'Hand review.\n\nNo findings.\n'); const res = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'ran it', '--attempt', attempt]); expect(res.status).toBe(1); expect(res.stderr).toMatch(/revised after .*dispatched.*\(RESULT_RECEIPT_MISSING\)$/m); }); test('naming a decision that is not a pending amendment refuses with AMENDMENT_UNKNOWN and never asks for the edit just made', () => { const s = start(); const before = fs.readFileSync(path.join(s.out, 'run.json'), 'utf8'); for (const args of [['--plan', v2File(s.dir), '--incorporates', 'd1b'], ['--withdraw', 'd1b']]) { const res = r.ap(['revise', '--out', s.out, ...args]); expect(res.status).toBe(3); expect(res.stderr).toMatch(/d1b is not a pending amendment \(pending: none\).*\(AMENDMENT_UNKNOWN\)$/m); expect(res.stderr).toContain(`; fix: ${RESULT_CODES.AMENDMENT_UNKNOWN.fix} (AMENDMENT_UNKNOWN)`); expect(res.stderr).not.toContain('PLAN_AMEND_PENDING'); expect(res.stderr).not.toContain('edit the plan'); } expect(fs.readFileSync(path.join(s.out, 'run.json'), 'utf8')).toBe(before); }); }); describe('drift hints', () => { test('next prints PLAN_CHANGED when the source plan changed since the snapshot', () => { const s = start(); fs.writeFileSync(s.plan, V2); const res = r.ap(['next', '--out', s.out]); expect(res.stdout).toMatch(new RegExp(`^PLAN_CHANGED: ${s.plan.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} differs from the reviewed plan; run gstack-autoplan revise --out \\S+ --plan \\S+$`, 'm')); }); test('answer prints PLAN_SOURCE_CHANGED when a reply reopens phases after the source plan changed', () => { const s = gated(); fs.writeFileSync(s.plan, V2); const res = r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']); expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\)/m); expect(res.stdout).toMatch(/^PLAN_SOURCE_CHANGED: .* differs from the reviewed plan; run gstack-autoplan revise --out \S+ --plan \S+$/m); }); }); describe('plan sections', () => { test('only an Implementation plan: next refuses with PLAN_SECTIONS_INVALID and stdout; --scaffold reviews a copy', () => { const dir = path.join(r.host.base, 'scaffold'); const text = '# P\n\n## Implementation plan\n\nDo the thing in lib/x.ts with a 30 s timeout.\n'; const plan = writePlan(dir, text); const refused = r.ap(['next', '--out', path.join(dir, 'a'), '--plan', plan, '--no-ui']); expect(refused.status).toBe(3); expect(refused.stderr).toMatch(/no "## Review record".*--scaffold.*\(PLAN_SECTIONS_INVALID\)$/m); expect(refused.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused'); const ok = r.ap(['next', '--out', path.join(dir, 'b'), '--plan', plan, '--no-ui', '--scaffold']); expect(ok.status, ok.stderr).toBe(0); expect(ok.stdout).toMatch(/^SCAFFOLDED: added "## Review record"/m); expect(ok.stdout).toMatch(/^PHASE: ceo /m); expect(fs.readFileSync(plan, 'utf8')).toBe(text); expect(fs.readFileSync(path.join(dir, 'b', 'plan-source.md'), 'utf8')).toContain('## Review record'); }); test('duplicate or reversed headings refuse with the code even with --scaffold; an empty section says so', () => { const dir = path.join(r.host.base, 'ambiguous'); for (const [name, text] of [['dup', '# P\n\n## Implementation plan\n\nA.\n\n## Implementation plan\n\nB.\n\n## Review record\n'], ['rev', '# P\n\n## Review record\n\nx\n\n## Implementation plan\n\nA.\n']] as const) { const plan = writePlan(path.join(dir, name), text); const res = r.ap(['next', '--out', path.join(dir, name, 'run'), '--plan', plan, '--no-ui', '--scaffold']); expect(res.status).toBe(3); expect(res.stderr).toMatch(/\(PLAN_SECTIONS_INVALID\)$/m); } const plan = writePlan(path.join(dir, 'empty'), '# P\n\n## Implementation plan\n\n## Review record\n'); const res = r.ap(['next', '--out', path.join(dir, 'empty', 'run'), '--plan', plan, '--no-ui']); expect(res.status).toBe(3); expect(res.stderr).toMatch(/Implementation plan is empty.*\(PLAN_SECTIONS_INVALID\)$/m); }); });