voyage-lekerepo end-to-end trial (2026-10-06), report points 8, 10, 12, 15, 16:
- approval-log: «Approve (Recommended)» / «Godkjenn (anbefalt)» is the
approval it labels; the suffix never turns a hollow answer into a yes.
- trekexecute Phase 8: the state-file block imports atomic-write through
${CLAUDE_PLUGIN_ROOT}; a relative import only ran with cwd = plugin root
(the test runs the block from another directory).
- trekreview, trekresearch, trekcontinue: stats go through stats-append like
the other commands, and a failed write is reported, never skipped
silently (closes the open STATE finding for all three). The schema
fixture's stale line refs for these and trekplan are re-measured.
- annotate prints an absolute path, so file://{$ANNOT_HTML} is a live link.
- red-proof: `red_kind` = load-error | test-failure on a proven red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
16 KiB
JavaScript
339 lines
16 KiB
JavaScript
// 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' }} */
|
|
// AskUserQuestion's own guidance puts «(Recommended)» after the label it
|
|
// recommends, so the operator's real «Approve (Recommended)» was refused in the
|
|
// voyage-lekerepo trial (2026-10-06). The suffix is stripped; what is left must
|
|
// still be an explicit approval on its own.
|
|
const RECOMMENDED_SUFFIX = /\s*\((?:recommended|anbefalt)\)\s*$/u;
|
|
|
|
export function isExplicitApproval(answer) {
|
|
const t = String(answer ?? '').trim().toLowerCase().replace(/[\s.!]+$/u, '')
|
|
.replace(RECOMMENDED_SUFFIX, '').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);
|
|
}
|