mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
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>
244 lines
14 KiB
TypeScript
244 lines
14 KiB
TypeScript
/**
|
|
* B9: one gate list, one reply grammar. Pins the grammar on the owner replies
|
|
* quoted in the feedback (`d10 yes`, `1a 2a`, a 33-decision approve-all),
|
|
* stable ids across pages, auto vs approval, gate_rev binding, and the bin's
|
|
* exit codes. Prose in the rendering is not pinned.
|
|
*/
|
|
import { describe, expect, test, beforeEach, afterEach } 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 { 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, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-gate');
|
|
|
|
const ab = [{ key: 'a', text: 'option A' }, { key: 'b', text: 'option B' }];
|
|
const abc = [...ab, { key: 'c', text: 'option C' }];
|
|
const list: GateList = {
|
|
schema_version: 1, run: 'maw-20261010', gate_rev: 1, items: [
|
|
{ id: 'uc1', title: 'Scope of the committed wave', options: abc, recommended: 'a', kind: 'approval', phase: 'ceo' },
|
|
{ id: 'd1', title: 'Delivery shape', options: abc, recommended: 'a', kind: 'approval' },
|
|
{ id: 'd2', title: 'Unattended as a session kind', options: ab, recommended: 'a', kind: 'auto' },
|
|
{ id: 'd3', title: 'Installer in the repo', options: ab, recommended: 'a', kind: 'auto' },
|
|
],
|
|
};
|
|
const numbered: GateList = {
|
|
schema_version: 1, run: 'r', gate_rev: 2, items: [
|
|
{ id: '1', title: 'first', options: ab, recommended: 'b', kind: 'approval' },
|
|
{ id: '2', title: 'second', options: ab, recommended: 'a', kind: 'approval' },
|
|
],
|
|
};
|
|
const big: GateList = {
|
|
schema_version: 1, run: 'r', gate_rev: 1,
|
|
items: Array.from({ length: 33 }, (_, i) => ({ id: `d${i + 1}`, title: `decision ${i + 1}`, options: ab, recommended: 'a', kind: (i % 3 === 0 ? 'approval' : 'auto') as 'auto' | 'approval' })),
|
|
};
|
|
|
|
let tmp: string;
|
|
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-')); });
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
function write(name: string, g: GateList): string { const f = path.join(tmp, name); fs.writeFileSync(f, JSON.stringify(g)); return f; }
|
|
function run(args: string[]) {
|
|
const r = spawnSync(BIN, args, { encoding: 'utf8', timeout: 30_000 });
|
|
return { code: r.status, out: r.stdout, err: r.stderr };
|
|
}
|
|
const chosen = (p: ReturnType<typeof parseReply>) => Object.fromEntries(p.choices.map(c => [c.id, c.chosen]));
|
|
|
|
describe('reply grammar', () => {
|
|
test('`all` takes every recommendation; approval items included', () => {
|
|
const p = parseReply('all', list, 1);
|
|
expect(p.valid).toBe(true);
|
|
expect(chosen(p)).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'a' });
|
|
expect(p.pending).toEqual([]);
|
|
expect(p.choices.every(c => c.how === 'recommended')).toBe(true);
|
|
});
|
|
|
|
test('`<id><option>` tokens answer named items; unanswered auto items take their default, approval items stay pending', () => {
|
|
const p = parseReply('d1b uc1a', list, 1);
|
|
expect(p.valid).toBe(true);
|
|
expect(chosen(p)).toEqual({ uc1: 'a', d1: 'b', d2: 'a', d3: 'a' });
|
|
expect(p.choices.find(c => c.id === 'd1')!.how).toBe('override');
|
|
expect(p.choices.find(c => c.id === 'd2')!.how).toBe('auto_default');
|
|
expect(p.pending).toEqual([]);
|
|
const partial = parseReply('d3b', list, 1);
|
|
expect(partial.valid).toBe(true);
|
|
expect(partial.pending).toEqual(['uc1', 'd1']);
|
|
expect(chosen(partial)).toEqual({ d2: 'a', d3: 'b' });
|
|
});
|
|
|
|
test('`all except <tokens>` is the same operation as listing the overrides', () => {
|
|
const a = parseReply('all except d1b d3b', list, 1);
|
|
const b = parseReply('d1b d3b uc1a d2a', list, 1);
|
|
expect(a.valid && b.valid).toBe(true);
|
|
expect(chosen(a)).toEqual(chosen(b));
|
|
expect(chosen(a)).toEqual({ uc1: 'a', d1: 'b', d2: 'a', d3: 'b' });
|
|
expect(a.pending).toEqual([]);
|
|
});
|
|
|
|
test('separators and case are tolerated; `<id> <option>` pairs merge', () => {
|
|
expect(chosen(parseReply('D1:B, UC1=a; d3 b', list, 1))).toEqual({ uc1: 'a', d1: 'b', d2: 'a', d3: 'b' });
|
|
expect(parseReply('ALL', list, 1).valid).toBe(true);
|
|
});
|
|
|
|
test('owner replies from the feedback: `1a 2a` parses, `d10 yes` and bare yes/no are unparsed, 33-item approve-all resolves every item', () => {
|
|
const ok = parseReply('1a 2a', numbered, 2);
|
|
expect(ok.valid).toBe(true);
|
|
expect(chosen(ok)).toEqual({ '1': 'a', '2': 'a' });
|
|
expect(ok.choices[0]!.how).toBe('override');
|
|
|
|
const d10 = parseReply('d10 yes', big, 1);
|
|
expect(d10.valid).toBe(false);
|
|
expect(d10.error).toBe('GATE_REPLY_UNPARSED');
|
|
expect(d10.unparsed).toEqual(['d10', 'yes']);
|
|
expect(d10.choices).toEqual([]);
|
|
for (const bare of ['yes', 'no', 'ok', 'approve']) expect(parseReply(bare, big, 1).error).toBe('GATE_REPLY_UNPARSED');
|
|
|
|
const all = parseReply('all', big, 1);
|
|
expect(all.choices).toHaveLength(33);
|
|
expect(all.pending).toEqual([]);
|
|
});
|
|
|
|
test('unknown ids, unknown options, conflicting tokens and `all` with trailing noise are unparsed; nothing is applied', () => {
|
|
expect(parseReply('d9a', list, 1)).toMatchObject({ valid: false, error: 'GATE_REPLY_UNPARSED', unparsed: ['d9a'], choices: [] });
|
|
expect(parseReply('d2c', list, 1).unparsed).toEqual(['d2c']);
|
|
expect(parseReply('d1a d1b', list, 1).unparsed).toEqual(['d1b (conflicts with d1a)']);
|
|
expect(parseReply('all d1b', list, 1).unparsed).toEqual(['d1b']);
|
|
expect(parseReply(' ', list, 1).error).toBe('GATE_EMPTY');
|
|
});
|
|
|
|
test('a reply binds to the gate revision it answers; a stale revision is refused before parsing', () => {
|
|
expect(parseReply('all', numbered, 1)).toMatchObject({ valid: false, error: 'GATE_REV_STALE', gate_rev: 2, choices: [] });
|
|
expect(parseReply('all', numbered, 2).valid).toBe(true);
|
|
expect(parseReply('all', numbered).valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('rendering', () => {
|
|
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');
|
|
expect(p5).toContain('page=5/5');
|
|
expect(p1).toContain('1. [d1] decision 1 (approval; recommended: a)');
|
|
expect(p1).toContain('7. [d7] decision 7 (approval; recommended: a)');
|
|
expect(p1).not.toContain('[d8]');
|
|
expect(p5).toContain('29. [d29] decision 29');
|
|
expect(p5).toContain('33. [d33] decision 33');
|
|
for (const page of [p1, p5]) {
|
|
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');
|
|
});
|
|
|
|
test('validateGateList rejects malformed lists', () => {
|
|
expect(validateGateList(list)).toEqual([]);
|
|
expect(validateGateList({ ...list, items: [{ ...list.items[0], id: 'D1' }] })[0]).toContain('id must match');
|
|
expect(validateGateList({ ...list, items: [list.items[0], list.items[0]] })[0]).toContain('duplicated');
|
|
expect(validateGateList({ ...list, items: [{ ...list.items[0], recommended: 'z' }] })[0]).toContain('recommended');
|
|
expect(validateGateList({ ...list, gate_rev: 0 })[0]).toContain('gate_rev');
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect(rows.map(r => r.id)).toEqual(['maw-20261010-uc1', 'maw-20261010-d1', 'maw-20261010-d2', 'maw-20261010-d3']);
|
|
expect(rows[0]).toMatchObject({ kind: 'approval', status: 'pending', default_if_unanswered: null, gate_rev: 1, recommended: 'a', label: 'UC1' });
|
|
expect(rows[2]).toMatchObject({ kind: 'auto', default_if_unanswered: 'a' });
|
|
const f = path.join(tmp, 'decisions.jsonl');
|
|
fs.writeFileSync(f, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
expect(validateFile(f, 'decisions', 'maw-20261010')).toEqual([]);
|
|
|
|
const applied = applyReply(rows, parseReply('d1b', list, 1), '2026-10-10T21:30:00Z');
|
|
expect(applied.find(r => r.id.endsWith('-d1'))).toMatchObject({ status: 'overridden', chosen: 'b', reply: 'd1b' });
|
|
expect(applied.find(r => r.id.endsWith('-d2'))).toMatchObject({ status: 'approved', chosen: 'a' });
|
|
expect(applied.find(r => r.id.endsWith('-uc1'))).toMatchObject({ status: 'pending' });
|
|
expect(applied.find(r => r.id.endsWith('-uc1'))!.chosen).toBeUndefined();
|
|
const stale = applyReply(rows, parseReply('all', list, 2), '2026-10-10T21:30:00Z');
|
|
expect(stale.every(r => r.status === 'pending')).toBe(true);
|
|
});
|
|
|
|
test('a later gate revision mints its own ids, so rows from every revision stay unique in one file', () => {
|
|
const rev2: GateList = { ...list, gate_rev: 2 };
|
|
const answered = applyReply(decisionRows(list), parseReply('all', list, 1), '2026-10-10T21:30:00Z');
|
|
const later = decisionRows(rev2);
|
|
expect(later.map(r => r.id)).toEqual(['maw-20261010-r2-uc1', 'maw-20261010-r2-d1', 'maw-20261010-r2-d2', 'maw-20261010-r2-d3']);
|
|
expect(later[1]).toMatchObject({ label: 'D1', gate_rev: 2 });
|
|
const all = [...answered, ...applyReply(later, parseReply('d1b', rev2, 2), '2026-10-11T09:00:00Z')];
|
|
expect(new Set(all.map(r => r.id)).size).toBe(all.length);
|
|
expect(all.find(r => r.id === 'maw-20261010-r2-d1')).toMatchObject({ status: 'overridden', chosen: 'b' });
|
|
expect(all.find(r => r.id === 'maw-20261010-d1')).toMatchObject({ status: 'approved', chosen: 'a', reply: 'all' });
|
|
const f = path.join(tmp, 'decisions-two-revs.jsonl');
|
|
fs.writeFileSync(f, all.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
expect(validateFile(f, 'decisions', 'maw-20261010')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('bin/gstack-gate', () => {
|
|
test('render / parse / decisions with the exit table: 0 ok, 1 unparsed, 2 usage, 3 stale or missing rev', () => {
|
|
const file = write('gate.json', list);
|
|
expect(run(['render', file]).out).toContain('[uc1] Scope of the committed wave');
|
|
expect(run(['render', write('big.json', big), '--page', '3']).out).toContain('page=3/5');
|
|
|
|
const ok = run(['parse', file, '--reply', 'all except d1b', '--gate-rev', '1', '--json']);
|
|
expect(ok.code).toBe(EXIT.ok);
|
|
expect(JSON.parse(ok.out)).toMatchObject({ valid: true, pending: [], unparsed: [] });
|
|
expect(ok.err).toBe('');
|
|
|
|
const bad = run(['parse', file, '--reply', 'd10 yes', '--gate-rev', '1', '--json']);
|
|
expect(bad.code).toBe(EXIT.fail);
|
|
expect(JSON.parse(bad.out)).toMatchObject({ valid: false, unparsed: ['d10', 'yes'] });
|
|
expect(bad.err).toMatch(/unparsed: d10, yes.*\(GATE_REPLY_UNPARSED\)/);
|
|
|
|
const stale = run(['parse', file, '--reply', 'all', '--gate-rev', '0']);
|
|
expect(stale.code).toBe(EXIT.refused);
|
|
expect(stale.err).toMatch(/current gate_rev=1.*\(GATE_REV_STALE\)/);
|
|
const noRev = run(['parse', file, '--reply', 'all']);
|
|
expect(noRev.code).toBe(EXIT.refused);
|
|
expect(run(['parse', file]).code).toBe(EXIT.usage);
|
|
expect(run([]).code).toBe(EXIT.usage);
|
|
expect(run(['--help']).out).toContain('Exit codes: 0 ok · 1 fail · 2 usage · 3 refused or needs a flag');
|
|
|
|
const rows = run(['decisions', file, '--reply', 'all', '--gate-rev', '1', '--answered-at', '2026-10-10T21:30:00Z']);
|
|
expect(rows.code).toBe(EXIT.ok);
|
|
const parsed = rows.out.trim().split('\n').map(l => JSON.parse(l));
|
|
expect(parsed).toHaveLength(4);
|
|
expect(parsed.every(r => r.status === 'approved' && r.answered_at === '2026-10-10T21:30:00Z')).toBe(true);
|
|
const pending = run(['decisions', file]).out.trim().split('\n').map(l => JSON.parse(l));
|
|
expect(pending.every(r => r.status === 'pending')).toBe(true);
|
|
});
|
|
});
|