merge: wave 1 order 2 (intent) into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
21c28df3bc
16 changed files with 1527 additions and 85 deletions
332
lib/util/approval-log.mjs
Normal file
332
lib/util/approval-log.mjs
Normal 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);
|
||||
}
|
||||
114
lib/util/artifact-commit.mjs
Normal file
114
lib/util/artifact-commit.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue