Files
gstack/test/autoplan-record.test.ts
T
garrytanandcapy-ai[bot] 77ab139675 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 <text>|-, --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 <phase>-<voice>.<attempt>.md; submit refuses another
    attempt's file (ATTEMPT_MISMATCH) and binds a sealed 0444
    <phase>-<voice>.<attempt>.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 <id|d4>`
    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>
2026-10-15 00:37:42 +00:00

157 lines
10 KiB
TypeScript

/**
* `gstack-autoplan submit --record` (plan B1b, item 7): a phase reviewed by a
* host subagent binds from its own record. Accepted shapes are the canonical
* fence, an exact `No findings.` line, or exactly one findings table or
* severity-tagged list; anything else, or two shapes, is refused. A record
* without the INPUT receipt binds only with `--attest <reason>` and an
* explicit `--attempt` (receipt: attested); a wrong receipt always refuses.
* `--voice outside --unavailable|--failed` closes the phase native_only, and
* the terminal outcome is journaled so `next` and `resume` never redispatch
* or resurrect it. Runs on the simulated Capy host.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { 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());
let n = 0;
function start(): string {
const dir = path.join(r.host.base, `rec${++n}`);
const plan = writePlan(dir);
const out = path.join(dir, 'run');
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui']);
if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`);
return out;
}
const write = (out: string, name: string, text: string) => { const f = path.join(path.dirname(out), name); fs.writeFileSync(f, text); return f; };
const snapshotOf = (out: string, phase = 'ceo') => readRunJson(out).phases[phase].snapshot as string;
const TABLE = `# CEO review (hand-run subagent)
Prose about the plan.
| # | Severity | Finding | File | Fix |
|---|---|---|---|---|
| 1 | High | Retry cap is unbounded on restart | lib/worker.ts:10 | persist the cap |
| 2 | Medium | Split persistence change into its own plan | | split it |
`;
const list = (sha: string) => `INPUT: ceo ${sha}
Review prose.
- [High] Jitter bounds are untested
- [Low] Name the environment variable
`;
describe('submit --record', () => {
test('a table record without a receipt is refused unless attested, and an attested bind names its attempt', () => {
const out = start();
const rec = write(out, 'ceo-table.md', TABLE);
const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
const bare = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5']);
expect(bare.status).toBe(1);
expect(bare.stderr).toMatch(/--attest.*\(RESULT_RECEIPT_MISSING\)$/m);
const noAttempt = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'subagent ran from the prompt file']);
expect(noAttempt.status).toBe(3);
expect(noAttempt.stderr).toMatch(/--attempt.*\(ATTEMPT_MISMATCH\)$/m);
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before);
const attempt = readRunJson(out).phases.ceo.native.attempt;
const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'subagent ran from the prompt file', '--attempt', attempt]);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toMatch(new RegExp(`^BOUND: ${attempt} phase=ceo voice=native .*findings=2 .*receipt=attested`, 'm'));
const v = readRunJson(out).phases.ceo.native;
expect(v).toMatchObject({ status: 'completed', receipt: 'attested', attest_reason: 'subagent ran from the prompt file', findings_source: 'table' });
expect(fs.readFileSync(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);
});
test('a record with the receipt binds a severity-tagged list without --attempt, and `No findings.` binds zero rows', () => {
const out = start();
const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', write(out, 'l.md', list(snapshotOf(out))), '--model', 'anthropic/claude-opus-5-5']);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^BOUND: \S+-a1 phase=ceo voice=native .*findings=2 .*receipt=verified/m);
expect(readRunJson(out).phases.ceo.native).toMatchObject({ receipt: 'verified', findings_source: 'list' });
const none = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--record', write(out, 'none.md', `INPUT: ceo ${snapshotOf(out)}\n\nRead it all.\n\nNo findings.\n`), '--model', 'openai/gpt-6.1-sol']);
expect(none.status, none.stderr).toBe(0);
expect(none.stdout).toMatch(/^BOUND: \S+-a2 phase=ceo voice=outside .*findings=0/m);
expect(none.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=both/m);
});
test('refusals write nothing: a wrong receipt (even attested), prose with no findings shape, two shapes, an unknown severity', () => {
const out = start();
const attempt = readRunJson(out).phases.ceo.native.attempt;
const before = { run: fs.readFileSync(path.join(out, 'run.json'), 'utf8'), journal: readJsonl(path.join(out, 'attempts.jsonl')).length };
const submit = (text: string, extra: string[] = []) => r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--record', write(out, `bad${++n}.md`, text), '--model', 'anthropic/claude-opus-5-5', ...extra]);
const wrong = submit(`INPUT: ceo ${'0'.repeat(64)}\n\nNo findings.\n`, ['--attest', 'trust me', '--attempt', attempt]);
expect(wrong.status).toBe(1);
expect(wrong.stderr).toMatch(/\(RESULT_RECEIPT_MISSING\)$/m);
const prose = submit(`INPUT: ceo ${snapshotOf(out)}\n\nThe plan looks fine overall but I have concerns.\n`);
expect(prose.status).toBe(1);
expect(prose.stderr).toMatch(/table.*list.*No findings\..*\(RESULT_FINDINGS_MISSING\)$/m);
expect(prose.stderr).toContain(`; fix: ${RESULT_CODES.RESULT_FINDINGS_MISSING.fix} (RESULT_FINDINGS_MISSING)`);
const two = submit(`${list(snapshotOf(out))}\n${TABLE}`);
expect(two.status).toBe(1);
expect(two.stderr).toMatch(/more than one findings shape.*\(RESULT_FINDINGS_MISSING\)$/m);
const spicy = submit(`INPUT: ceo ${snapshotOf(out)}\n\n| Severity | Title |\n|---|---|\n| Spicy | Too hot |\n`);
expect(spicy.status).toBe(1);
expect(spicy.stderr).toMatch(/row 1: severity "Spicy".*\(RESULT_FINDINGS_MISSING\)$/m);
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before.run);
expect(readJsonl(path.join(out, 'attempts.jsonl')).length).toBe(before.journal);
});
test('the canonical --result path binds without --attempt when exactly one attempt is open', () => {
const out = start();
expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', 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);
});
test('a phase that next has not opened is refused with the next command', () => {
const out = start();
const res = r.ap(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--record', write(out, 'eng.md', 'No findings.\n'), '--model', 'anthropic/claude-opus-5-5']);
expect(res.status).toBe(3);
expect(res.stderr).toMatch(new RegExp(`run gstack-autoplan next --out ${out.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*\\(PHASE_NOT_AWAITING\\)$`, 'm'));
});
test('--voice outside --unavailable closes the phase native_only; next never redispatches it and resume never resurrects it', () => {
const out = start();
const outsideAttempt = readRunJson(out).phases.ceo.outside.attempt;
const un = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--unavailable', 'no codex on this host']);
expect(un.status, un.stderr).toBe(0);
expect(un.stdout).toMatch(/^OUTSIDE: ceo unavailable/m);
const j = readJsonl(path.join(out, 'attempts.jsonl'));
expect(j.find(e => e.event === 'outside_unavailable')).toMatchObject({ phase: 'ceo', attempt: outsideAttempt, detail: 'no codex on this host' });
expect(readRunJson(out).phases.ceo.outside).toMatchObject({ status: 'unavailable', detail: 'no codex on this host' });
const again = r.ap(['next', '--out', out]);
expect(again.status, again.stderr).toBe(0);
expect(again.stdout).not.toMatch(/voice=outside/);
expect(again.stdout).toMatch(/^ATTEMPT: \S+ phase=ceo voice=native .*\(open\)$/m);
const nativeAttempt = readRunJson(out).phases.ceo.native.attempt;
const resumed = r.ap(['resume', '--out', out]);
expect(resumed.stdout).toMatch(/execution_unknown=1 /);
expect(resumed.stdout).toContain(`EXECUTION_UNKNOWN: ${nativeAttempt}\n`);
expect(resumed.stdout).not.toContain(outsideAttempt);
expect(readRunJson(out).phases.ceo.outside.status).toBe('unavailable');
expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0);
const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', 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', 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);
expect(readRunJson(out).phases.ceo.outside).toMatchObject({ status: 'failed', detail: 'runner exited 1' });
});
});