merge: wave 1 order 2 (intent) into main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:37:12 +02:00
commit 21c28df3bc
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);
}

View file

@ -6,7 +6,8 @@
// optional fields (jf. the source_findings precedent on trekreview) can be
// added without a brief_version bump.
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
@ -24,6 +25,25 @@ export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replac
// v5.5 — obligatory TL;DR section (≤ 5 content lines) at the top of brief.md,
// gated at brief_version ≥ 2.2. Soft cap is a warning, not a blocker.
export const BRIEF_TLDR_MAX_LINES = 5;
// K1 — the brief's size class (ceremony by size). Optional, additive, no
// brief_version bump; absent (or invalid) reads as the heaviest class.
export const BRIEF_CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
export const BRIEF_CEREMONY_DEFAULT = 'architectural';
// Where the brief and the plan live: committed (`git`) or kept out of git
// (`local`). The approval log is committed either way. Optional, additive;
// absent reads as `local`, so nothing is published without the operator's yes.
export const BRIEF_ARTIFACT_STORE_VALUES = Object.freeze(['git', 'local']);
export const BRIEF_ARTIFACT_STORE_DEFAULT = 'local';
/** The brief's size class; absent or invalid → architectural (never lighter in doubt). */
export function briefCeremony(fm) {
return BRIEF_CEREMONY_VALUES.includes(fm?.ceremony) ? fm.ceremony : BRIEF_CEREMONY_DEFAULT;
}
/** Where the brief and plan are stored; absent or invalid → local. */
export function briefArtifactStore(fm) {
return BRIEF_ARTIFACT_STORE_VALUES.includes(fm?.artifact_store) ? fm.artifact_store : BRIEF_ARTIFACT_STORE_DEFAULT;
}
// Extract the raw text of a `## {heading}` section body (between its heading line
// and the next `## ` heading, or end of document). Returns null if absent.
@ -115,6 +135,21 @@ export function validateBriefContent(text, opts = {}) {
));
}
if ('ceremony' in fm && !BRIEF_CEREMONY_VALUES.includes(fm.ceremony)) {
errors.push(issue(
'BRIEF_INVALID_CEREMONY',
`ceremony "${fm.ceremony}" not in [${BRIEF_CEREMONY_VALUES.join(', ')}]`,
'ceremony is the brief\'s size class; leave it out to mean architectural (the heaviest).',
));
}
if ('artifact_store' in fm && !BRIEF_ARTIFACT_STORE_VALUES.includes(fm.artifact_store)) {
errors.push(issue(
'BRIEF_INVALID_ARTIFACT_STORE',
`artifact_store "${fm.artifact_store}" not in [${BRIEF_ARTIFACT_STORE_VALUES.join(', ')}]`,
'git = the brief and plan are committed; local = kept out of git. Leave it out to mean local.',
));
}
if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') {
const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/);
if (vm) {
@ -246,7 +281,15 @@ export function validateBrief(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
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 strict = !args.includes('--soft');
const minIdx = args.indexOf('--min-version');

View file

@ -39,8 +39,9 @@
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue } from '../util/result.mjs';
import { emit } from '../stats/event-emit.mjs';
@ -233,7 +234,18 @@ export function resolveApproveTarget(arg) {
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
}
if (import.meta.url === `file://${process.argv[1]}`) {
// Main-module guard on real paths: comparing against `file://` + argv[1] fails
// when the plugin sits behind a symlink or in a path with a space; the CLI then
// exits 0 having checked nothing.
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 file = args.find((a) => !a.startsWith('--'));
if (args.includes('--resolve')) {