// tests/validators/intent-approval.test.mjs // Veikart steg 1 — «Intensjonen først»: /trekplan stops without an approved intent. // // The four order cases, each bound to the COMMAND PROSE rather than to a module // name alone: the gate line is extracted from commands/trekplan.md and the stamp // line from commands/trekbrief.md, and those exact lines are executed. Remove the // line from the prose, or stub the module, and these tests fall. // (a) brief without a marker → /trekplan's check exits non-zero // (b) marker + intent text edited after → exits non-zero (stale) // (c) valid marker → exits 0 (known negative) // (d) the stamp runs before the manual/auto fork and emits brief-approved // // The module is imported dynamically so a missing module fails each test on its // own instead of crashing the file into a single failure. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const MODULE = join(ROOT, 'lib', 'validators', 'intent-approval.mjs'); const load = () => import(MODULE); const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); // A generic brief — no real organisation, no real corpus. const BRIEF = `--- type: trekbrief brief_version: "2.2" created: 2026-09-23 task: "Add a dark-mode toggle to a recipe app" slug: dark-mode-toggle project_dir: .claude/projects/2026-09-23-dark-mode-toggle/ research_topics: 0 research_status: complete auto_research: false interview_turns: 4 source: interview framing: new-direction phase_signals_partial: true --- # Task: Dark-mode toggle ## TL;DR Readers cook at night; the white page glares. ## Intent Readers cook in dim kitchens and the bright page glares. They want the app to follow the system theme. ## Goal A toggle in settings, defaulting to the system theme, remembered across restarts. ## Success Criteria - Toggle persists across restarts. `; function tmpDir() { return mkdtempSync(join(tmpdir(), 'intent-approval-')); } /** The single bash line in `rel` that invokes intent-approval.mjs with `verb`. */ function commandLine(rel, verb) { const lines = read(rel).split('\n').filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/intent-approval\.mjs\b/.test(l) && l.includes(verb)); assert.equal(lines.length, 1, `${rel} must carry exactly one \`intent-approval.mjs ${verb}\` line; found ${lines.length}`); return lines[0].trim(); } function run(line, subs, env = {}) { let cmd = line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT); for (const [k, v] of Object.entries(subs)) cmd = cmd.split(k).join(v); return spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: { ...process.env, ...env } }); } function gate(briefPath) { return run(commandLine('commands/trekplan.md', '--check'), { '{brief_path}': briefPath }); } function stampInto(dir, env) { return run(commandLine('commands/trekbrief.md', '--stamp'), { '{PROJECT_DIR}': dir }, env); } function codes(res) { const out = JSON.parse(res.stdout); return out.errors.map((e) => e.code); } // ---- (a) (b) (c): the /trekplan gate, executed as the command prose spells it ---- test('(a) /trekplan gate: brief WITHOUT an approval marker stops (exit != 0, BRIEF_INTENT_NOT_APPROVED)', () => { const dir = tmpDir(); try { const p = join(dir, 'brief.md'); writeFileSync(p, BRIEF); const r = gate(p); assert.notEqual(r.status, 0, `gate must stop on an unapproved brief; stdout=${r.stdout} stderr=${r.stderr}`); assert.ok(codes(r).includes('BRIEF_INTENT_NOT_APPROVED'), r.stdout); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('(b) /trekplan gate: marker present but ## Intent edited after approval stops (BRIEF_INTENT_APPROVAL_STALE)', async () => { const { stampIntentApproval } = await load(); const dir = tmpDir(); try { const p = join(dir, 'brief.md'); const stamped = stampIntentApproval(BRIEF, new Date('2026-09-23T08:00:00Z')).text; writeFileSync(p, stamped.replace('follow the system theme', 'always be dark')); const r = gate(p); assert.notEqual(r.status, 0, `gate must stop on a stale approval; stdout=${r.stdout}`); assert.ok(codes(r).includes('BRIEF_INTENT_APPROVAL_STALE'), r.stdout); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('(b) /trekplan gate: editing ## Goal after approval also stops', async () => { const { stampIntentApproval } = await load(); const dir = tmpDir(); try { const p = join(dir, 'brief.md'); const stamped = stampIntentApproval(BRIEF, new Date('2026-09-23T08:00:00Z')).text; writeFileSync(p, stamped.replace('remembered across restarts', 'reset on every launch')); const r = gate(p); assert.notEqual(r.status, 0); assert.ok(codes(r).includes('BRIEF_INTENT_APPROVAL_STALE'), r.stdout); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('(c) /trekplan gate: a valid marker passes (exit 0) — known negative', async () => { const { stampIntentApproval } = await load(); const dir = tmpDir(); try { const p = join(dir, 'brief.md'); writeFileSync(p, stampIntentApproval(BRIEF, new Date('2026-09-23T08:00:00Z')).text); const r = gate(p); assert.equal(r.status, 0, `valid approval must pass; stdout=${r.stdout} stderr=${r.stderr}`); assert.deepEqual(codes(r), []); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('(c) editing text OUTSIDE ## Intent / ## Goal keeps the approval valid', async () => { const { stampIntentApproval, checkIntentApprovalContent } = await load(); const stamped = stampIntentApproval(BRIEF, new Date('2026-09-23T08:00:00Z')).text; const edited = stamped.replace('- Toggle persists across restarts.', '- Toggle persists across restarts.\n- Contrast passes AA.'); assert.deepEqual(checkIntentApprovalContent(edited).errors, []); }); test('whitespace-only reflow of ## Intent keeps the approval valid (normalized hash)', async () => { const { stampIntentApproval, checkIntentApprovalContent } = await load(); const stamped = stampIntentApproval(BRIEF, new Date('2026-09-23T08:00:00Z')).text; const reflowed = stamped.replace( 'Readers cook in dim kitchens and the bright page glares.\nThey want', 'Readers cook in dim kitchens and the bright page glares.\n\n They want'); assert.notEqual(reflowed, stamped); assert.deepEqual(checkIntentApprovalContent(reflowed).errors, []); }); test('a malformed marker is BRIEF_INTENT_APPROVAL_INVALID, not a pass', async () => { const { checkIntentApprovalContent } = await load(); const bad = BRIEF.replace('source: interview\n', 'source: interview\nintent_approved_hash: "yes"\n'); const codesOut = checkIntentApprovalContent(bad).errors.map((e) => e.code); assert.deepEqual(codesOut, ['BRIEF_INTENT_APPROVAL_INVALID']); }); test('a brief missing ## Goal cannot be approved: stamp refuses, check is INVALID', async () => { const { stampIntentApproval, checkIntentApprovalContent } = await load(); const noGoal = BRIEF.replace(/## Goal\n\n[\s\S]*?\n\n## Success/, '## Success'); assert.equal(stampIntentApproval(noGoal).stamped, false); const forged = noGoal.replace('source: interview\n', `source: interview\nintent_approved_hash: "sha256:${'0'.repeat(64)}"\n`); assert.deepEqual(checkIntentApprovalContent(forged).errors.map((e) => e.code), ['BRIEF_INTENT_APPROVAL_INVALID']); }); test('trekreview briefs are exempt from the gate (they carry no ## Intent of their own)', async () => { const { checkIntentApprovalContent } = await load(); const review = `--- type: trekreview task: "Review delivered toggle" slug: dark-mode-toggle project_dir: .claude/projects/2026-09-23-dark-mode-toggle/ findings: - 0123456789abcdef0123456789abcdef01234567 --- # Review brief `; assert.deepEqual(checkIntentApprovalContent(review).errors, []); }); test('the gate message tells the operator what to do (/trekbrief --approve)', () => { const dir = tmpDir(); try { const p = join(dir, 'brief.md'); writeFileSync(p, BRIEF); const out = JSON.parse(gate(p).stdout); const e = out.errors.find((x) => x.code === 'BRIEF_INTENT_NOT_APPROVED'); assert.ok(e && /\/trekbrief --approve/.test(`${e.message} ${e.hint || ''}`), JSON.stringify(e)); } finally { rmSync(dir, { recursive: true, force: true }); } }); // ---- /trekplan prose: the gate halts, on both brief modes, before any planning ---- test('trekplan prose: the intent-approval gate sits in "Read the brief" and says HALT', () => { const text = read('commands/trekplan.md'); const readIdx = text.indexOf('### Read the brief'); const gateIdx = text.indexOf(commandLine('commands/trekplan.md', '--check')); const phase2 = text.indexOf('\n## Phase 2'); assert.ok(readIdx >= 0 && gateIdx > readIdx, 'gate must sit under ### Read the brief (runs for --brief AND --project)'); assert.ok(phase2 < 0 || gateIdx < phase2, 'gate must run before Phase 2'); const section = text.slice(gateIdx, gateIdx + 2500); assert.match(section, /BRIEF_INTENT_NOT_APPROVED/); assert.match(section, /BRIEF_INTENT_APPROVAL_STALE/); assert.match(section, /\*\*halt\*\*/i); }); // ---- (d) the stamp: operator answer → marker + brief-approved, on BOTH paths ---- test('(d) trekbrief: the stamp step runs BEFORE the Phase 5 manual/auto fork', () => { const text = read('commands/trekbrief.md'); const stampIdx = text.indexOf(commandLine('commands/trekbrief.md', '--stamp')); const phase5 = text.indexOf('## Phase 5'); const manual = text.indexOf('### Manual path'); assert.ok(phase5 > 0 && manual > 0, 'Phase 5 / Manual path headings must exist'); assert.ok(stampIdx > 0 && stampIdx < phase5 && stampIdx < manual, 'the stamp must run before the fork, so the manual path passes through it'); // The approval is the operator's explicit answer, asked even in --quick. const before = text.slice(text.lastIndexOf('\n## Phase 4h', stampIdx), stampIdx); assert.match(before, /AskUserQuestion/); assert.match(before, /--quick/); }); test('(d) trekbrief: the auto path no longer emits brief-approved on its own (one emission, not two)', () => { const text = read('commands/trekbrief.md'); const auto = text.indexOf('### Auto path'); const phase6 = text.indexOf('## Phase 6'); assert.ok(auto > 0 && phase6 > auto); assert.ok(!/--event brief-approved/.test(text.slice(auto, phase6)), 'brief-approved is emitted by the stamp; a second emission on the auto path would double-count'); }); test('(d) the manual-path stamp line writes the marker AND emits a brief-approved record', () => { const dir = tmpDir(); const data = tmpDir(); try { writeFileSync(join(dir, 'brief.md'), BRIEF); const r = stampInto(dir, { CLAUDE_PLUGIN_DATA: data }); assert.equal(r.status, 0, `stamp failed: ${r.stdout} ${r.stderr}`); const after = readFileSync(join(dir, 'brief.md'), 'utf8'); assert.match(after, /^intent_approved_hash: "sha256:[0-9a-f]{64}"$/m); assert.match(after, /^intent_approved_at: "\d{4}-\d\d-\d\dT[^"]+"$/m); const statsFile = join(data, 'trekexecute-stats.jsonl'); assert.ok(existsSync(statsFile), 'brief-approved record must be written'); const recs = readFileSync(statsFile, 'utf8').trim().split('\n').map((l) => JSON.parse(l)); const ev = recs.filter((x) => x.event === 'brief-approved'); assert.equal(ev.length, 1); assert.equal(ev[0].payload.slug, 'dark-mode-toggle'); assert.match(ev[0].payload.intent_hash, /^sha256:[0-9a-f]{64}$/); // and the gate now passes on the stamped file assert.equal(gate(join(dir, 'brief.md')).status, 0); } finally { rmSync(dir, { recursive: true, force: true }); rmSync(data, { recursive: true, force: true }); } }); test('(d) with CLAUDE_PLUGIN_DATA unset the record still lands (fallback = the dir the yardstick reads)', async () => { const { resolveApprovalDataDir } = await load(); const d = resolveApprovalDataDir({ HOME: '/home/someone' }); assert.equal(d, '/home/someone/.claude/plugins/data/voyage-ktg-plugin-marketplace'); assert.equal(resolveApprovalDataDir({ CLAUDE_PLUGIN_DATA: '/x/y', HOME: '/h' }), '/x/y'); }); test('re-stamping replaces the marker instead of duplicating it', async () => { const { stampIntentApproval } = await load(); const once = stampIntentApproval(BRIEF, new Date('2026-09-23T08:00:00Z')).text; const twice = stampIntentApproval(once, new Date('2026-09-24T08:00:00Z')).text; assert.equal((twice.match(/^intent_approved_hash:/gm) || []).length, 1); assert.equal((twice.match(/^intent_approved_at:/gm) || []).length, 1); assert.match(twice, /2026-09-24T08:00:00/); }); // ---- honesty: the code says what the marker does NOT prove ---- test('the module states what the marker does NOT prove (trace, not a signature)', () => { assert.ok(existsSync(MODULE), 'intent-approval.mjs missing'); const src = readFileSync(MODULE, 'utf8'); assert.match(src, /NOT a signature/); assert.match(src, /same user, same machine/i); }); // ---- /trekbrief --approve: the stop message's remedy exists ---- test('/trekbrief --approve is a parsed flag and a documented mode', async () => { const { parseArgs } = await import(join(ROOT, 'lib', 'parsers', 'arg-parser.mjs')); const r = parseArgs('--approve .claude/projects/2026-09-23-dark-mode-toggle', 'trekbrief'); assert.equal(r.flags['--approve'], '.claude/projects/2026-09-23-dark-mode-toggle'); assert.deepEqual(r.unknown, []); assert.deepEqual(r.errors, []); assert.match(read('commands/trekbrief.md'), /--approve /); assert.match(read('docs/command-modes.md'), /`--approve `/); });