voyage/lib/validators/intent-approval.mjs
Kjell Tore Guttormsen 27d586841a
fix(stats): event-emit lands without the env var and reports every failure
event-emit returned {written:false, reason:'CLAUDE_PLUGIN_DATA unset'} whenever the harness did not export the variable - the normal case for the Bash tool - so /trekexecute's Phase 8 events never landed. It now resolves the directory through stats-append's resolveStatsDir (one resolver; opts.path still wins, so intent-approval is unchanged). The CLI uses the realpath isMain() guard (the file:// guard was a silent no-op under a symlink or a path with a space) and exits 1 on every written:false - usage error, payload-not-json, failed write - instead of always 0. KNOWN_EVENTS gains main-merge-approved, main-merge-declined and gates_mode_flag_override, which /trekexecute emits by design. Header, intent-approval docstring (comment only), docs/operations.md and the README autonomy sentence (wrong state names, 'records each transition') updated.

Red: 10 of 20 tests fail on the pre-fix code - 7 new (CLI lands with CLAUDE_PLUGIN_DATA empty / absent, unwritable target exit 1, usage error exit 1, symlink, space path, known events) and 3 inverted contracts. Regression guards (green on arrival): never throws when unwritable; no second copy of the fallback directory name.

Inverted (the contract this changes, not deleted): emit with no CLAUDE_PLUGIN_DATA (silent skip) -> lands under a pinned HOME; never throws when unwritable -> a genuinely unwritable target (env {} now resolves to the real home); resolveStatsPath({}) === null -> the fallback path; malformed --payload exit 0 -> exit 1.

Readers checked: otel-export keys on 'event', yardstick counts only user_input, cache-analyzer and research-loop-cap name event-emit only in comments. Verify 268/268 (stats-event-emit, intent-approval, doc-consistency, otel-export-validators, yardstick). Suite 1376 (1374/0/2); non-hook lines in the real trekexecute-stats.jsonl 60 before and after.

Voyage order 20261005T104513Z-1250514346, plan step 2/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:35:11 +02:00

288 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. 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, 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.
* 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 };
}
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);
}