Files
gstack/lib/approval-record.ts
T
garrytanandcapy-ai[bot] ee2ce02e58 fix: pre-landing review fixes (pass 2)
Standing approvals: the newest row about autoplan recommendations decides, so a later refusal
blocks older grants; rows whose id or date is not the recorded shape are never cited; the
project is fixed at init from the plan's physical path (run.json project_root) so a symlinked
plans folder or a checkpoint restore cannot borrow another repository's record. Gate cost:
recReopens counts every phase revise can keep or add and prices answers already recorded at
the gate; run --json emits one payload; the Captain brief's ### Cost sits above the relaying
trailer.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 22:22:50 +00:00

327 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* approval-record — the per-thread approval record gstack keeps for Capy
* Captains (capy/skill/gstack/captain.md): one row per answer, with the date,
* what was asked, the person's exact words, who said them, what the answer
* covers, what is still pending and the outcome. bin/gstack-approvals is the
* CLI; /ship pastes `render` into the PR body as `## Approvals` and a crew
* thread pastes it into its report.
*
* The record is display and audit only. It is not an approval citation
* (lib/approval-citations.ts stays the one enforced mechanism): every cell is
* escaped so the rendered table cannot be parsed as an `Approved by ...`
* citation, a mention, an image or hidden HTML.
*
* Storage: `<state root>/projects/<slug>/approvals/<branch key>.jsonl`, the
* branch key being a readable prefix plus a hash of the exact ref (no two
* branches share a file, and the key is always one contained path segment).
* On Capy (GSTACK_CAPY=1) the state root is thrown away with the machine, so
* every write is mirrored: to GSTACK_APPROVALS_DIR when set, else to the
* directory the Captain named by creating `approvals/` in its drive
* (`~/.capy/drive/captain-<name>/approvals/<slug>/<branch key>.jsonl`, which every
* crew thread can read), else to the private project-user Drive folder
* (`<drive>/approvals/<slug>/<branch key>.jsonl`). GSTACK_APPROVALS_MIRROR=0
* turns the mirror off. Reads take the union of both. Every field passes the
* secret scanner before anything is written.
*/
import { createHash } from 'node:crypto';
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 { capyDriveFolder } from './autoplan-checkpoint';
import { reviewedPlan } from './captain-brief';
import { slugFromEnvironment } from './bin-context';
import { firstTitle } from './markdown-sections';
import { scan } from './redact-engine';
import type { ResultCodeName } from './result-codes';
import { resolveStateRoot } from './state-root';
export const OUTCOMES = ['approved', 'rejected', 'pending', 'standing'] as const;
export type Outcome = typeof OUTCOMES[number];
export const CAPS = { ask: 300, words: 2000, scope: 300, pending: 300, by: 120, source: 200 } as const;
export interface ApprovalRow {
schema_version: 1; id: string; date: string; ask: string; words: string; by: string;
scope: string; pending: string; outcome: Outcome; source: string; branch: string; recorded_at: string;
}
export type MirrorSource = 'GSTACK_APPROVALS_DIR' | 'captain drive' | 'project-user drive';
/** `mirror` is written and read; `read_also` are the other mirror roots' copies, read so a row mirrored under an earlier rule stays visible. */
export interface StoreLocation { file: string; mirror?: string; mirror_source?: MirrorSource; read_also?: string[]; branch: string; slug: string; ephemeral: boolean }
export interface RecordError { code: ResultCodeName; message: string }
export type Env = NodeJS.ProcessEnv;
const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
/** One contained path segment for a branch: readable prefix plus a hash of the exact ref. */
export function branchKey(branch: string): string {
const readable = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, 60) || 'branch';
return `${readable}-${createHash('sha256').update(branch).digest('hex').slice(0, 10)}`;
}
function contained(root: string, file: string): boolean {
const rel = path.relative(root, file);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
/** Where this branch's record lives, and its mirror. */
export function locate(o: { cwd: string; env: Env; branch?: string }): StoreLocation | RecordError {
let branch = o.branch?.trim();
if (!branch) {
const r = git(o.cwd, ['symbolic-ref', '--short', '-q', 'HEAD']);
branch = r.status === 0 ? r.stdout.trim() : '';
}
if (!branch) return { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no branch at ${o.cwd} (detached HEAD or not a git repository)` };
const stateRoot = resolveStateRoot(o.env);
const slug = slugFromEnvironment(stateRoot, o.cwd) || 'unknown';
const dir = path.join(stateRoot, 'projects', slug, 'approvals');
const file = path.join(dir, `${branchKey(branch)}.jsonl`);
if (!contained(stateRoot, file)) return { code: 'APPROVAL_RECORD_INVALID', message: `record path escapes the state root: ${file}` };
// Every mirror root this machine can see, best first; the first is written, the rest are read so a
// row mirrored before the Captain created approvals/ (or under an explicit directory) stays visible.
const roots: Array<[MirrorSource, string]> = [];
if (o.env.GSTACK_APPROVALS_DIR) roots.push(['GSTACK_APPROVALS_DIR', o.env.GSTACK_APPROVALS_DIR]);
if (o.env.GSTACK_CAPY === '1' && o.env.GSTACK_APPROVALS_MIRROR !== '0') {
const captain = captainApprovalsDir(o.env);
if (captain) roots.push(['captain drive', captain]);
const top = git(o.cwd, ['rev-parse', '--show-toplevel']);
const folder = capyDriveFolder(o.env, top.status === 0 ? path.basename(top.stdout.trim()) : undefined);
if (folder) roots.push(['project-user drive', path.join(path.dirname(folder), 'approvals')]);
}
const rel = path.join(slug, `${branchKey(branch)}.jsonl`);
for (const [, root] of roots) if (!contained(root, path.join(root, rel))) return { code: 'APPROVAL_RECORD_INVALID', message: `mirror path escapes ${root}` };
const [chosen, ...rest] = roots;
return {
file, mirror: chosen ? path.join(chosen[1], rel) : undefined, mirror_source: chosen?.[0], read_also: rest.map(([, root]) => path.join(root, rel)),
branch, slug, ephemeral: o.env.GSTACK_EPHEMERAL === '1',
};
}
/**
* The approvals directory a Captain named for its crew: exactly one mounted Captain drive
* (`~/.capy/drive/captain-*`) holding an `approvals/` directory. Two Captains with one each
* is ambiguous, so neither is taken and the private project-user folder stays the mirror.
*/
export function captainApprovalsDir(env: Env): string | undefined {
if (env.GSTACK_CAPY !== '1') return undefined;
const drive = path.join(env.HOME || os.homedir(), '.capy', 'drive');
let found: string[];
try {
found = fs.readdirSync(drive).filter(d => d.startsWith('captain-')).map(d => path.join(drive, d, 'approvals')).filter(p => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
} catch { return undefined; }
return found.length === 1 ? found[0] : undefined;
}
/**
* A standing approval an agent may cite to answer an autoplan gate with `rec` (plan E21): outcome
* standing, the ask or scope naming autoplan, and the owner's own words naming the recommendations
* without a negation (`never take the recommendations` is a standing refusal, not an approval).
*/
const NEGATION_RE = /\b(never|not|no|don[’']?t|do not|without|except|excluding|unless|reject|refuse|decline|stop|revoke|withdraw)\b/i;
/** A standing row that names autoplan and the recommendations at all: a grant or a refusal, so the newest one decides. */
export const mentionsAutoplanRec = (r: ApprovalRow): boolean =>
r.outcome === 'standing' && /\bautoplan\b/i.test(`${r.ask} ${r.scope}`) && /\b(rec|recommendations?|recommended)\b/i.test(r.words);
/** The words withhold or the scope carves autoplan out (`all reviews except autoplan`). */
export const refusesAutoplanRec = (r: ApprovalRow): boolean =>
NEGATION_RE.test(r.words) || /\b(except|excluding|not|never|unless|but not)\b[^.;]*\bautoplan\b/i.test(r.scope);
export const coversAutoplanRec = (r: ApprovalRow): boolean => mentionsAutoplanRec(r) && !refusesAutoplanRec(r);
/** A row an agent may cite: the generated id shape, a calendar date and plain string fields, so no stored row can forge an output line. */
const STANDING_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
export const citableRow = (r: ApprovalRow): boolean =>
STANDING_ID_RE.test(r.id) && DATE_RE.test(r.date) && ['ask', 'words', 'by', 'scope', 'source', 'branch', 'recorded_at'].every(k => typeof r[k as keyof ApprovalRow] === 'string');
export interface StandingLookup { grant?: ApprovalRow; refusal?: ApprovalRow }
/**
* The newest standing instruction about autoplan recommendations, from this branch's record and
* every other branch's record of the same project (local, mirror and the other mirror roots): a
* Captain records the owner's standing words once, on whatever branch it had checked out, and
* every lane cites it. The newest row decides: a later refusal (`never take the recommendations`)
* blocks every older grant instead of being filtered away, and a row that fails `citableRow` is
* never cited.
*/
export function standingRecApproval(loc: StoreLocation): StandingLookup {
const rows: ApprovalRow[] = [];
for (const dir of [loc.file, loc.mirror, ...(loc.read_also ?? [])].flatMap(f => f ? [path.dirname(f)] : [])) {
let names: string[] = [];
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.jsonl')); } catch { continue; }
for (const n of names) rows.push(...readRows(path.join(dir, n)));
}
const seen = new Set<string>();
const newest = rows.filter(r => mentionsAutoplanRec(r) && citableRow(r) && !seen.has(r.id) && seen.add(r.id)).sort((a, b) => b.date.localeCompare(a.date) || b.recorded_at.localeCompare(a.recorded_at))[0];
if (!newest) return {};
return refusesAutoplanRec(newest) ? { refusal: newest } : { grant: newest };
}
function readRows(file: string | undefined): ApprovalRow[] {
if (!file || !fs.existsSync(file)) return [];
const rows: ApprovalRow[] = [];
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim()) continue;
try { const r = JSON.parse(line); if (r && r.schema_version === 1 && typeof r.id === 'string') rows.push(r as ApprovalRow); } catch { /* a torn tail line is skipped, never repaired */ }
}
return rows;
}
export interface ReadResult { rows: ApprovalRow[]; state: 'present' | 'empty' | 'unavailable'; restored: boolean }
/** The union of the local record and its mirror (local wins on the same id), oldest first. */
export function readRecord(loc: StoreLocation): ReadResult {
const local = readRows(loc.file);
const mirrored = readRows(loc.mirror);
const byId = new Map<string, ApprovalRow>();
for (const r of [...(loc.read_also ?? []).flatMap(readRows), ...mirrored, ...local]) byId.set(r.id, r);
const rows = [...byId.values()].sort((a, b) => a.recorded_at.localeCompare(b.recorded_at));
const known = fs.existsSync(loc.file) || (loc.mirror !== undefined && fs.existsSync(loc.mirror));
const state = rows.length ? 'present' : known || !loc.ephemeral || loc.mirror ? 'empty' : 'unavailable';
return { rows, state, restored: !fs.existsSync(loc.file) && mirrored.length > 0 };
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function validDate(d: string): boolean {
if (!DATE_RE.test(d)) return false;
const t = Date.parse(`${d}T00:00:00Z`);
return !Number.isNaN(t) && new Date(t).toISOString().slice(0, 10) === d;
}
export interface NewRow { ask: string; words: string; by: string; scope: string; pending?: string; outcome?: string; date?: string; source?: string }
/** Validate and secret-scan one row; the error names the field and rule, never the text. */
export function buildRow(input: NewRow, branch: string, now = new Date()): ApprovalRow | RecordError {
const fields = { ask: input.ask, words: input.words, by: input.by, scope: input.scope, pending: input.pending ?? 'none', source: input.source ?? 'manual' };
for (const [k, v] of Object.entries(fields)) {
if (typeof v !== 'string' || !v.trim()) return { code: 'APPROVAL_RECORD_INVALID', message: `--${k} is required` };
const cap = CAPS[k as keyof typeof CAPS];
if (v.length > cap) return { code: 'APPROVAL_RECORD_INVALID', message: `--${k} is ${v.length} characters (cap ${cap})` };
}
const outcome = (input.outcome ?? 'approved') as Outcome;
if (!OUTCOMES.includes(outcome)) return { code: 'APPROVAL_RECORD_INVALID', message: `--outcome must be one of ${OUTCOMES.join('|')}` };
const date = input.date ?? now.toISOString().slice(0, 10);
if (!validDate(date)) return { code: 'APPROVAL_RECORD_INVALID', message: `--date must be a real YYYY-MM-DD day` };
for (const [k, v] of Object.entries(fields)) {
const s = scan(v);
const hits = s.findings.filter(f => f.severity === 'HIGH' || f.severity === 'MEDIUM');
if (s.oversize || hits.length) return { code: 'APPROVAL_RECORD_SECRET', message: `field ${k}: ${s.oversize ? 'oversize input' : [...new Set(hits.map(f => f.id))].join(', ')}` };
}
const id = 'a-' + createHash('sha256').update([fields.source, date, fields.ask, fields.words, fields.by].join('\u0000')).digest('hex').slice(0, 12);
return { schema_version: 1, id, date, ask: fields.ask.trim(), words: fields.words.trim(), by: fields.by.trim(), scope: fields.scope.trim(), pending: fields.pending.trim(), outcome, source: fields.source, branch, recorded_at: now.toISOString() };
}
function writeMirror(loc: StoreLocation, rows: ApprovalRow[]): RecordError | undefined {
if (!loc.mirror) return undefined;
try {
fs.mkdirSync(path.dirname(loc.mirror), { recursive: true, mode: 0o700 });
const tmp = `${loc.mirror}.${process.pid}.tmp`;
fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''), { mode: 0o600 });
fs.renameSync(tmp, loc.mirror);
return undefined;
} catch (e: any) { return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `mirror ${loc.mirror}: ${e.code ?? e.message} (the local row is kept)` }; }
}
export interface AppendResult { added: ApprovalRow[]; skipped: number; mirrorError?: RecordError }
/** Append rows not already present (by id) as single-line appends, then mirror the merged record. */
export function appendRows(loc: StoreLocation, rows: ApprovalRow[]): AppendResult | RecordError {
const have = new Set(readRecord(loc).rows.map(r => r.id));
const fresh = rows.filter(r => !have.has(r.id));
try {
fs.mkdirSync(path.dirname(loc.file), { recursive: true, mode: 0o700 });
for (const r of fresh) fs.appendFileSync(loc.file, JSON.stringify(r) + '\n', { encoding: 'utf8', mode: 0o600 });
} catch (e: any) { return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `${loc.file}: ${e.code ?? e.message}` }; }
const mirrorError = writeMirror(loc, readRecord(loc).rows);
return { added: fresh, skipped: rows.length - fresh.length, ...(mirrorError ? { mirrorError } : {}) };
}
/** Re-mirror the merged record (after a failed mirror write, or to restore a local copy from the mirror). */
export function syncRecord(loc: StoreLocation): { rows: number; error?: RecordError } {
const { rows } = readRecord(loc);
try {
fs.mkdirSync(path.dirname(loc.file), { recursive: true, mode: 0o700 });
const tmp = `${loc.file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''), { mode: 0o600 });
fs.renameSync(tmp, loc.file);
} catch (e: any) { return { rows: rows.length, error: { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `${loc.file}: ${e.code ?? e.message}` } }; }
const error = writeMirror(loc, rows);
return { rows: rows.length, ...(error ? { error } : {}) };
}
/** One Markdown table cell: no row breaks, mentions, images, links, HTML, code spans or citation-shaped text. */
export function cell(text: string, cap = 400): string {
const clipped = text.length > cap ? text.slice(0, cap - 1).trimEnd() + '…' : text;
return clipped
.replace(/\r?\n+/g, ' ')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/@/g, '&#64;').replace(/\`/g, '&#96;').replace(/\[/g, '&#91;').replace(/\]/g, '&#93;').replace(/!/g, '&#33;')
.replace(/\|/g, '\\|')
.replace(/(approved)(\s+)(by)/gi, '$1&#32;$3');
}
export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?: boolean } = {}): string {
const head = o.heading === false ? [] : ['## Approvals', ''];
if (read.state === 'unavailable') return [...head, `Record unavailable on this machine: no approval record for branch ${cell(loc.branch)} here, and the state root is ephemeral with no Drive mirror, so this is not proof that nothing was approved.`, ''].join('\n');
if (!read.rows.length) return [...head, `No approvals recorded for branch ${cell(loc.branch)}.`, ''].join('\n');
const lines = ['| Date | Asked | Exact words | Outcome | Covers | Still pending |', '|---|---|---|---|---|---|'];
for (const r of read.rows) {
const outcome = r.outcome === 'standing' ? 'standing approval' : r.outcome;
lines.push(`| ${r.date} | ${cell(r.ask)} | “${cell(r.words, 280)}” (${cell(r.by)}) | ${outcome} | ${cell(r.scope)} | ${cell(r.pending)} |`);
}
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
}
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; standing?: string; carried_from?: string; label?: string; chosen_text?: string }
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
/**
* Rows for every answered gate in an autoplan run: one row per answer event (gate_rev, time, answerer), covering
* the items it settled. Carried-forward answers were recorded when first given and are skipped. Ids are stable,
* so a rerun (or a retry after an interrupted import) adds nothing twice.
*/
export function importAutoplan(runDir: string, branch: string, by?: string): ApprovalRow[] | RecordError {
const file = path.join(runDir, 'decisions.jsonl');
if (!fs.existsSync(file)) return { code: 'BRIEF_INPUT_MISSING', message: `decisions.jsonl not in ${runDir}` };
const rows: DecisionLike[] = [];
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim()) continue;
try { rows.push(JSON.parse(line)); } catch { return { code: 'ARTIFACT_MALFORMED_JSONL', message: `decisions.jsonl has a line that is not JSON` }; }
}
const run = (() => { try { return JSON.parse(fs.readFileSync(path.join(runDir, 'run.json'), 'utf8')); } catch { return {}; } })();
const plan = reviewedPlan(runDir, run);
const planTitle = firstTitle(plan.text) ?? 'the plan';
const planSha = plan.sha;
const runId = String(run.run ?? run.id ?? path.basename(runDir));
const answered = rows.filter(r => r.status && r.status !== 'pending' && r.answered_at && !r.carried_from);
if (!answered.length) return { code: 'APPROVAL_IMPORT_UNANSWERED', message: `no answered gate in ${runDir}` };
const groups = new Map<string, DecisionLike[]>();
for (const r of answered) {
const k = [r.gate_rev, r.answered_at, r.answered_by].join('|');
groups.set(k, [...(groups.get(k) ?? []), r]);
}
const out: ApprovalRow[] = [];
for (const group of groups.values()) {
const first = group[0]!;
const rev = first.gate_rev ?? 0;
const p1 = group.find(r => (r.label ?? '').toLowerCase() === 'p1' || (r.id ?? '').endsWith('-p1'));
const outcome: Outcome = !p1 ? 'pending' : p1.chosen === 'a' || p1.chosen === 'b' ? 'approved' : p1.chosen === 'e' ? 'rejected' : 'pending';
const items = group.filter(r => r !== p1);
const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended);
const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending');
const answerer = first.answered_by ?? 'unknown';
const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : first.standing ? `${by ?? 'the owner'}, standing approval ${first.standing} cited by ${answerer}` : `${by ?? 'the owner'}, relayed by ${answerer}`;
const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`;
const built = buildRow({
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
words: first.owner_reply ?? first.reply ?? '(no words recorded)',
by: who.slice(0, CAPS.by),
scope: scope.slice(0, CAPS.scope),
pending: (stillPending.length ? `${stillPending.length} decision(s) unanswered: ${stillPending.map(r => stripRef(r.title ?? '')).join('; ')}` : outcome === 'pending' ? 'the plan approval itself' : 'none').slice(0, CAPS.pending),
outcome,
date: first.answered_at!.slice(0, 10),
source: `autoplan:${runId}@gate_rev=${rev}${first.standing ? ` standing=${first.standing}` : ''}`,
}, branch, new Date(first.answered_at!));
if ('code' in built) return built;
out.push(built);
}
return out;
}