/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>
196 lines
9.8 KiB
JavaScript
196 lines
9.8 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)');
|
||
});
|
||
|
||
// 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/);
|
||
});
|