// 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: "" 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] // exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage // node lib/validators/intent-approval.mjs --stamp // writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused // node lib/validators/intent-approval.mjs --resolve // 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 — 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('', c + 4) < 0) inComment = true; } return out; } /** Every `## ` 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 ` approves. A directory means `/brief.md` * (a /trekplan --project brief); a file is itself (a /trekplan --brief brief, * whatever its name). Before M2 only `/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 ' }; 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] | --stamp | --resolve \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); }