voyage/tests/validators/intent-approval.test.mjs
Kjell Tore Guttormsen f39e7ccf39
test(intent): /trekplan must stop without an approved intent — RED
Veikart steg 1, økt 1 av 2. 18 tests, all failing today:
(a) a brief without an approval marker → the gate line in
commands/trekplan.md exits non-zero (today: no gate, planning proceeds);
(b) marker + ## Intent or ## Goal edited after approval → stale, stops;
(c) a valid marker passes (known negative), and edits outside the two
sections or whitespace-only reflow keep it valid;
(d) the stamp runs before the Phase 5 manual/auto fork and emits
brief-approved, so the manual (default) path records it too.

The gate and stamp lines are extracted from the command prose and
executed, so removing the line or stubbing the module fells the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 07:47:17 +02:00

308 lines
14 KiB
JavaScript

// 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 <project-dir>/);
assert.match(read('docs/command-modes.md'), /`--approve <project-dir>`/);
});