voyage/lib/validators/intent-approval.mjs
Kjell Tore Guttormsen 20f32bb06b
fix(intent): M1 + M2 — the parser fails closed; --approve reaches any brief file
M1: headings inside fenced code blocks and HTML comments are text, not
headings, and ## Intent / ## Goal must each appear exactly once. A second
section, an example section in a code block or comment, or a '## ' line in
a code block inside Intent no longer carries the old approval: check gives
INVALID (duplicate) or STALE, stamp refuses a duplicate. A plain brief
hashes exactly as before (pinned), so no stamped brief goes stale.
Re-run of the PM's 29 probes: vi-a, vi-b, vii-a, vii-b now stop; the rest
unchanged. The PM's p7c probe was a no-op (its replace string is not in the
base brief); vii-c is covered by its own test, red on 66e1fa1.

M2: /trekbrief --approve takes <project-dir | brief-file>. New
intent-approval.mjs --resolve decides: a directory → <dir>/brief.md and
/trekplan --project; a file → itself and /trekplan --brief. Phase 4h and the
stamp line use {BRIEF_PATH}; /trekplan's halt table names
/trekbrief --approve {brief_path}, which works on both brief modes, and so
does the gate's hint. HANDOVER-CONTRACTS' "open to every producer's brief"
is now true. Chose fixing the path over rewording the contract because two
real briefs (docs/*-brief.md) had no approval path at all.

Red ff760ed 9/29 → green 29/29. Suite 1219: 1217 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:22:03 +02:00

281 lines
12 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. Reflowing a paragraph keeps the approval; changing
// a word in ## Intent or ## Goal after approval makes it stale, and /trekplan
// stops until the operator approves the new text.
//
// 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.
//
// 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, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
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,
* and event-emit's own fallback is a silent skip, which is how brief-approved
* reached 0 records. 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 };
}
if (import.meta.url === `file://${process.argv[1]}`) {
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);
}