// 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 import { createHash } from 'node:crypto'; import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; 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'; 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".'; function extractSection(body, heading) { const re = new RegExp(`^##\\s+${heading}\\b.*$`, 'm'); const m = re.exec(body); if (!m) return null; const after = body.slice(m.index + m[0].length); const next = after.search(/^##\s/m); return next === -1 ? after : after.slice(0, next); } 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[] }} hash is null when a section is absent or empty. */ export function computeIntentHash(body) { const parts = []; const missing = []; for (const h of INTENT_SECTIONS) { const s = extractSection(body || '', h); const n = s === null ? '' : normalize(s); if (!n) missing.push(h); parts.push(n); } if (missing.length) return { hash: null, missing }; const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex'); return { hash: `sha256:${hex}`, missing }; } /** 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 { hash, missing } = computeIntentHash(body); if (!hash) { errors.push(issue( 'BRIEF_INTENT_APPROVAL_INVALID', `The approval cannot bind: ## ${missing.join(' and ## ')} is missing or empty.`, 'Write the missing section(s), 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 { hash, missing } = computeIntentHash(doc.parsed.body || ''); if (!hash) return { stamped: false, text, reason: `## ${missing.join(' and ## ')} missing or empty` }; 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. */ export function resolveApprovalDataDir(env = process.env) { if (env.CLAUDE_PLUGIN_DATA) return env.CLAUDE_PLUGIN_DATA; const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir(); return join(home, '.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace'); } if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); const file = args.find((a) => !a.startsWith('--')); const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null; if (!file || !mode) { process.stderr.write('Usage: intent-approval.mjs --check [--json] | --stamp \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); }