Files
gstack/bin/gstack-gate
T
garrytanandcapy-ai[bot] 78ee23240c 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: <id> phase=<p> voice=<v> result=<file>` per open attempt.
B9  `gstack-gate render --all`; paged output says to relay every page.
B10 `gstack-plan-reality check --plan <snapshot>`: 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>
2026-10-14 23:56:48 +00:00

103 lines
5.1 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* gstack-gate — render and parse the one gate list (plan B9).
*
* gstack-gate render <gate.json> [--page N | --all]
* gstack-gate parse <gate.json> --reply "<text>" --gate-rev N [--json]
* gstack-gate decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
*
* Logic: lib/gate-list.ts. Codes: lib/result-codes.ts (GATE_REV_STALE, GATE_REPLY_UNPARSED).
*/
import * as fs from 'node:fs';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { applyReply, decisionRows, parseReply, renderGate, validateGateList, type GateList } from '../lib/gate-list';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-gate — one gate list, one reply grammar
render <gate.json> [--page N | --all] numbered items with stable ids, auto/approval, the reply shape;
--all prints every item on one page (relay every page)
parse <gate.json> --reply "<text>" --gate-rev N [--json]
resolved choices as JSON; unparsed tokens are listed, never guessed
decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
decisions.jsonl rows (pending, or applied when a reply is given)
Reply grammar: \`all\` · \`<id><option>\` tokens (\`d3b uc1a\`) · \`all except <tokens>\`. Bare yes/no is rejected.
gate.json: {"schema_version":1,"run":"<id>","gate_rev":1,"items":[{"id":"d1","title":"…","kind":"auto|approval","recommended":"a","options":[{"key":"a","text":"…"},…]}]}
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function usage(message: string): number {
process.stderr.write(`gstack-gate: ${message}\n${HELP}\n`);
return EXIT.usage;
}
function loadList(file: string | undefined): GateList | number {
if (!file) return usage('a gate.json path is required');
let list: unknown;
try { list = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { process.stderr.write(`gstack-gate: ${file}: ${e.message}\n`); return EXIT.fail; }
const problems = validateGateList(list);
if (problems.length) { process.stderr.write(`gstack-gate: ${file} is not a gate list:\n ${problems.join('\n ')}\n`); return EXIT.fail; }
return list as GateList;
}
function parseWith(list: GateList, rest: string[], json: boolean) {
const reply = flag(rest, '--reply');
const revText = flag(rest, '--gate-rev');
if (reply === undefined) return { code: usage('--reply "<text>" is required') };
if (revText === undefined) {
process.stderr.write(resultLine('gstack-gate parse', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${list.gate_rev}`) + '\n');
return { code: EXIT.refused };
}
const parsed = parseReply(reply, list, Number(revText));
if (json) process.stdout.write(JSON.stringify(parsed) + '\n');
if (parsed.error === 'GATE_REV_STALE') {
process.stderr.write(resultLine('gstack-gate parse', 'GATE_REV_STALE', `reply names gate_rev=${revText}, current gate_rev=${list.gate_rev}`) + '\n');
return { code: EXIT.refused, parsed };
}
if (!parsed.valid) {
const detail = parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`;
process.stderr.write(resultLine('gstack-gate parse', 'GATE_REPLY_UNPARSED', detail) + '\n');
return { code: EXIT.fail, parsed };
}
return { code: EXIT.ok, parsed };
}
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('--') && !['--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');
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') {
const { code, parsed } = parseWith(list, rest, rest.includes('--json'));
if (code === EXIT.ok && !rest.includes('--json')) {
process.stdout.write(JSON.stringify(parsed) + '\n');
}
return code;
}
if (cmd === 'decisions') {
let rows = decisionRows(list);
if (flag(rest, '--reply') !== undefined || flag(rest, '--gate-rev') !== undefined) {
const { code, parsed } = parseWith(list, rest, false);
if (code !== EXIT.ok) return code;
rows = applyReply(rows, parsed!, flag(rest, '--answered-at') ?? new Date().toISOString());
}
process.stdout.write(rows.map(r => JSON.stringify(r)).join('\n') + '\n');
return EXIT.ok;
}
return usage(`unknown command ${cmd}`);
}
process.exitCode = main(process.argv.slice(2));