M3: the header claimed "reflowing a paragraph keeps the approval"; joining
lines gives STALE (PM probe iii-c). It now says what keeps the approval
(indentation, in-line spacing, blank lines, CRLF) and that joining or
splitting lines makes it stale — erring safe. Also states the one-section
and code-block/comment rules from M1.
M7: the trekreview exemption is read from the brief's own frontmatter, so a
relabelled brief walks past the gate — the same trust class as self-stamping.
Now said in the module header, HANDOVER-CONTRACTS and /trekplan's gate prose.
HANDOVER-CONTRACTS also names both brief modes the --approve path reaches.
Red 097bae8 (M3, M7) → green. Suite 1224: 1222 pass / 0 fail / 2 skip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
// 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,
|
||
* 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);
|
||
}
|