feat(brief): ceremony by size, one question round, logged and committed approvals

P1 (order 2, wave 1). /trekbrief puts the task in a size class
(ceremony: small|bounded|architectural, K1; heaviest in doubt, said
aloud, overridable, only moved up) that sets the question budget and the
review cap, and asks one round of questions: facts looked up, never
asked; recommendation first; ranked by impact x uncertainty with a
visible Deferred list; stop when the next three answers can be predicted.
"høres bra ut", "gjør som du vil" and an empty answer are not approval.

K2: lib/util/approval-log.mjs, an append-only hash-chained log in the
target repo (.claude/voyage/approvals.jsonl) bound to the approved
content's sha256; append/verify (0/1/2), answer, digest. Committed by
lib/util/artifact-commit.mjs: the log always, the brief and plan only
with artifact_store: git (operator decision 2026-10-06). /trekplan
Phase 1 halts on a broken, diverged, missing or mismatched log entry;
Phase 11 logs and commits an explicit plan approval before any handoff.

Both new brief fields are optional: no brief_version bump. The
main-module guard in intent-approval.mjs and brief-validator.mjs now
compares real paths. Borrowings credited in the prose and module headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 19:44:42 +02:00
commit 5244d2e8e8
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
16 changed files with 1527 additions and 85 deletions

332
lib/util/approval-log.mjs Normal file
View file

@ -0,0 +1,332 @@
// lib/util/approval-log.mjs
// The approval log (K2): an append-only, hash-chained record of what the
// operator approved, bound to the sha256 of the approved content. One log per
// target repo, at .claude/voyage/approvals.jsonl; it is committed to the target
// repo (operator decision 2026-10-06: the log always goes to git, the brief and
// the plan only when the operator chooses so). Kinds: intent, plan, review,
// merge, release. Nothing but this CLI reads or writes the file.
//
// Adapted from bashebr/ai-native-sdlc@11fb8d7 (MIT, © 2026 AI-Native SDLC
// contributors): its gate ledger (prev_hash + hash per record, verify walks the
// chain). Two of its flaws are corrected here: an approval there is not bound to
// the approved content (here every record carries the content's sha256 and
// `verify --sha256` checks the current content against it), and the agent can
// write its own approval unseen (here the log is committed, `verify` reports
// records that are not committed yet, and a rewrite of committed records is
// `diverged`).
//
// WHAT THE LOG DOES NOT PROVE. Same user, same machine: any session that can run
// this CLI can append an approval, exactly as it can run
// `intent-approval.mjs --stamp`. A record is a TRACE that the approval step ran
// against this content, NOT a signature binding a person. What it does catch:
// - an edited, deleted or reordered record that was not re-hashed (`broken`);
// - content changed after approval (`mismatch` for that subject);
// - an approval that never happened (`not-logged`);
// - a rewrite that re-hashes the whole chain, once the old chain was committed
// (`diverged`: the committed log is no longer a prefix of the file). Before the
// first commit, a full re-hash is invisible to the chain; git history is the
// only witness, which is why every approval is committed right away.
// An approval a session writes and commits on the operator's behalf is visible as
// a commit in `git log`, not prevented. That is the trust class, stated openly.
//
// CLI (exit 0 = ok, 1 = broken chain / refused, 2 = could not run; 2 is never green):
// node lib/util/approval-log.mjs append --kind <kind> --subject <path>
// (--sha256 <hex | sha256:hex> | --sha256-of <file>) [--expires <ISO>] [--repo <dir>]
// node lib/util/approval-log.mjs verify [--json] [--repo <dir>]
// [--kind <kind> --subject <path> (--sha256 <hex> | --sha256-of <file>)]
// node lib/util/approval-log.mjs answer [--text <answer>] (stdin when --text is absent)
// node lib/util/approval-log.mjs digest <file>
// The repo is --repo, else the git top level of the working directory, else the
// working directory. A subject inside the repo is stored relative to it.
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import {
appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync,
} from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
export const APPROVAL_KINDS = Object.freeze(['intent', 'plan', 'review', 'merge', 'release']);
export const LOG_RELPATH = '.claude/voyage/approvals.jsonl';
export const GENESIS = '0'.repeat(64);
const FIELDS = Object.freeze(['v', 'seq', 'ts', 'kind', 'subject', 'sha256', 'expires', 'prev']);
const HEX_RE = /^[0-9a-f]{64}$/;
const EXIT = Object.freeze({
intact: 0, empty: 0, approved: 0,
broken: 1, diverged: 1, 'not-logged': 1, mismatch: 1, expired: 1,
error: 2,
});
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
function realOr(p) {
try { return realpathSync(p); } catch { /* absent */ }
try { return join(realpathSync(dirname(p)), basename(p)); } catch { return resolve(p); }
}
/** The repo the log belongs to: explicit, else git top level of cwd, else cwd. */
export function resolveRepo(explicit, cwd = process.cwd()) {
if (explicit) return realOr(resolve(cwd, explicit));
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
if (r.status === 0 && r.stdout.trim()) return realOr(r.stdout.trim());
return realOr(cwd);
}
export function logPath(repo) { return join(repo, ...LOG_RELPATH.split('/')); }
/** A subject inside the repo is stored repo-relative with forward slashes; outside, absolute. */
export function normalizeSubject(repo, subject, cwd = process.cwd()) {
const real = realOr(resolve(cwd, subject));
const rel = relative(repo, real);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.split(sep).join('/');
return real;
}
/** "sha256:<hex>" or "<hex>" → lowercase hex, or null. */
export function normalizeHex(value) {
const v = String(value ?? '').trim().replace(/^sha256:/, '').toLowerCase();
return HEX_RE.test(v) ? v : null;
}
export function digestFile(file) {
return sha256(readFileSync(file));
}
export function recordHash(rec) {
const canon = {};
for (const k of FIELDS) canon[k] = rec[k] ?? null;
return sha256(JSON.stringify(canon));
}
/** Read and walk the chain. @returns {{ exists, lines, records, broken: null | {line, why}, error? }} */
export function readLog(repo) {
const p = logPath(repo);
if (!existsSync(p)) return { exists: false, lines: [], records: [], broken: null };
let text;
try {
if (statSync(p).isDirectory()) return { exists: true, lines: [], records: [], broken: null, error: `${p} is a directory` };
text = readFileSync(p, 'utf8');
} catch (e) {
return { exists: true, lines: [], records: [], broken: null, error: `cannot read ${p}: ${e.message}` };
}
const lines = text.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
const records = [];
let prev = GENESIS;
for (let i = 0; i < lines.length; i++) {
const at = i + 1;
let rec;
try { rec = JSON.parse(lines[i]); } catch { return { exists: true, lines, records, broken: { line: at, why: 'not JSON' } }; }
const keys = rec && typeof rec === 'object' ? Object.keys(rec).sort().join(',') : '';
if (keys !== [...FIELDS, 'hash'].sort().join(',')) return { exists: true, lines, records, broken: { line: at, why: 'fields differ from a record' } };
if (rec.seq !== at) return { exists: true, lines, records, broken: { line: at, why: `seq ${rec.seq}, expected ${at}` } };
if (rec.prev !== prev) return { exists: true, lines, records, broken: { line: at, why: 'prev does not link to the record before' } };
if (recordHash(rec) !== rec.hash) return { exists: true, lines, records, broken: { line: at, why: 'hash does not match the record' } };
records.push(rec);
prev = rec.hash;
}
return { exists: true, lines, records, broken: null };
}
/** The committed log's lines, or null when there is no git, no HEAD or the log is not tracked. */
function committedLines(repo) {
const r = spawnSync('git', ['-C', repo, 'show', `HEAD:${LOG_RELPATH}`], { encoding: 'utf8' });
if (r.status !== 0) return null;
const lines = r.stdout.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
return lines;
}
function inGit(repo) {
return spawnSync('git', ['-C', repo, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' }).status === 0;
}
/**
* Verify the chain, and optionally that `subject`'s latest `kind` record approves `sha`.
* @returns {{ status: string, exit: number, records: number, uncommitted: number|null, detail?: string, record?: object }}
*/
export function verifyLog({ repo, kind, subject, sha, now = new Date() }) {
const log = readLog(repo);
const base = { log: logPath(repo), records: log.records.length, uncommitted: null };
if (log.error) return { ...base, status: 'error', exit: 2, detail: log.error };
if (log.broken) return { ...base, status: 'broken', exit: 1, detail: `line ${log.broken.line}: ${log.broken.why}` };
if (log.exists && inGit(repo)) {
const head = committedLines(repo) || [];
const prefix = head.length <= log.lines.length && head.every((l, i) => l === log.lines[i]);
if (!prefix) {
return { ...base, status: 'diverged', exit: 1, detail: 'the committed log is not a prefix of the file: committed approvals were rewritten or removed' };
}
base.uncommitted = log.lines.length - head.length;
}
if (subject === undefined) {
const status = log.records.length ? 'intact' : 'empty';
return { ...base, status, exit: EXIT[status] };
}
const latest = [...log.records].reverse().find((r) => r.kind === kind && r.subject === subject);
if (!latest) return { ...base, status: 'not-logged', exit: 1, detail: `no ${kind} approval for ${subject} in the log` };
if (latest.sha256 !== sha) {
return { ...base, status: 'mismatch', exit: 1, record: latest, detail: `the latest ${kind} approval for ${subject} covers other content` };
}
if (latest.expires && Date.parse(latest.expires) <= now.getTime()) {
return { ...base, status: 'expired', exit: 1, record: latest, detail: `the approval expired ${latest.expires}` };
}
return { ...base, status: 'approved', exit: 0, record: latest };
}
function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
function withLock(p, fn) {
const lock = `${p}.lock`;
for (let i = 0; i < 200; i++) {
try { mkdirSync(lock); break; } catch (e) {
if (e.code !== 'EEXIST') throw e;
try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmdirSync(lock); } catch { /* raced */ }
if (i === 199) throw new Error(`log is locked: ${lock}`);
sleep(25);
}
}
try { return fn(); } finally { try { rmdirSync(lock); } catch { /* gone */ } }
}
/**
* Append one approval. Refuses (exit 1) a bad kind/subject/hash/expiry or a log
* that does not verify; never extends a broken or diverged chain.
*/
export function appendApproval({ repo, kind, subject, sha, expires, now = new Date() }) {
if (!APPROVAL_KINDS.includes(kind)) return { appended: false, exit: 1, reason: `kind "${kind}" not in [${APPROVAL_KINDS.join(', ')}]` };
if (!subject) return { appended: false, exit: 1, reason: '--subject is required' };
if (!sha) return { appended: false, exit: 1, reason: '--sha256 must be 64 hex characters (optionally prefixed "sha256:")' };
let exp = null;
if (expires !== undefined) {
const t = Date.parse(expires);
if (Number.isNaN(t)) return { appended: false, exit: 1, reason: `--expires "${expires}" is not an ISO-8601 time` };
exp = new Date(t).toISOString();
}
const p = logPath(repo);
try {
mkdirSync(dirname(p), { recursive: true });
return withLock(p, () => {
const v = verifyLog({ repo, now });
if (v.exit === 2) return { appended: false, exit: 2, reason: v.detail };
if (v.exit === 1) return { appended: false, exit: 1, reason: `the log does not verify (${v.status}: ${v.detail}); nothing appended` };
const log = readLog(repo);
const last = log.records[log.records.length - 1];
const rec = {
v: 1, seq: log.records.length + 1, ts: now.toISOString(), kind, subject, sha256: sha,
expires: exp, prev: last ? last.hash : GENESIS,
};
rec.hash = recordHash(rec);
let raw = '';
try { raw = readFileSync(p, 'utf8'); } catch { /* new log */ }
appendFileSync(p, (raw && !raw.endsWith('\n') ? '\n' : '') + JSON.stringify(rec) + '\n');
return { appended: true, exit: 0, record: rec, log: p };
});
} catch (e) {
return { appended: false, exit: 2, reason: e.message };
}
}
// Answers that are not approval. Adapted from addyosmani/agent-skills@9d0c60d
// (MIT, © 2025 Addy Osmani), skills/interview-me: «Sounds good» and «Whatever
// you think» are not a yes. Only an explicit approval counts; everything else,
// the empty answer included, is not one.
const EXPLICIT = new Set([
'approve', 'approved', 'i approve', 'yes', 'yes, approve', 'yes approve',
'godkjenn', 'godkjent', 'godkjenner', 'jeg godkjenner', 'ja', 'ja, godkjent', 'ja godkjent',
]);
const HOLLOW = [
'høres bra ut', 'hores bra ut', 'gjør som du vil', 'gjor som du vil', 'ser bra ut', 'greit nok',
'sounds good', 'whatever you think', 'looks good', 'lgtm', 'up to you', 'your call', 'fine by me',
'ok', 'okay', 'sure', 'fine', 'kjør på', 'kjor pa',
];
/** @returns {{ approved: boolean, reason: 'explicit'|'empty'|'hollow'|'not-explicit' }} */
export function isExplicitApproval(answer) {
const t = String(answer ?? '').trim().toLowerCase().replace(/[\s.!]+$/u, '').replace(/\s+/g, ' ');
if (!t) return { approved: false, reason: 'empty' };
if (EXPLICIT.has(t)) return { approved: true, reason: 'explicit' };
if (HOLLOW.some((h) => t === h || (h.includes(' ') && t.includes(h)))) return { approved: false, reason: 'hollow' };
return { approved: false, reason: 'not-explicit' };
}
// ---- CLI ----
function opt(args, name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function out(obj, code) {
process.stdout.write(JSON.stringify(obj) + '\n');
process.exit(code);
}
function shaFromArgs(args) {
const of = opt(args, '--sha256-of');
if (of !== undefined) {
try { return { sha: digestFile(of) }; } catch (e) { return { error: `cannot read ${of}: ${e.message}` }; }
}
const raw = opt(args, '--sha256');
return { sha: raw === undefined ? undefined : normalizeHex(raw), raw };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'answer') {
let text = opt(args, '--text');
if (text === undefined) { try { text = readFileSync(0, 'utf8'); } catch { text = ''; } }
const r = isExplicitApproval(text);
out(r, r.approved ? 0 : 1);
}
if (cmd === 'digest') {
const file = args.find((a) => !a.startsWith('--'));
try { process.stdout.write(digestFile(file) + '\n'); process.exit(0); } catch (e) {
process.stderr.write(`approval-log digest: cannot read ${file}: ${e.message}\n`);
process.exit(2);
}
}
if (cmd !== 'append' && cmd !== 'verify') {
process.stderr.write('Usage: approval-log.mjs append|verify|answer|digest — see the header of lib/util/approval-log.mjs\n');
process.exit(2);
}
const repo = resolveRepo(opt(args, '--repo'));
const kind = opt(args, '--kind');
const subjectArg = opt(args, '--subject');
const { sha, raw, error } = shaFromArgs(args);
if (cmd === 'append') {
if (error) out({ appended: false, reason: error }, 2);
const r = appendApproval({
repo, kind, subject: subjectArg ? normalizeSubject(repo, subjectArg) : '',
sha: raw !== undefined || sha ? sha : undefined, expires: opt(args, '--expires'),
});
const { exit, ...rest } = r;
out(rest, exit);
}
// verify
const filtered = [kind, subjectArg, sha ?? raw ?? error].filter((x) => x !== undefined).length;
let r;
if (error) r = { status: 'error', exit: 2, detail: error };
else if (filtered !== 0 && filtered !== 3) r = { status: 'error', exit: 2, detail: 'a subject check needs --kind, --subject and --sha256 (or --sha256-of) together' };
else if (filtered === 3 && !sha) r = { status: 'error', exit: 2, detail: `--sha256 "${raw}" is not 64 hex characters` };
else if (filtered === 3 && !APPROVAL_KINDS.includes(kind)) r = { status: 'error', exit: 2, detail: `kind "${kind}" not in [${APPROVAL_KINDS.join(', ')}]` };
else r = verifyLog({ repo, kind, subject: filtered === 3 ? normalizeSubject(repo, subjectArg) : undefined, sha });
const { exit, ...rest } = r;
if (args.includes('--json')) {
process.stdout.write(JSON.stringify(rest) + '\n');
} else {
process.stdout.write(`approval-log: ${rest.status}${rest.detail ? ` — ${rest.detail}` : ''}\n`);
if (rest.uncommitted) process.stdout.write(` ${rest.uncommitted} approval(s) not committed yet\n`);
}
process.exit(exit);
}

View file

@ -0,0 +1,114 @@
// lib/util/artifact-commit.mjs
// Commit an approved artifact in the TARGET repo. The approval log
// (lib/util/approval-log.mjs) is always committed; the artifact itself (brief.md,
// plan.md) only when the operator chose `artifact_store: git`. With `local` the
// artifact's folder is kept out of git through the repo's own .git/info/exclude,
// a file that is never committed, so no shared file changes (operator decision
// 2026-10-06: «Loggen alltid, briefen etter valg»).
//
// The commit names its paths (`git commit --only -- <paths>`): whatever else the
// operator has staged stays staged and uncommitted. A path the repo ignores is
// never forced in; the refusal says so. The repo's own git hooks run as usual.
//
// CLI:
// node lib/util/artifact-commit.mjs --store <git|local> [--artifact <path>]...
// [--message <msg>] [--repo <dir>]
// prints {committed, commit?, paths, excluded, warnings, reason?}
// exit 0 = committed, or nothing new to commit; 1 = refused (ignored path, path
// outside the repo, git add/commit failed); 2 = could not run (not a git repo,
// bad arguments, git timed out). 2 is never green.
import { spawnSync } from 'node:child_process';
import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { LOG_RELPATH, logPath, normalizeSubject } from './approval-log.mjs';
export const ARTIFACT_STORES = Object.freeze(['git', 'local']);
const DEFAULT_MESSAGE = 'docs(voyage): record approval';
function git(repo, args, timeout = 60_000) {
return spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout });
}
/** Add `/<dir>/` to the repo's .git/info/exclude once. @returns the pattern */
function excludeLocally(repo, rel) {
const slash = rel.lastIndexOf('/');
const pattern = slash > 0 ? `/${rel.slice(0, slash)}/` : `/${rel}`;
const where = git(repo, ['rev-parse', '--git-path', 'info/exclude']).stdout.trim();
const file = isAbsolute(where) ? where : resolve(repo, where);
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
if (!text.split('\n').includes(pattern)) {
appendFileSync(file, (text && !text.endsWith('\n') ? '\n' : '') + pattern + '\n');
}
return pattern;
}
/**
* @returns {{ exit: number, committed: boolean, commit?: string, paths: string[],
* excluded: string[], warnings: string[], reason?: string }}
*/
export function commitArtifacts({ repo, store, artifacts = [], message = DEFAULT_MESSAGE }) {
const res = { committed: false, paths: [], excluded: [], warnings: [] };
if (!ARTIFACT_STORES.includes(store)) {
return { ...res, exit: 2, reason: `--store must be one of [${ARTIFACT_STORES.join(', ')}]` };
}
if (existsSync(logPath(repo))) res.paths.push(LOG_RELPATH);
for (const a of artifacts) {
const rel = normalizeSubject(repo, a);
if (isAbsolute(rel)) return { ...res, exit: 1, reason: `${a} is outside the repo ${repo}` };
if (store === 'git') { res.paths.push(rel); continue; }
if (git(repo, ['ls-files', '--error-unmatch', '--', rel]).status === 0) {
res.warnings.push(`${rel} is already tracked; it stays in git (local applies to new files only)`);
continue;
}
res.excluded.push(excludeLocally(repo, rel));
}
if (!res.paths.length) return { ...res, exit: 0, reason: 'nothing to commit' };
const add = git(repo, ['add', '--', ...res.paths]);
if (add.status !== 0) {
const ignored = /ignored/i.test(add.stderr);
return {
...res, exit: 1,
reason: ignored
? `${res.paths.join(', ')}: ignored by the repo (.gitignore); not forced in — nothing committed`
: `git add failed: ${add.stderr.trim()}`,
};
}
if (git(repo, ['diff', '--cached', '--quiet', '--', ...res.paths]).status === 0) {
return { ...res, exit: 0, reason: 'nothing to commit' };
}
const c = git(repo, ['commit', '-q', '--only', '-m', message, '--', ...res.paths]);
if (c.error) return { ...res, exit: 2, reason: `git commit did not finish: ${c.error.message}` };
if (c.status !== 0) return { ...res, exit: 1, reason: `git commit failed: ${(c.stderr || c.stdout).trim()}` };
return { ...res, exit: 0, committed: true, commit: git(repo, ['rev-parse', 'HEAD']).stdout.trim() };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
const artifacts = args.flatMap((a, i) => (a === '--artifact' && args[i + 1] !== undefined ? [args[i + 1]] : []));
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd: opt('--repo') ? resolve(opt('--repo')) : process.cwd(), encoding: 'utf8',
});
let r;
if (top.status !== 0 || !top.stdout.trim()) {
r = { exit: 2, committed: false, paths: [], excluded: [], warnings: [], reason: 'not a git repository — nothing committed' };
} else {
r = commitArtifacts({
repo: realpathSync(top.stdout.trim()), store: opt('--store'), artifacts, message: opt('--message') || DEFAULT_MESSAGE,
});
}
const { exit, ...rest } = r;
process.stdout.write(JSON.stringify(rest) + '\n');
process.exit(exit);
}