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>
182 lines
9.1 KiB
JavaScript
182 lines
9.1 KiB
JavaScript
// 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/);
|
||
});
|