voyage/tests/lib/intent-prose-pins.test.mjs
Kjell Tore Guttormsen 5244d2e8e8
feat(brief): ceremony by size, one question round, logged and committed approvals
P1 (order 2, wave 1). /trekbrief puts the task in a size class
(ceremony: small|bounded|architectural, K1; heaviest in doubt, said
aloud, overridable, only moved up) that sets the question budget and the
review cap, and asks one round of questions: facts looked up, never
asked; recommendation first; ranked by impact x uncertainty with a
visible Deferred list; stop when the next three answers can be predicted.
"høres bra ut", "gjør som du vil" and an empty answer are not approval.

K2: lib/util/approval-log.mjs, an append-only hash-chained log in the
target repo (.claude/voyage/approvals.jsonl) bound to the approved
content's sha256; append/verify (0/1/2), answer, digest. Committed by
lib/util/artifact-commit.mjs: the log always, the brief and plan only
with artifact_store: git (operator decision 2026-10-06). /trekplan
Phase 1 halts on a broken, diverged, missing or mismatched log entry;
Phase 11 logs and commits an explicit plan approval before any handoff.

Both new brief fields are optional: no brief_version bump. The
main-module guard in intent-approval.mjs and brief-validator.mjs now
compares real paths. Borrowings credited in the prose and module headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:44:42 +02:00

182 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/lib/intent-prose-pins.test.mjs
// Order 2 (P1 — intent as a stored artifact): the prose pins for /trekbrief's
// ceremony by size, the question round, answers that are not approval, the
// approval log and the commit; and /trekplan's Phase 1 log gate and Phase 11
// plan approval. The command lines are EXTRACTED from the prose and executed,
// so removing a line, or pointing it at the wrong module, fails here.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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 read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
/** Text from `## <heading>` up to the next `## ` heading. */
function section(text, heading) {
const start = text.indexOf(`\n## ${heading}`);
assert.ok(start >= 0, `missing ## ${heading}`);
const next = text.indexOf('\n## ', start + 4);
return text.slice(start, next < 0 ? undefined : next);
}
/** The single line in `text` that runs `lib/<mod>` with `verb`. */
function line(text, mod, verb) {
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
assert.equal(hits.length, 1, `expected exactly one \`${mod} ${verb}\` line; found ${hits.length}`);
return hits[0].trim();
}
function bash(cmd, cwd) {
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
}
const brief = read('commands/trekbrief.md');
const plan = read('commands/trekplan.md');
// ---- /trekbrief: ceremony by size ----
test('trekbrief: three classes, heaviest in doubt, said aloud, overridable, only upward mid-task', () => {
const p3 = section(brief, 'Phase 3');
for (const c of ['small', 'bounded', 'architectural']) assert.ok(p3.includes(`\`${c}\``), `class ${c}`);
assert.match(p3, /heaviest/i);
assert.match(p3, /aloud/i);
assert.match(p3, /override/i);
assert.match(p3, /only (ever )?(moves? )?up/i);
assert.match(p3, /`ceremony:/, 'the class is written to the brief (K1)');
});
test('trekbrief: the round — all answerable questions at once, recommendation first, no fact questions', () => {
const p3 = section(brief, 'Phase 3');
assert.match(p3, /Use `AskUserQuestion` for every question/);
assert.doesNotMatch(brief, /Ask one question at a time/, 'the one-at-a-time rule is replaced by the round');
assert.match(p3, /one round/i);
assert.match(p3, /recommend(ed|ation)[^.\n]*first/i);
assert.match(p3, /never ask[^.\n]*fact/i);
assert.match(p3, /Impact × Uncertainty/);
assert.match(p3, /[Dd]eferred/);
assert.match(p3, /next three answers/);
});
test('trekbrief: the budget for a small task is at most 5 questions and fits one AskUserQuestion call', () => {
const p3 = section(brief, 'Phase 3');
const m = p3.match(/`small`[^\n|]*\|\s*(\d+)/);
assert.ok(m, 'the budget table must give a number for `small`');
assert.ok(Number(m[1]) <= 5 && Number(m[1]) <= 4, `small budget ${m[1]} must be ≤ 5 and fit one call (max 4 questions)`);
assert.match(p3, /framing/i, 'framing is one of the round\'s questions');
assert.match(p3, /artifact_store/, 'the storage choice is one of the round\'s questions');
});
test('trekbrief: credits every borrowing of the round and the classes', () => {
const p3 = section(brief, 'Phase 3');
for (const src of ['obra/superpowers@8ca22db', 'mattpocock/skills@c55ee46', 'GitHub Spec Kit', 'addyosmani/agent-skills@9d0c60d', 'kiro.dev']) {
assert.ok(p3.includes(src), `credit for ${src}`);
}
});
test('trekbrief: hard rule 8 no longer says Phase 3 has no cap', () => {
assert.doesNotMatch(brief, /Phase 3 has no cap/);
});
// ---- /trekbrief Phase 4h: answers that are not approval, the log, the commit ----
test('trekbrief 4h: the answer check runs before the stamp, then append, then commit', () => {
const p4h = section(brief, 'Phase 4h');
const ans = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' answer'));
const stamp = p4h.indexOf('intent-approval.mjs --stamp');
const app = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' append'));
const com = p4h.indexOf(line(p4h, 'util/artifact-commit.mjs', '--store'));
assert.ok(ans >= 0 && ans < stamp && stamp < app && app < com, 'order: answer → stamp → append → commit');
assert.match(p4h, /høres bra ut/);
assert.match(p4h, /gjør som du vil/);
assert.match(p4h, /empty/i);
});
test('trekbrief 4h: the answer line, run as written, refuses «høres bra ut» and «gjør som du vil»', () => {
const p4h = section(brief, 'Phase 4h');
const start = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' answer'));
const block = p4h.slice(start, p4h.indexOf('```', start)).trim(); // the heredoc, up to the fence
const d = mkdtempSync(join(tmpdir(), 'intent-pins-'));
try {
for (const [answer, code] of [['høres bra ut', 1], ['gjør som du vil', 1], ['', 1], ['Approve', 0]]) {
const r = bash(block.split('{OPERATOR_ANSWER}').join(answer), d);
assert.equal(r.status, code, `«${answer}» → ${r.status} (${r.stdout}${r.stderr})`);
}
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('trekbrief 4h: append binds the intent hash; commit uses the operator\'s storage choice', () => {
const p4h = section(brief, 'Phase 4h');
const app = line(p4h, 'util/approval-log.mjs', ' append');
assert.match(app, /--kind intent/);
assert.match(app, /--subject "\{BRIEF_PATH\}"/);
assert.match(app, /--sha256 "\{INTENT_HASH\}"/);
const com = line(p4h, 'util/artifact-commit.mjs', '--store');
assert.match(com, /--store \{ARTIFACT_STORE\}/);
assert.match(com, /--artifact "\{BRIEF_PATH\}"/);
});
// ---- /trekplan Phase 1: the log gate ----
function gateLine() {
const read1 = plan.slice(plan.indexOf('### Read the brief'), plan.indexOf('## Phase 1.5'));
return line(read1, 'util/approval-log.mjs', ' verify');
}
test('trekplan Phase 1: the log gate sits after the marker check, before planning, and says halt', () => {
const readIdx = plan.indexOf('### Read the brief');
const check = plan.indexOf('intent-approval.mjs --check');
const gate = plan.indexOf(gateLine());
const p15 = plan.indexOf('## Phase 1.5');
assert.ok(readIdx < check && check < gate && gate < p15);
const after = plan.slice(gate, gate + 2000);
assert.match(after, /\*\*halt\*\*/i);
for (const s of ['broken', 'diverged', 'not-logged', 'mismatch']) assert.ok(after.includes(`\`${s}\``), s);
assert.match(gateLine(), /--kind intent --subject "\{brief_path\}" --sha256 "\{intent_approved_hash\}"/);
});
test('trekplan Phase 1: the gate line, run as written, stops (exit 1) when the brief\'s log is broken', () => {
const d = mkdtempSync(join(tmpdir(), 'intent-pins-gate-'));
try {
const H = 'd'.repeat(64);
const logCli = join(ROOT, 'lib', 'util', 'approval-log.mjs');
writeFileSync(join(d, 'brief.md'), 'x');
spawnSync(process.execPath, [logCli, 'append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H], { cwd: d });
spawnSync(process.execPath, [logCli, 'append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H], { cwd: d });
const cmd = gateLine().split('{brief_path}').join('brief.md').split('{intent_approved_hash}').join(`sha256:${H}`);
assert.equal(bash(cmd, d).status, 0, 'intact log with the brief\'s approval passes');
const p = join(d, '.claude', 'voyage', 'approvals.jsonl');
const text = readFileSync(p, 'utf8');
writeFileSync(p, text.replace('"intent"', '"intenT"'));
const r = bash(cmd, d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).status, 'broken');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('trekplan Phase 1: reads ceremony (K1, absent = architectural) and artifact_store (absent = local)', () => {
const read1 = plan.slice(plan.indexOf('### Read the brief'), plan.indexOf('### When the input is type:trekreview'));
assert.match(read1, /`ceremony`[^\n]*absent[^\n]*`architectural`/);
assert.match(read1, /`artifact_store`[^\n]*absent[^\n]*`local`/);
});
// ---- /trekplan Phase 11: plan approval before any handoff ----
test('trekplan Phase 11: plan approval (answer check → append plan → commit) comes before every handoff', () => {
const p11 = section(plan, 'Phase 11');
const ans = p11.indexOf(line(p11, 'util/approval-log.mjs', ' answer'));
const app = p11.indexOf(line(p11, 'util/approval-log.mjs', ' append'));
const com = p11.indexOf(line(p11, 'util/artifact-commit.mjs', '--store'));
const save = p11.indexOf('### "save"');
assert.ok(ans >= 0 && ans < app && app < com && com < save);
assert.match(line(p11, 'util/approval-log.mjs', ' append'), /--kind plan --subject "\{plan_path\}" --sha256-of "\{plan_path\}"/);
assert.match(line(p11, 'util/artifact-commit.mjs', '--store'), /--store \{artifact_store\} --artifact "\{plan_path\}"/);
assert.match(p11, /AskUserQuestion/);
});