New lib/stats/stats-append.mjs: one record on stdin, appended to
<data dir>/<kind>-stats.jsonl. The data dir is CLAUDE_PLUGIN_DATA, else
~/.claude/plugins/data/voyage-ktg-plugin-marketplace (the yardstick's
default). A failed write exits 1 with a reason: reported, never silent.
/trekbrief Phase 7 and /trekplan Phase 12 now run it instead of
"append to ${CLAUDE_PLUGIN_DATA}/…; skip silently". intent-approval's
resolveApprovalDataDir delegates to the same resolver.
Chose a stdin heredoc over a --json argument because a JSON record in a
shell argument breaks on quotes in task text. Only trekbrief and trekplan
are rewired (økt 2's countable form needs exactly those two); the other
commands' stats prose is unchanged and docs/architecture.md says so.
Red a358059 7/7 → green 7/7. Suite 1208: 1206 pass / 0 fail / 2 skip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.6 KiB
JavaScript
207 lines
8.6 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. 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
|
||
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, readFileSync, renameSync, 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> — 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. One resolver for every stats writer: lib/stats/stats-append.mjs.
|
||
*/
|
||
export function resolveApprovalDataDir(env = process.env) {
|
||
return resolveStatsDir(env);
|
||
}
|
||
|
||
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] <brief.md> | --stamp <brief.md>\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);
|
||
}
|