fix(autoplan): review pass 1 — standing answers bind to the run's project and refuse recommend-edited gates, cost is an upper bound and reaches JSON and the Captain brief

Review pass 1 (6 specialists + red team) on the standing-approval and gate-cost change:
- the standing lookup resolves the approvals record of the repository the run's plan lives in,
  never the caller's cwd (red team: project A's row answered project B's gate)
- standingAnswer refuses a gate that `recommend` edited (rec_rev > 0 or recommended_by), a
  missing or unreadable findings.jsonl, and an outside-plan finding; the STANDING line flattens
  control characters so a crew-writable row cannot forge a protocol line
- coversAutoplanRec matches the recommendation words in the owner's words only and rejects
  negations; STANDING_APPROVAL_ABSENT's fix names every required gstack-approvals flag
- recReopens is the upper bound of the answer/revise rules (a plan change counts every phase,
  a round grant its phase plus Eng); gate_cost rides the JSON payloads, `run` prints the lines
  at its final gate, and `gstack-owner-brief render --captain` appends a ### Cost section
- locate keeps the other mirror roots as read_also so rows mirrored under an earlier rule stay
  visible; gstack-approvals path prints READ_ALSO
- the Eng phase edit is size-neutral: five nearby phrases shortened so every host's autoplan
  SKILL.md stays at its size-guard pin
- tests: isolated fixtures, the run's plan inside its own git repo, cross-project refusal,
  amend_pending vs complete, recommend-then-standing, injection, gate_cost JSON, Captain brief

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 21:49:10 +00:00
1 parent e6a094ed0e
commit ff2bcd8686
14 files changed
+266 -134

No files matched your search

+122 -38
View File
@@ -18,7 +18,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 { buildRow, captainApprovalsDir, coversAutoplanRec, locate, standingRecApproval, type ApprovalRow } from '../lib/approval-record';
import { buildRow, captainApprovalsDir, coversAutoplanRec, locate, readRecord, standingRecApproval, type ApprovalRow } from '../lib/approval-record';
import { gateCost, recReopens, renderGateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
import type { GateList } from '../lib/gate-list';
import { attemptsOf, autoplan, env, readRun, resultOf, review, ROOT, startSmallRun } from './helpers/autoplan-run-fixture';
@@ -43,12 +43,18 @@ const row = (over: Partial<Parameters<typeof buildRow>[0]> = {}, branch = 'main'
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
describe('E16: the gate prints what it cost and what rec adds', () => {
test('recReopens names each plan-changing recommendation\'s phase plus Eng last, and nothing when no recommendation changes the plan', () => {
expect(recReopens(list('b'), ['ceo', 'design', 'eng'])).toEqual(['ceo', 'eng']);
test('recReopens is the upper bound of the answer and revise rules: a plan change counts every phase, a recommended round grant its phase plus Eng, nothing otherwise', () => {
expect(recReopens(list('b'), ['ceo', 'design', 'eng'])).toEqual(['ceo', 'design', 'eng']);
expect(recReopens(list('a'), ['ceo', 'design', 'eng'])).toEqual([]);
expect(recReopens(list('b'), ['ceo', 'design'])).toEqual(['ceo']);
const carried = list('b'); carried.items[0]!.carried = { from: 'x', chosen: 'b', gate_rev: 1 };
expect(recReopens(carried, ['ceo', 'eng'])).toEqual([]);
const agent = list('b'); agent.items[0]!.reopens = false;
expect(recReopens(agent, ['ceo', 'eng'])).toEqual([]);
const grant = list('a');
grant.items.splice(1, 0, { id: 'c1', title: 'design at its round cap', options: [{ key: 'a', text: 'accept as deferred', changes_plan: false }, { key: 'b', text: 'one more round', changes_plan: false }], recommended: 'b', kind: 'approval', phase: 'design', cap: { phase: 'design', kind: 'deferred' } });
expect(recReopens(grant, ['ceo', 'design', 'dx', 'eng'])).toEqual(['design', 'eng']);
grant.items[1]!.recommended = 'a';
expect(recReopens(grant, ['ceo', 'design', 'dx', 'eng'])).toEqual([]);
});
test('the lines price the reopen at the per-voice reservation, two voices each with the outside voice on, and name unknown host attempts', () => {
@@ -56,22 +62,21 @@ describe('E16: the gate prints what it cost and what rec adds', () => {
fs.writeFileSync(path.join(out, 'spend.json'), JSON.stringify({ schema_version: 1, cap_usd: 40, spent_usd: 1.5, reserved: [], released: [],
settled: [{ attempt: 'a1', usd: 1.5, reserved_usd: 5, at: 't' }, { attempt: 'a2', usd: 'unknown', reserved_usd: 5, at: 't' }] }));
const c = gateCost(out, list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 40 });
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'eng'], voices: 4, per_voice_usd: 5, rec_more_usd: 20 });
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'design', 'eng'], voices: 6, per_voice_usd: 5, rec_more_usd: 30 });
const lines = renderGateCost(c);
expect(lines[0]).toBe('GATE_SPEND: spent=1.50 worst_case=6.50 cap=40.00 headroom=33.50 attempts=2; 1 host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host\'s cost view)');
expect(lines[1]).toBe('GATE_REC_COST: rec reopens ceo,eng ≈ 4 voice(s) × 5.00 = +20.00 (estimate at the run\'s per-voice reservation) → headroom 13.50');
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens ceo, eng: about +20.00 more.');
const tight = path.join(out, 'tight.json');
fs.writeFileSync(tight, fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', '"cap_usd":10'));
const over = renderGateCost(gateCost(out, list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10 }, { spendFile: tight }));
expect(lines[1]).toBe('GATE_REC_COST: rec reopens up to ceo,design,eng ≈ 6 voice(s) × 5.00 = +30.00 at most (the run\'s per-voice reservation) → headroom 3.50');
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens up to ceo, design, eng: at most +30.00 more.');
const variant = (name: string, cap: string) => { const d = path.join(TMP, name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'spend.json'), fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', `"cap_usd":${cap}`)); return d; };
const over = renderGateCost(gateCost(variant('cost-tight', '10'), list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10 }));
expect(over[1]).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
const uncapped = path.join(out, 'uncapped.json');
fs.writeFileSync(uncapped, fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', '"cap_usd":null'));
const none = renderGateCost(gateCost(out, list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null }, { spendFile: uncapped }));
const uncapped = gateCost(variant('cost-uncapped', 'null'), list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null });
const none = renderGateCost(uncapped);
expect(none[0]).toContain('cap=none headroom=none');
expect(none[1]).toBe('GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00');
expect(gateCostSentence(uncapped)).toBe('Cost so far: spent 1.50, worst case 6.50 (no cap set, 1 host attempt(s) unknown and counted at reservation; 2 reviewer attempt(s)). Taking every recommendation reopens nothing: no further spend.');
const empty = gateCost(path.join(TMP, 'no-ledger'), list('b'), { phase_order: ['ceo', 'eng'], outside_enabled: true, spend_cap_usd: null }, { defaultPerVoiceUsd: 5 });
expect(empty).toMatchObject({ attempts: 0, per_voice_usd: 5, rec_more_usd: 20 });
expect(empty).toMatchObject({ attempts: 0, per_voice_usd: 5, rec_more_usd: 20, reopens: ['ceo', 'eng'] });
});
});
@@ -101,9 +106,24 @@ describe('E22: the mirror honours the Captain-named approvals directory', () =>
expect(captainApprovalsDir({ ...base, GSTACK_CAPY: undefined })).toBeUndefined();
});
test('gstack-approvals path names the rule that chose the mirror', () => {
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...base } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
expect(r.stdout).toContain('(captain drive)');
test('the other mirror roots are read: a row mirrored under project-user before the Captain created approvals/ stays visible, and path names both', () => {
const h = fs.mkdtempSync(path.join(TMP, 'home-'));
const d = path.join(h, '.capy', 'drive');
fs.mkdirSync(path.join(d, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
const e = { ...base, HOME: h, GSTACK_STATE_ROOT: path.join(TMP, 'state-ra') } as NodeJS.ProcessEnv;
const before = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in before) throw new Error(before.message);
expect(before.read_also).toEqual([]);
fs.mkdirSync(path.dirname(before.mirror!), { recursive: true });
fs.writeFileSync(before.mirror!, JSON.stringify(row({}, 'lane-x')) + '\n');
fs.mkdirSync(path.join(d, 'captain-minder', 'approvals'), { recursive: true });
const after = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in after) throw new Error(after.message);
expect(after.mirror_source).toBe('captain drive');
expect(after.read_also).toEqual([before.mirror!]);
expect(readRecord(after).rows.map(r => r.id)).toEqual([row({}, 'lane-x').id]);
expect(standingRecApproval(after)?.id).toBe(row({}, 'lane-x').id);
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...e } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
expect(r.stdout).toContain(`MIRROR: ${after.mirror} (captain drive)`);
expect(r.stdout).toContain(`READ_ALSO: ${before.mirror}`);
});
});
@@ -112,8 +132,10 @@ describe('E21: a standing approval answers rec without a round trip', () => {
expect(coversAutoplanRec(row())).toBe(true);
expect(coversAutoplanRec(row({ outcome: 'approved' }))).toBe(false);
expect(coversAutoplanRec(row({ ask: 'merges', scope: 'merge when green' }))).toBe(false);
expect(coversAutoplanRec(row({ words: 'do it', scope: 'autoplan gates' }))).toBe(false);
expect(coversAutoplanRec(row({ words: 'do it', scope: 'autoplan gates: take the recommendations' }))).toBe(false);
expect(coversAutoplanRec(row({ words: 'rec', scope: 'autoplan' }))).toBe(true);
expect(coversAutoplanRec(row({ words: 'never take the recommendations for autoplan' }))).toBe(false);
expect(coversAutoplanRec(row({ words: 'do not assume rec' }))).toBe(false);
});
test('standingRecApproval reads every branch\'s record, local and mirror, newest standing row first', () => {
@@ -132,14 +154,18 @@ describe('E21: a standing approval answers rec without a round trip', () => {
});
/** Both voices challenge the same item: the gate recommends the plan change (b) and `rec` would reopen phases. */
function gateRun(name: string, extraFinding: Record<string, unknown> = {}): string {
const out = startSmallRun(path.join(TMP, name));
/** The run's plan lives in its own git repository, so the standing lookup resolves that project's record, never the caller's cwd. */
function gateRun(name: string, extraFinding: Record<string, unknown> = {}, o: { challenge?: boolean; json?: boolean } = {}): string {
const repo = path.join(TMP, name);
fs.mkdirSync(repo, { recursive: true });
expect(spawnSync('git', ['init', '-q', '-b', 'lane-z', repo], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
const out = startSmallRun(repo);
const challenge = o.challenge === false ? [] : [{ 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 native = JSON.stringify([
{ id: 'F1', severity: 'High', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'print PASS/FAIL per component', ...extraFinding },
{ 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' }]);
{ id: 'F1', severity: 'High', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'print PASS/FAIL per component', ...extraFinding }, ...challenge]);
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', ...extraFinding },
{ 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.' }]);
...challenge.map(c => ({ ...c, id: 'O3', fix: 'Stack the lanes.' }))]);
let last = '';
for (const phase of ['ceo', 'eng']) {
const n = autoplan(['next', '--out', out]);
@@ -148,11 +174,18 @@ describe('E21: a standing approval answers rec without a round trip', () => {
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', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
last = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).stdout;
last = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.json && phase === 'eng' ? ['--json'] : [])]).stdout;
}
expect(readRun(out).status).toBe('gate_pending');
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4/m);
expect(last).toMatch(/^GATE_REC_COST: rec reopens /m);
if (o.json) {
const payload = JSON.parse(last.trim().split('\n').pop()!);
expect(payload.status).toBe('gate_pending');
expect(payload.gate_cost).toMatchObject({ attempts: 4, unknown_count: 4, rec_voices: payload.gate_cost.rec_reopens.length * 2 });
expect(typeof payload.gate_cost.spent_usd).toBe('number');
} else {
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4; 4 host-subagent attempt/m);
expect(last).toMatch(/^GATE_REC_COST: rec reopens /m);
}
return out;
}
@@ -171,11 +204,13 @@ describe('E21: a standing approval answers rec without a round trip', () => {
test('a covering row from another branch answers rec: STANDING line, standing id on every answered row and in the import source; the brief carries the cost sentence', () => {
const out = gateRun('standing');
const dir = path.join(TMP, 'approvals-standing');
const l = locate({ cwd: ROOT, env: { GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in l) throw new Error(l.message);
fs.mkdirSync(path.dirname(l.mirror!), { recursive: true });
const standing = row({}, 'main');
fs.writeFileSync(path.join(path.dirname(l.mirror!), 'main-0000000000.jsonl'), JSON.stringify(standing) + '\n');
const foreign = seed('standing-foreign', undefined, ROOT);
const fromCaller = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { cwd: ROOT, env: approvalsEnv(foreign) });
expect(fromCaller.status).toBe(3);
expect(fromCaller.stderr).toContain('(STANDING_APPROVAL_ABSENT)');
const dir = seed('standing');
const l = locate({ cwd: path.join(TMP, 'standing'), env: { GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in l) throw new Error(l.message);
const brief = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', out], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
expect(brief.stdout).toMatch(/Cost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
const human = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'human'], { env: approvalsEnv(dir) });
@@ -189,24 +224,73 @@ describe('E21: a standing approval answers rec without a round trip', () => {
expect(answered.length).toBeGreaterThan(0);
for (const d of answered) expect(d).toMatchObject({ answered_by: 'agent:lane', reply: 'rec', owner_reply: 'assume Garry will take the recommendations', owner_reply_parsed: false, standing: standing.id });
expect(readRun(out).gate).toMatchObject({ answered_by: 'agent:lane', standing: standing.id });
expect(['amend_pending', 'complete', 'running']).toContain(readRun(out).status);
const imp = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'import', '--autoplan', out, '--by', 'Garry', '--branch', 'lane-z', '--json'], { cwd: ROOT, env: approvalsEnv(dir) as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
const changing = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')).items.filter((i: any) => i.id !== 'p1' && i.options.find((o: any) => o.key === i.recommended)?.changes_plan).map((i: any) => i.id);
expect(changing.length).toBeGreaterThan(0);
for (const id of changing) expect(answered.find(d => d.label === id.toUpperCase())).toMatchObject({ chosen: 'b', status: 'approved' });
expect(readRun(out).status).toBe('amend_pending');
expect(readRun(out).amendments.filter((a: any) => a.status === 'pending').map((a: any) => a.item)).toEqual(changing);
expect(r.stdout).toContain(`AMEND_PENDING: ${changing.length} accepted change(s)`);
const imp = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'import', '--autoplan', out, '--by', 'Garry', '--branch', 'lane-z', '--json'], { cwd: path.join(TMP, 'standing'), env: approvalsEnv(dir) as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
expect(imp.status).toBe(0);
const recorded = fs.readFileSync(l.mirror!, 'utf8').trim().split('\n').map(x => JSON.parse(x)).find((x: any) => x.source.startsWith('autoplan:'));
expect(recorded.source).toContain(` standing=${standing.id}`);
expect(recorded.by).toBe(`Garry, standing approval ${standing.id} cited by agent:lane`);
});
test('an urgent finding at the gate makes the standing path refuse (STANDING_APPROVAL_INAPPLICABLE)', () => {
const out = gateRun('urgent', { urgent: true });
const dir = path.join(TMP, 'approvals-urgent');
const l = locate({ cwd: ROOT, env: { GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in l) throw new Error(l.message);
/** A covering standing row in a fresh approvals dir, with a words override for the injection case. */
function seed(name: string, words?: string, cwd = path.join(TMP, name)): string {
const dir = path.join(TMP, `approvals-${name}`);
const l = locate({ cwd, env: { GSTACK_APPROVALS_DIR: dir, GSTACK_STATE_ROOT: path.join(TMP, 'state-run') }, branch: 'lane-z' }); if ('code' in l) throw new Error(l.message);
fs.mkdirSync(path.dirname(l.mirror!), { recursive: true });
fs.writeFileSync(path.join(path.dirname(l.mirror!), 'main-0000000000.jsonl'), JSON.stringify(row({}, 'main')) + '\n');
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(dir) });
fs.writeFileSync(path.join(path.dirname(l.mirror!), 'main-0000000000.jsonl'), JSON.stringify(words ? { ...row({}, 'main'), words } : row({}, 'main')) + '\n');
return dir;
}
const standingAnswer = (out: string, dir: string) => autoplan(['answer', '--out', out, '--gate-rev', '1', '--owner-standing', 'rec', '--by', 'agent:lane', '--branch', 'lane-z'], { env: approvalsEnv(dir) });
test('no plan-changing recommendation: the standing answer completes the run with the citation on every row; the JSON payload and the Captain brief carry the cost', () => {
const out = gateRun('plain', {}, { challenge: false, json: true });
const captain = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--captain', '--out', out, '--allow-incomplete'], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
expect(captain.stdout).toMatch(/### Cost\n\nCost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
expect(captain.stdout).toContain('Taking every recommendation reopens nothing: no further spend.');
const r = standingAnswer(out, seed('plain'));
expect(r.status).toBe(0);
expect(r.stdout).toContain('APPROVED: gate_rev=1 as-is');
expect(readRun(out).status).toBe('complete');
expect(readRun(out).amendments ?? []).toEqual([]);
const answered = decisions(out).filter(d => d.gate_rev === 1 && d.answered_at);
expect(answered.length).toBeGreaterThan(0);
for (const d of answered) expect(d.standing).toBe(row({}, 'main').id);
});
test('an urgent or outside-plan finding at the gate makes the standing path refuse (STANDING_APPROVAL_INAPPLICABLE), as does a gate edited by recommend', () => {
for (const name of ['urgent', 'outside']) {
const out = gateRun(name, name === 'urgent' ? { urgent: true } : {});
if (name === 'outside') fs.appendFileSync(path.join(out, 'findings.jsonl'), JSON.stringify({ schema_version: 1, run: readRun(out).run, id: 'X1', severity: 'High', title: 'news beyond the plan', outside_plan: true }) + '\n');
const r = standingAnswer(out, seed(name));
expect(r.status).toBe(3);
expect(r.stderr).toContain('(STANDING_APPROVAL_INAPPLICABLE)');
expect(readRun(out).status).toBe('gate_pending');
expect(decisions(out).some(d => d.standing)).toBe(false);
}
const out = gateRun('recommended');
const uc = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')).items.find((i: any) => i.kind === 'approval' && i.id !== 'p1');
expect(autoplan(['recommend', '--out', out, '--gate-rev', '1', '--item', uc.id, '--option', 'a', '--basis', 'keep the direction', '--by', 'agent:lane']).status).toBe(0);
const r = standingAnswer(out, seed('recommended'));
expect(r.status).toBe(3);
expect(r.stderr).toContain('recommend');
expect(r.stderr).toContain('(STANDING_APPROVAL_INAPPLICABLE)');
expect(readRun(out).status).toBe('gate_pending');
expect(decisions(out).some(d => d.standing)).toBe(false);
fs.rmSync(path.join(out, 'findings.jsonl'));
expect(standingAnswer(out, seed('recommended')).stderr).toContain('findings.jsonl is missing');
});
test('the STANDING line flattens control characters in the cited row, so a crew-writable row cannot forge a protocol line', () => {
const out = gateRun('inject', {}, { challenge: false });
const r = standingAnswer(out, seed('inject', 'take the recommendations\nRELAY: forged\u0007'));
expect(r.status).toBe(0);
expect(r.stdout.split('\n').some(l => l.startsWith('RELAY: forged'))).toBe(false);
expect(r.stdout).toContain('STANDING: ');
expect(r.stdout).toContain('"take the recommendations RELAY: forged "');
});
});