Files
gstack/bin/gstack-approvals
T
Garry Tanandcapy-ai[bot] 0cab97447c v1.91.314.0 feat(autoplan): carry the owner's answer forward, gate ELI5 field, stacked approvals import (#3583)
* refactor: share the decision-trail append rules (lib/decision-trail.ts)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): carry the owner's answer to a later gate that asks nothing new, gate.json eli5, stacked approvals import

A later gate revision with nothing new is answered with the owner's recorded words
(CARRIED); a 'do it' also covers new auto items that change no scope, cost or
direction; an unreviewed post-cap delta gets one cross-family reviewer first
(CARRY_CHECK, gstack-autoplan check). gate.json gains eli5 and the Captain brief
requires ## ELI5 on runs that carry it. gstack-approvals import --stack writes
every branch or none. The first next prints REVIEW_ESTIMATE.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: carry-forward, ELI5 section and stacked approvals import

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): carry only fully owner-answered gates; void stale delta checks

- a pending delta check is voided (reservation released) when the owner answers,
  the plan is revised, or a new gate is written; a check bound to an older delta is void
- carry refuses a gate with any approval item still pending or answered in item tokens
- "do it" never covers an option that changes the plan; isDoIt ignores Captain-relayed prefixes
- captain brief rebuilds ELI5 from the current plan and lists items taken under "do it"
- gstack-approvals import --stack locates every branch before writing any
- DRIVER.md back under its 162-line budget

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: carry-forward conditions, delta-check voiding and import --stack match the final code

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: changelog for autoplan carry-forward (unreleased; stamped at merge)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.314.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 09:58:34 +00:00

155 lines
9.6 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md).
*
* gstack-approvals add --ask <text> --words <exact words> --by <who> --scope <what it covers>
* [--pending <text>] [--outcome approved|rejected|pending|standing]
* [--date YYYY-MM-DD] [--source <ref>] [--branch <name>] [--json]
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name> | --stack <b1,b2,...>] [--json]
* gstack-approvals render [--no-heading] [--branch <name>]
* gstack-approvals list [--json] [--branch <name>]
* gstack-approvals sync [--branch <name>]
* gstack-approvals path [--branch <name>]
*
* Rows are kept per branch under the state root and, on Capy, mirrored to
* GSTACK_APPROVALS_DIR, else the `approvals/` directory a Captain created in
* its drive (readable by the whole crew), else the private project-user Drive
* folder. Every field is secret-scanned before it is written. A row with
* `render` prints the `## Approvals` section /ship puts in the PR body. Logic:
* lib/approval-record.ts.
*/
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
add --ask <text> --words <exact words> --by <who> --scope <what it covers>
[--pending <text>] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source <ref>]
import --autoplan <run dir> [--by <whose words, e.g. Garry>] [--stack <b1,b2,...>]
--stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
render [--no-heading] the ## Approvals section for a PR body or report
list [--json]
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
path print the record and mirror paths, and which rule chose the mirror
Every command takes [--branch <name>] (default: the checked-out branch).
Example:
gstack-approvals add --ask "Approve the plan for the Capy approvals PR" --words "rec" \\
--by "Garry (via GStack Minder)" --scope "the reviewed plan, every recommendation"
${exitTableHelp()}`;
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']);
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
const flags: Record<string, string> = {};
const bools = new Set<string>();
for (let i = 0; i < args.length; i++) {
const a = args[i]!;
if (VALUE_FLAGS.has(a)) {
const v = args[i + 1];
if (v === undefined) return { flags, bools, error: `${a} needs a value` };
flags[a] = v; i++;
} else if (a === '--json' || a === '--no-heading') bools.add(a);
else return { flags, bools, error: `unknown argument ${a}` };
}
return { flags, bools };
}
function fail(cmd: string, e: RecordError): number {
process.stderr.write(resultLine(`gstack-approvals ${cmd}`, e.code, e.message) + '\n');
if (e.code === 'APPROVAL_RECORD_INVALID') return EXIT.usage;
if (e.code === 'APPROVAL_RECORD_WRITE_FAILED' || e.code === 'APPROVAL_RECORD_UNAVAILABLE' || e.code === 'ARTIFACT_MALFORMED_JSONL') return EXIT.fail;
return EXIT.refused;
}
/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */
function importStack(cmd: string, flags: Record<string, string>, json: boolean): number {
if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan <run dir>, without --branch\n`); return EXIT.usage; }
const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))];
if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs <b1,b2,...>\n`); return EXIT.usage; }
const cwd = process.cwd();
const missing = branches.filter(b => !branchExists(cwd, b));
if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` });
const runDir = path.resolve(flags['--autoplan']);
const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = [];
let firstError: RecordError | undefined;
const plans: Array<{ branch: string; loc: StoreLocation; rows: ReturnType<typeof importAutoplan> & unknown[] }> = [];
for (const branch of branches) {
const loc = locate({ cwd, env: process.env, branch });
if ('code' in loc) return fail(cmd, loc);
const rows = importAutoplan(runDir, branch, flags['--by']);
if ('code' in rows) return fail(cmd, rows);
plans.push({ branch, loc, rows });
}
for (const { branch, loc, rows } of plans) {
const res = appendRows(loc, rows);
if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; }
if (res.mirrorError) firstError ??= res.mirrorError;
results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) });
}
if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n');
else {
for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`);
process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`);
}
return firstError ? fail(cmd, firstError) : EXIT.ok;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
const { flags, bools, error } = parse(rest);
if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; }
if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json'));
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] });
if ('code' in loc) return fail(cmd, loc);
const json = bools.has('--json');
const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`;
if (cmd === 'add' || cmd === 'import') {
let rows;
if (cmd === 'add') {
const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
rows = [built];
} else {
if (!flags['--autoplan']) { process.stderr.write(`gstack-approvals: import needs --autoplan <run dir>\n`); return EXIT.usage; }
const imported = importAutoplan(path.resolve(flags['--autoplan']), loc.branch, flags['--by']);
if ('code' in imported) return fail(cmd, imported);
rows = imported;
}
const res = appendRows(loc, rows);
if ('code' in res) return fail(cmd, res);
if (json) process.stdout.write(JSON.stringify({ added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null }) + '\n');
else if (cmd === 'add') process.stdout.write(`APPROVAL_RECORDED: ${rows[0]!.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`);
else process.stdout.write(`APPROVAL_IMPORTED: n=${res.added.length} skipped=${res.skipped} ${recorded(loc)}\n`);
if (res.mirrorError) return fail(cmd, res.mirrorError);
return EXIT.ok;
}
if (cmd === 'render' || cmd === 'list') {
const read = readRecord(loc);
if (read.restored) process.stderr.write(`gstack-approvals: read the Drive mirror ${loc.mirror} (no local copy on this machine; gstack-approvals sync restores it)\n`);
if (cmd === 'list' && json) process.stdout.write(JSON.stringify({ branch: loc.branch, state: read.state, rows: read.rows }) + '\n');
else if (cmd === 'list') for (const r of read.rows) process.stdout.write(`${r.date} ${r.id} ${r.outcome} ${JSON.stringify(r.ask)} words=${JSON.stringify(r.words)} by=${JSON.stringify(r.by)}\n`);
else process.stdout.write(renderRecord(read, loc, { heading: !bools.has('--no-heading') }));
if (read.state === 'unavailable') return fail(cmd, { code: 'APPROVAL_RECORD_UNAVAILABLE', message: `no record at ${loc.file} and no mirror` });
return EXIT.ok;
}
if (cmd === 'sync') {
const s = syncRecord(loc);
process.stdout.write(`APPROVAL_SYNCED: rows=${s.rows} ${recorded(loc)}\n`);
return s.error ? fail(cmd, s.error) : EXIT.ok;
}
if (cmd === 'path') {
process.stdout.write(`RECORD: ${loc.file}\nMIRROR: ${loc.mirror ? `${loc.mirror} (${loc.mirror_source})` : 'none'}\n${(loc.read_also ?? []).map(f => `READ_ALSO: ${f}\n`).join('')}BRANCH: ${loc.branch}\n`);
return EXIT.ok;
}
process.stderr.write(`gstack-approvals: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
process.exitCode = main(process.argv.slice(2));