Files
gstack/bin/gstack-docs-candidate
T
garrytanandcapy-ai[bot] b5898e6854 fix(ship-receipt): write honors every docs audit in one /ship run (GST-1616)
When Step 14.5 audits twice in one /ship run (an audit, then a base merge and
restamp, then the re-audit), receipt write reset only the last audit's files,
so a file the first audit edited and committed stayed in the review_exclude
selection and write refused the reviewed digest (gbrain #8678).

gstack-docs-candidate snapshot now takes --run (the invocation record), --pre
on a post snapshot (records the files the audit edited, before and after) and
--carry (the run's earlier post snapshot, refused from another run). Write
resets every file the run's audits edited to its content before them when HEAD
holds what the last of them left; a file no audit edited, an edit after an
audit and a carried audit of another run still refuse.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-06 12:38:26 +00:00

309 lines
20 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-docs-candidate — /ship's documentation-audit snapshot and its later check.
*
* Usage (run inside the repository):
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
* --base <rev> [--select <path>]... [--docs <path>[,<path>...]]... [--exception <docs-audit.json>]
* [--run <id>] [--pre <prepare.json> [--carry <earlier-post.json>]]
* gstack-docs-candidate compare <candidate.json>
* gstack-docs-candidate attempt <child-output> --state <attempts.json>
* gstack-docs-candidate [snapshot|compare] --help
*
* snapshot saves, NUL-safely and without writing to the repository: base SHA,
* HEAD, branch, index entries, committed (base..HEAD), staged, unstaged and
* untracked paths, and git blob hashes of the working-tree content of every
* selected path, docs path and dirty/untracked path. Without --select, the
* selected paths are the committed, staged, unstaged and untracked paths.
* --docs takes files or directories (directories expand to their tracked and
* untracked files): repeat the flag, or give a comma-separated list (each entry
* trimmed). A value that names an existing file or directory is one path, commas
* and spaces included; any other value holding whitespace is refused, because a
* space-separated list cannot be told from a path with a space. It prints a short summary; the JSON file is the record.
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
* (Step 14.5 Blocked recovery): the record then carries that exception, so
* Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
* or already contains it while the branch has commits its real base lacks is
* refused (BASE_CONTAINS_HEAD, lib/default-base.ts): it would select nothing.
* --run names the /ship invocation (its record's path) and is saved as run_id.
* --pre <Prepare snapshot> marks a post snapshot: it must be this audit's
* (same audit id, HEAD and run) and the record saves `edited`, each path whose
* content the audit changed with its content before (the Prepare hash, else
* HEAD's blob, null when absent) and after. --carry <an earlier audit's post
* snapshot> (with --pre and --run) saves that audit and the ones it carried as
* `earlier`, so receipt write resets every file this run's audits edited
* (GST-1616); a snapshot of another run, one without `edited`, or one whose
* HEAD is not an ancestor of HEAD is refused.
*
* compare re-reads the same state and prints one JSON object: whether HEAD or
* the index changed, which recorded paths' content changed or disappeared, and
* which paths are newly dirty or untracked. It never decides whether a change
* was permitted; the caller checks those paths against the child's report.
*
* attempt reads one audit child's output and says whether the launch counts
* against Step 14.5's attempt budget. A launch the child's install check refused
* (an `INSTALL: fail` row, its last GSTACK_RESULT line a document-release
* refusal, and no audit JSON) never audited, so it is not counted, once per
* invocation (--state, the invocation's record); a second refusal is counted
* and stops for the install fix (GST-391, gbrain #8084).
*/
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { baseContainsHead } from '../lib/default-base';
import { baseArg } from '../lib/gate-diff/git';
import { resultLine } from '../lib/result-codes';
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
function git(args: string[]): string {
const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
return r.stdout;
}
const nul = (text: string) => text.split('\0').filter(Boolean);
function state(base: string) {
const head = git(['rev-parse', 'HEAD']).trim();
return {
head,
branch: git(['branch', '--show-current']).trim(),
index: nul(git(['ls-files', '-s', '-z'])),
committed: nul(git(['diff', '--name-only', '-z', base, head])),
staged: nul(git(['diff', '--cached', '--name-only', '-z'])),
unstaged: nul(git(['diff', '--name-only', '-z'])),
untracked: nul(git(['ls-files', '-z', '--others', '--exclude-standard'])),
};
}
function expand(listed: string[], paths: string[]): string[] {
const out = new Set<string>();
for (const p of paths) {
const rel = p.replace(/^\.\//, '').replace(/\/+$/, '');
const under = listed.filter(f => f === rel || f.startsWith(rel + '/'));
if (under.length) under.forEach(f => out.add(f)); else out.add(rel);
}
return [...out].sort();
}
/** --docs values as paths: a value naming an existing file or directory is one path; any other splits on commas, and an entry with whitespace or a value with no entry is refused. */
function docsPaths(listed: string[], values: string[]): string[] {
const exists = (p: string) => {
const rel = p.replace(/^\.\//, '').replace(/\/+$/, '');
return fs.existsSync(rel) || listed.some(f => f === rel || f.startsWith(rel + '/'));
};
return values.flatMap(v => {
const paths = exists(v) ? [v] : v.split(',').map(p => p.trim()).filter(Boolean);
if (!paths.length) throw new Error(`--docs ${JSON.stringify(v)} names no path: give a file or directory, repeat --docs for each path, or separate paths with commas`);
return paths;
}).map(p => {
if (/\s/.test(p) && !exists(p)) throw new Error(`--docs ${JSON.stringify(p)} names no file or directory and holds whitespace: repeat --docs for each path, or separate paths with commas`);
return p;
});
}
/** Git blob ids of working-tree content, computed in-process (any path bytes, nothing written); null when absent. */
function hashes(paths: string[]): Record<string, string | null> {
const out: Record<string, string | null> = {};
for (const p of paths) {
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) { out[p] = null; continue; }
const content = fs.readFileSync(p);
out[p] = new Bun.CryptoHasher('sha1').update(`blob ${content.length}\0`).update(content).digest('hex');
}
return out;
}
function flag(args: string[], name: string): string[] {
const values: string[] = [];
for (let i = 0; i < args.length; i++) if (args[i] === name) {
const value = args[i + 1];
if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value`);
values.push(value); i++;
}
return values;
}
function one(args: string[], name: string): string {
const values = flag(args, name);
if (values.length !== 1) throw new Error(`${name} is required exactly once`);
return values[0]!;
}
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>[,<path>...]]... [--exception <docs-audit.json>] [--run <id>] [--pre <prepare.json> [--carry <earlier-post.json>]]
gstack-docs-candidate compare <candidate.json>
gstack-docs-candidate attempt <child-output> --state <attempts.json>`;
/** The owner's accepted Step 14.5 exception from the blocked .gstack/tmp/docs-audit.json record, read by the receipt's own parser; throws when the record is not blocked or names no complete exception. */
function acceptedException(file: string): NonNullable<ReceiptDocs['exception']> & { scope: string } {
let audit: unknown;
try { audit = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new Error(`--exception ${file} is not readable JSON: ${(error as Error).message}`); }
const parsed = parseReceiptDocs(audit);
const problem = !parsed.ok ? parsed.message : parsed.docs.status !== 'blocked' ? `its status is ${parsed.docs.status}, not blocked` : !parsed.docs.exception ? 'it names no accepted exception' : null;
if (problem || !parsed.ok || !parsed.docs.exception) throw new Error(`--exception ${file}: ${problem}. Only an owner-accepted documentation risk writes this snapshot (ship/sections/documentation.md, Blocked recovery step 3)`);
return { ...parsed.docs.exception, scope: parsed.docs.scope };
}
/** One value for an optional flag given at most once. */
function optional(args: string[], name: string): string | undefined {
const values = flag(args, name);
if (values.length > 1) throw new Error(`${name} is allowed once`);
if (values[0] !== undefined && (!values[0].trim() || /[\x00-\x1f\x7f]/.test(values[0]))) throw new Error(`${name} must be a non-empty one-line value`);
return values[0];
}
function readRecord(file: string, name: string): Record<string, any> {
let record: unknown;
try { record = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new Error(`${name} ${file} is not readable JSON: ${(error as Error).message.split('\n')[0]}`); }
if (!record || typeof record !== 'object' || Array.isArray(record) || typeof (record as any).head !== 'string' || !(record as any).hashes || typeof (record as any).hashes !== 'object') throw new Error(`${name} ${file} is not a gstack-docs-candidate snapshot`);
return record as Record<string, any>;
}
type Edited = Record<string, { before: string | null; after: string | null }>;
/** The post snapshot's own audit: each path whose content differs from this audit's Prepare snapshot, before (its Prepare hash, else HEAD's blob) and after. */
function auditEdits(pre: Record<string, any>, file: string, post: { audit_id: string; run_id?: string; head: string; hashes: Record<string, string | null> }): { edited: Edited } {
if (pre.audit_id !== post.audit_id || pre.head !== post.head || pre.run_id !== post.run_id) throw new Error(`--pre ${file} is not this audit's Prepare snapshot (its audit id, HEAD or run differs)`);
const paths = [...new Set([...Object.keys(pre.hashes), ...Object.keys(post.hashes)])].sort();
const now = hashes(paths.filter(p => !(p in post.hashes)));
const blob = (p: string) => spawnSync('git', ['rev-parse', '--verify', '--quiet', `${pre.head}:${p}`], { encoding: 'utf8', timeout: 60_000 }).stdout?.trim() || null;
const edited: Edited = {};
for (const p of paths) {
const before = p in pre.hashes ? pre.hashes[p] : blob(p);
const after = p in post.hashes ? post.hashes[p]! : now[p]!;
if (before !== after) edited[p] = { before, after };
}
return { edited };
}
/** An earlier audit of this run and the audits it carried, oldest first; refuses another run's snapshot, one without `edited` and one whose HEAD is not an ancestor of HEAD. */
function carried(prior: Record<string, any>, file: string, post: { audit_id: string; run_id?: string; head: string }): Array<{ run_id: string; audit_id: string; head: string; edited: Edited }> {
if (prior.run_id !== post.run_id) throw new Error(`--carry ${file} is audit ${prior.audit_id} of another /ship run (${prior.run_id ?? 'no --run'}), not ${post.run_id}: a post snapshot carries only this run's earlier audits`);
if (!prior.edited || typeof prior.edited !== 'object' || Array.isArray(prior.edited)) throw new Error(`--carry ${file} names no edited files: take each post snapshot with --pre <its Prepare snapshot>`);
if (prior.audit_id === post.audit_id) throw new Error(`--carry ${file} is this audit (${post.audit_id}), not an earlier one`);
if (spawnSync('git', ['merge-base', '--is-ancestor', String(prior.head), post.head], { timeout: 60_000 }).status !== 0) throw new Error(`--carry ${file}: its HEAD ${String(prior.head).slice(0, 12)} is not an ancestor of HEAD`);
return [...(Array.isArray(prior.earlier) ? prior.earlier : []), { run_id: prior.run_id, audit_id: prior.audit_id, head: prior.head, edited: prior.edited }];
}
/** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */
function recordProblems(record: any): string[] {
if (!record || typeof record !== 'object' || Array.isArray(record)) return ['not a JSON object'];
const problems: string[] = [];
if (record.schema_version !== 1) problems.push('missing or wrong schema_version (want 1)');
if (typeof record.base !== 'string') problems.push('missing or wrong base (want the base commit SHA string)');
if (!record.hashes || typeof record.hashes !== 'object' || Array.isArray(record.hashes)) problems.push('missing or wrong hashes (want an object of path -> blob id)');
return problems;
}
function main(argv: string[]): void {
const [command, ...args] = argv;
const topHelp = (command === '--help' || command === '-h') && !args.length;
const commandHelp = (command === 'snapshot' || command === 'compare' || command === 'attempt') && args.length === 1 && args[0] === '--help';
if (topHelp || commandHelp) {
console.log(USAGE);
return;
}
if (command === 'snapshot') {
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs', '--exception', '--run', '--pre', '--carry']);
for (let i = 0; i < args.length; i += 2) if (!known.has(args[i]!)) throw new Error(`unknown argument: ${args[i]}`);
const out = path.resolve(one(args, '--out'));
const top = fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim());
if (out === top || out.startsWith(top + path.sep)) throw new Error('--out must be outside the repository');
const mode = one(args, '--mode');
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
const base = git(['rev-parse', '--verify', baseArg('gstack-docs-candidate', one(args, '--base'), process.cwd()) + '^{commit}']).trim();
const self = baseContainsHead(top, base);
if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
const exceptionFile = flag(args, '--exception')[0];
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
const [preFile, carryFile] = ['--pre', '--carry'].map(name => { const v = optional(args, name); return v === undefined ? undefined : path.resolve(v); });
process.chdir(top);
const now = state(base);
const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']));
const selected = flag(args, '--select').length ? expand(listed, flag(args, '--select'))
: [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
const docs = expand(listed, docsPaths(listed, flag(args, '--docs')));
const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
const auditId = one(args, '--audit-id');
const run = optional(args, '--run');
const record = { schema_version: 1, audit_id: auditId, ...(run === undefined ? {} : { run_id: run }), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) };
if (carryFile !== undefined && (preFile === undefined || run === undefined)) throw new Error('--carry needs --pre and --run: only a post snapshot of a named /ship run carries an earlier audit');
const audit = preFile === undefined ? {} : auditEdits(readRecord(preFile, '--pre'), preFile, record);
const earlier = carryFile === undefined ? {} : { earlier: carried(readRecord(carryFile, '--carry'), carryFile, record) };
fs.writeFileSync(out, JSON.stringify({ ...record, ...audit, ...earlier }, null, 2) + '\n', { mode: 0o600 });
console.log(`CANDIDATE: ${out}`);
console.log(`base=${base} head=${now.head} branch=${now.branch || '(detached)'} mode=${mode}`);
if (exception) console.log(`EXCEPTION: accepted by ${exception.accepted_by} ${exception.date} via ${exception.via}`);
if ('edited' in audit) console.log(`edited: ${Object.keys(audit.edited).map(p => JSON.stringify(p)).join(' ') || '(none)'}`);
if ('earlier' in earlier) console.log(`earlier: ${earlier.earlier.map(e => e.audit_id).join(' ')}`);
console.log(`committed=${now.committed.length} staged=${now.staged.length} unstaged=${now.unstaged.length} untracked=${now.untracked.length} selected=${selected.length} docs=${docs.length} hashed=${tracked.length}`);
for (const [label, list] of [['committed', now.committed], ['staged', now.staged], ['unstaged', now.unstaged], ['untracked', now.untracked], ['docs', docs]] as const) {
if (list.length) console.log(`${label}: ${list.map(p => JSON.stringify(p)).join(' ')}`);
}
return;
}
if (command === 'compare') {
if (args.length !== 1) throw new Error('compare takes exactly one candidate path');
const file = args[0]!;
let record: any;
try { record = JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw error; record = undefined; }
const problems = record === undefined ? ['not valid JSON'] : recordProblems(record);
if (problems.length) {
throw new Error(`${file} is not a gstack-docs-candidate record: ${problems.join('; ')}. `
+ 'Fix: the candidate must be the record the Prepare snapshot wrote. To rebuild it, rerun the Prepare snapshot '
+ '(gstack-docs-candidate snapshot --out <path> --audit-id <id> --mode edit --base <rev> ...) only before dispatching '
+ 'the child audit. After the audit, never snapshot over it: the post-child snapshot uses a different --out '
+ '(<audit-id>-post.json).');
}
process.chdir(fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()));
const now = state(record.base);
const current = hashes(Object.keys(record.hashes));
const content_changed = Object.keys(record.hashes).filter(p => current[p] !== record.hashes[p]).sort();
const dirty = (s: { staged: string[]; unstaged: string[]; untracked: string[] }) => new Set([...s.staged, ...s.unstaged, ...s.untracked]);
const before = dirty(record);
const newly_dirty = [...dirty(now)].filter(p => !before.has(p) && !(p in record.hashes)).sort();
const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);
console.log(JSON.stringify({
audit_id: record.audit_id,
head_changed: now.head !== record.head,
index_changed: !sameList(now.index, record.index),
content_changed,
newly_dirty,
}));
return;
}
if (command === 'attempt') {
const INSTALL_FIX = 'run the Capy skill installer (scripts/install.sh --for document-release), then launch with its GSTACK_LAUNCH line';
const [file, ...rest] = args;
if (!file || file.startsWith('--')) throw new Error('attempt takes the child output file, then --state <attempts.json>');
const state = one(rest, '--state');
const lines = fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(Boolean);
const last = lines.filter(l => l.startsWith('GSTACK_RESULT: ')).pop();
const install = lines.find(l => /^INSTALL: fail\b/.test(l));
const audited = lines.some(l => { try { const v = JSON.parse(l); return !!v && typeof v === 'object' && 'schema_version' in v; } catch { return false; } });
if (!last || !/^GSTACK_RESULT: skill=document-release status=refused\b/.test(last) || !install || audited) { console.log('ATTEMPT: counted'); return; }
let seen = 0;
try { seen = Number(JSON.parse(fs.readFileSync(state, 'utf8')).not_counted) || 0; } catch { seen = fs.existsSync(state) ? 1 : 0; }
if (seen >= 1) {
console.log('ATTEMPT: counted (a second install refusal in this invocation)');
console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`);
process.exit(3);
}
fs.mkdirSync(path.dirname(path.resolve(state)), { recursive: true });
try { fs.writeFileSync(state, JSON.stringify({ not_counted: 1 }) + '\n', { mode: 0o600, flag: 'wx' }); } catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
console.log('ATTEMPT: counted (another launch in this invocation already used the allowance)');
console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`);
process.exit(3);
}
console.log('ATTEMPT: not-counted (the audit child\'s install check refused document-release)');
return;
}
throw new Error(USAGE);
}
try { main(process.argv.slice(2)); }
catch (error) {
console.error(`gstack-docs-candidate: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}