voyage/lib/validators/intent-approval.mjs
Kjell Tore Guttormsen 5244d2e8e8
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>
2026-10-06 19:44:42 +02:00

300 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/validators/intent-approval.mjs
// Intent approval marker — the gate /trekplan passes before it plans anything.
//
// A brief carries two optional frontmatter fields once the operator has
// approved its intent in /trekbrief (Phase 4h, or `/trekbrief --approve`):
//
// intent_approved_hash: "sha256:<64 hex>" hash over ## Intent + ## Goal
// intent_approved_at: "<ISO-8601>" when the stamp was written
//
// The hash binds the approval to the EXACT intent text: each section is
// normalized (lines trimmed, whitespace runs collapsed, blank lines dropped)
// and the pair is hashed. Indentation, spacing inside a line, blank lines and
// CRLF keep the approval. Line breaks are part of the text:
// joining or splitting lines makes the approval stale, as does changing a
// word. That errs safe: /trekplan stops until the operator approves the new text.
// Each section must appear exactly once; a `## ` line inside a fenced code
// block or an HTML comment is text, not a heading.
//
// WHAT THE MARKER DOES NOT PROVE. Same user, same machine: any session with
// write access to the brief can run `--stamp` itself, or write the two fields
// by hand. The marker is a TRACE that the approval step ran against this text,
// NOT a signature binding a person — the same limit as the order queue's
// `--from`. It catches drift (intent edited after approval) and omission (no
// approval step at all); it does not catch a session that approves on the
// operator's behalf.
//
// trekreview briefs are exempt: they are produced from a review of an already
// planned brief and carry no ## Intent of their own. The exemption is read
// from the brief's own frontmatter, so relabelling a brief `type: trekreview`
// walks past the gate. That is the same trust class as self-stamping: a
// session that can edit the brief can do either. Neither is caught here.
//
// CLI:
// node lib/validators/intent-approval.mjs --check [--json] <brief.md>
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
// node lib/validators/intent-approval.mjs --stamp <brief.md>
// writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused
// node lib/validators/intent-approval.mjs --resolve <project-dir | brief-file>
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
import { createHash } from 'node:crypto';
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';
import { resolveStatsDir } from '../stats/stats-append.mjs';
export const INTENT_HASH_FIELD = 'intent_approved_hash';
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
const STATS_FILENAME = 'trekexecute-stats.jsonl'; // where event-emit writes lifecycle events
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal and answers "Approve".';
/**
* Line indexes of the `## ` headings that are real headings: a `## ` line
* inside a fenced code block or an HTML comment is text, not a heading. Seeing
* it as one let an example `## Intent` capture the hash, or cut the real
* section short (M1).
*/
function headingLines(lines) {
const out = [];
let fence = null; // { ch, len } of the open fence
let inComment = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (fence) {
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
if (close && close[1][0] === fence.ch && close[1].length >= fence.len) fence = null;
continue;
}
if (inComment) {
if (line.includes('-->')) inComment = false;
continue;
}
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (open) { fence = { ch: open[1][0], len: open[1].length }; continue; }
if (/^##\s/.test(line)) out.push(i);
const c = line.lastIndexOf('<!--');
if (c >= 0 && line.indexOf('-->', c + 4) < 0) inComment = true;
}
return out;
}
/** Every `## <heading>` section's text (one entry per occurrence). */
function extractSections(lines, heads, heading) {
const re = new RegExp(`^##\\s+${heading}\\b`);
return heads.filter((i) => re.test(lines[i])).map((i) => {
const next = heads.find((j) => j > i);
return lines.slice(i + 1, next === undefined ? lines.length : next).join('\n');
});
}
function normalize(text) {
return text.split(/\r?\n/)
.map((l) => l.trim().replace(/\s+/g, ' '))
.filter(Boolean)
.join('\n');
}
/**
* Hash ## Intent + ## Goal of a brief body.
* @returns {{ hash: string|null, missing: string[], duplicate: string[] }} hash is null when a
* section is absent or empty, or appears more than once (which one would the approval cover?).
*/
export function computeIntentHash(body) {
const lines = (body || '').split('\n');
const heads = headingLines(lines);
const parts = [];
const missing = [];
const duplicate = [];
for (const h of INTENT_SECTIONS) {
const found = extractSections(lines, heads, h);
if (found.length > 1) duplicate.push(h);
const n = found.length === 1 ? normalize(found[0]) : '';
if (!n && found.length <= 1) missing.push(h);
parts.push(n);
}
if (missing.length || duplicate.length) return { hash: null, missing, duplicate };
const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex');
return { hash: `sha256:${hex}`, missing, duplicate };
}
/** Why the approval cannot bind, in words. */
function unbindable({ missing, duplicate }) {
const why = [];
if (duplicate.length) why.push(`## ${duplicate.join(' and ## ')} appears more than once`);
if (missing.length) why.push(`## ${missing.join(' and ## ')} is missing or empty`);
return why.join('; ');
}
/** Check the approval marker of a brief's text. Returns a validator Result. */
export function checkIntentApprovalContent(text) {
const doc = parseDocument(text);
if (!doc.valid) return doc;
const fm = doc.parsed.frontmatter || {};
const body = doc.parsed.body || '';
const errors = [];
const result = () => ({ valid: errors.length === 0, errors, warnings: [], parsed: { frontmatter: fm } });
if (fm.type === 'trekreview') return result();
if (!(INTENT_HASH_FIELD in fm)) {
errors.push(issue(
'BRIEF_INTENT_NOT_APPROVED',
'The brief\'s intent has not been approved — /trekplan does not plan against an unapproved intent.',
APPROVE_HINT,
));
return result();
}
const recorded = String(fm[INTENT_HASH_FIELD]);
if (!HASH_RE.test(recorded)) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`${INTENT_HASH_FIELD} "${recorded}" is not "sha256:<64 hex>" — the marker is malformed.`,
APPROVE_HINT,
));
return result();
}
const bound = computeIntentHash(body);
const { hash } = bound;
if (!hash) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`The approval cannot bind: ${unbindable(bound)}.`,
'Leave exactly one non-empty ## Intent and one ## Goal, then ' + APPROVE_HINT,
));
return result();
}
if (hash !== recorded) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_STALE',
'## Intent or ## Goal changed after the intent was approved — the approval covers the old text, not this one.',
APPROVE_HINT,
));
}
return result();
}
/**
* Write the approval marker into a brief's frontmatter (replacing any prior one).
* @returns {{ stamped: boolean, text: string, hash?: string, approvedAt?: string, reason?: string }}
*/
export function stampIntentApproval(text, now = new Date()) {
const doc = parseDocument(text);
if (!doc.valid) return { stamped: false, text, reason: 'frontmatter does not parse' };
const bound = computeIntentHash(doc.parsed.body || '');
const { hash } = bound;
if (!hash) return { stamped: false, text, reason: unbindable(bound) };
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text);
if (!m) return { stamped: false, text, reason: 'no frontmatter block' };
const approvedAt = now.toISOString();
const kept = m[2].split(/\r?\n/).filter((l) =>
!l.startsWith(`${INTENT_HASH_FIELD}:`) && !l.startsWith(`${INTENT_APPROVED_AT_FIELD}:`));
kept.push(`${INTENT_HASH_FIELD}: "${hash}"`, `${INTENT_APPROVED_AT_FIELD}: "${approvedAt}"`);
const out = m[1] + kept.join('\n') + m[3] + text.slice(m.index + m[0].length);
return { stamped: true, text: out, hash, approvedAt };
}
/**
* Where the brief-approved record goes. CLAUDE_PLUGIN_DATA when the harness
* provides it; otherwise the plugin data dir the hooks write to and
* scripts/yardstick.mjs reads — the Bash tool env carries no CLAUDE_PLUGIN_DATA.
* event-emit's fallback used to be a silent skip, which is how brief-approved
* reached 0 records; it now resolves the same directory. One resolver for every
* stats writer: lib/stats/stats-append.mjs.
*/
export function resolveApprovalDataDir(env = process.env) {
return resolveStatsDir(env);
}
/**
* What `/trekbrief --approve <arg>` approves. A directory means `<dir>/brief.md`
* (a /trekplan --project brief); a file is itself (a /trekplan --brief brief,
* whatever its name). Before M2 only `<dir>/brief.md` was reachable.
* @returns {{ brief_path: string, project_dir: string, plan_command: string } | { error: string }}
*/
export function resolveApproveTarget(arg) {
const target = String(arg || '').replace(/\/+$/, '');
if (!target) return { error: 'no brief given — /trekbrief --approve <project-dir | brief-file>' };
let isDir = false;
try { isDir = statSync(target).isDirectory(); } catch { /* absent */ }
const briefPath = isDir ? join(target, 'brief.md') : target;
if (!existsSync(briefPath) || statSync(briefPath).isDirectory()) {
return { error: `no brief at ${briefPath} — run /trekbrief first` };
}
const projectDir = dirname(briefPath);
const planCommand = basename(briefPath) === 'brief.md'
? `/trekplan --project ${projectDir}`
: `/trekplan --brief ${briefPath}`;
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
}
// 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')) {
const r = resolveApproveTarget(file);
process.stdout.write(JSON.stringify(r) + '\n');
process.exit(r.error ? 1 : 0);
}
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
if (!file || !mode) {
process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md> | --resolve <project-dir | brief-file>\n');
process.exit(2);
}
let text = null;
if (existsSync(file)) {
try { text = readFileSync(file, 'utf8'); } catch { text = null; }
}
if (mode === 'check') {
const r = text === null
? { valid: false, errors: [issue('BRIEF_NOT_FOUND', `Cannot read brief: ${file}`)], warnings: [] }
: checkIntentApprovalContent(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`intent-approval: ${r.valid ? 'APPROVED' : 'STOP'} ${file}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n ${e.hint || ''}\n`);
}
process.exit(r.valid ? 0 : 1);
}
// --stamp
if (text === null) {
process.stdout.write(JSON.stringify({ stamped: false, reason: `cannot read ${file}` }) + '\n');
process.exit(1);
}
const s = stampIntentApproval(text);
if (!s.stamped) {
process.stdout.write(JSON.stringify({ stamped: false, reason: s.reason }) + '\n');
process.exit(1);
}
const tmp = join(dirname(file), `.${basename(file)}.approve.tmp`);
writeFileSync(tmp, s.text);
renameSync(tmp, file);
const fm = parseDocument(s.text).parsed.frontmatter || {};
const record = emit('brief-approved',
{ project: dirname(file), slug: fm.slug ?? null, intent_hash: s.hash },
{ path: join(resolveApprovalDataDir(), STATS_FILENAME) });
process.stdout.write(JSON.stringify({
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt, record,
}) + '\n');
process.exit(0);
}