voyage/tests/lib/intent-prose-pins.test.mjs
Kjell Tore Guttormsen f6e18a1d09
feat(skills): interviewing - one source for the brief's question round (order 7e)
/trekbrief Phase 3 carried the interview form inline. It now asks for the
new skill voyage:interviewing by name (allowed-tools gains Skill) and keeps
only its own contract: the size class and budget, the three fixed questions
(framing first, class, storage), where each answer lands in the brief, and
the answers that decide nothing. Step 3a and the form points of Step 3c move
into the skill with their credits (Pocock, Osmani, Spec Kit; licences and
line ranges checked at the pinned commits).

Red baseline first (3 helper interviews against a fixed answer sheet,
Opus 5.5): 10-11 questions in round 1 (3 of 3), 15 of 31 without a
recommendation, a hollow answer written in as a decision (2 of 3), unasked
scope as a requirement (2 of 3). With the skill: 4, 0 of 12, 0 of 3, 0 of 3.
Fact questions and stopping did not fail without it.

Also: pointers to voyage:writing-criteria (S3) where criteria are asked and
written, both review skill names (S3 + S4) in the brief-reviewer prompt; the
intent pins that read moved text now read the skill, same assertions; the
Phase 7 line_ref moves with the shorter Phase 3; 7 eval cases (3/2/2).

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

196 lines
9.8 KiB
JavaScript
Raw Permalink 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)');
});
// The interview form moved into the skill /trekbrief asks for by name (order
// 7e, one source): the same assertions, now read where the text lives.
const interviewing = () => read('skills/interviewing/SKILL.md');
test('trekbrief: the round — all answerable questions at once, recommendation first, no fact questions', () => {
const p3 = section(brief, 'Phase 3');
const iv = interviewing();
assert.match(p3, /Use `AskUserQuestion` for every question/);
assert.match(p3, /`voyage:interviewing`/, 'Phase 3 asks for the interview form by name');
assert.doesNotMatch(brief, /Ask one question at a time/, 'the one-at-a-time rule is replaced by the round');
assert.doesNotMatch(iv, /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(iv, /one round/i);
assert.match(iv, /recommend(ed|ation)[^.\n]*first/i);
assert.match(iv, /never ask[^.\n]*fact/i);
assert.match(iv, /Impact × Uncertainty/);
assert.match(iv, /[Dd]eferred/);
assert.match(iv, /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', () => {
// The classes stay in /trekbrief with their credits; the round's credits
// moved with the round into the skill.
const p3 = section(brief, 'Phase 3');
for (const src of ['obra/superpowers@8ca22db', 'kiro.dev']) {
assert.ok(p3.includes(src), `credit for ${src}`);
}
const iv = interviewing();
for (const re of [/mattpocock\/skills ?@c55ee46/, /GitHub Spec Kit/, /addyosmani\/agent-skills ?@9d0c60d/]) {
assert.match(iv, re, `credit for ${re}`);
}
});
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/);
});