voyage/tests/lib/plan-prose-pins.test.mjs
Kjell Tore Guttormsen 029e83117c
test(r8): move the live tests the move map missed
33 more tests of live behaviour from the deleted round files, each in
the test file named after its source and seen failing under a mutation
of that source (log in the project folder). Two stay dropped: one pinned
the removed gate states, one the old Phase 1.5 file comparison. The two
trekexecute.md sentences that still said /trekcontinue compares the two
prompt files now say it uses the project-folder file (line count kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:11:25 +01:00

333 lines
17 KiB
JavaScript

// tests/lib/plan-prose-pins.test.mjs
// Prose pins for the plan process (wave 1, order 3): /trekplan Phases 8-10, the
// plan and design templates, plan-critic and the Handover 4 contract must keep
// saying what the plan gate, the validator and the verification CLIs do.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
import { DESIGN_SUBSECTIONS, OPTIONAL_STEP_FIELDS } from '../../lib/validators/plan-validator.mjs';
import { findSection, findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
function phase(text, n) {
const start = text.indexOf(`\n## Phase ${n} `);
assert.ok(start >= 0, `## Phase ${n} missing`);
const end = text.indexOf('\n## Phase ', start + 1);
return text.slice(start, end < 0 ? undefined : end);
}
const PLAN_CMD = read('commands/trekplan.md');
test('Phase 9 is a bounded loop: --record after the dedup, the cap equals MAX_ROUNDS, stop and ask after it', () => {
const p9 = phase(PLAN_CMD, 9);
assert.match(p9, /plan-review-dedup\.mjs --stdin <<'JSON' \| node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/plan-gate\.mjs --record/);
assert.ok(p9.includes(`at most **${MAX_ROUNDS} critic rounds**`), `Phase 9 must state the cap of ${MAX_ROUNDS}`);
assert.match(p9, /After round 3 with a BLOCKER open/);
assert.match(p9, /never goes on silently/);
assert.match(p9, /--extra-round/);
assert.ok(p9.indexOf('plan-gate.mjs --hash') < p9.indexOf('Then launch two review agents'), 'the hash is taken before the reviewers read the plan');
assert.match(p9, /--record "\{plan_path\}" --reviewed \{the hash from --hash\}/);
assert.match(p9, /Kiro/, 'the bounded loop is borrowed and credited');
});
test('Phase 10 runs the plan gate before offering "execute", and covers plan mode', () => {
const p10 = phase(PLAN_CMD, 10);
assert.ok(p10.indexOf('plan-gate.mjs --json') < p10.indexOf('Say **"execute"**'), 'the gate runs before the offer');
assert.match(p10, /only when the gate exited 0/);
assert.match(p10, /ExitPlanMode/);
assert.match(p10, /\*\*Not\s+verified:\*\* that `\/trekplan` runs end to end inside plan mode/);
assert.match(p10, /hedged reply/i);
});
test('Phase 8 reads ceremony (absent = architectural), the design template and the arch-rules runner', () => {
const p8 = phase(PLAN_CMD, 8);
assert.match(p8, /Absent means `architectural`/);
assert.ok(p8.includes('templates/design-template.md'));
assert.ok(p8.includes('lib/verification/arch-rules.mjs --repo .'));
assert.ok(p8.includes('--prove --rule <rule name> --plant'), 'the proof names the rule the plant must trip');
assert.match(p8, /End-to-end:/);
for (const f of OPTIONAL_STEP_FIELDS) assert.ok(p8.includes(`${f}:`), `Phase 8 names the optional field ${f}:`);
for (const credit of ['obra/superpowers', 'Spec Kit', 'mattpocock/skills', 'Cherny']) {
assert.ok(p8.includes(credit), `Phase 8 credits ${credit}`);
}
});
test('the CLIs and templates /trekplan points at exist', () => {
for (const rel of [
'lib/validators/plan-gate.mjs', 'lib/verification/arch-rules.mjs',
'lib/verification/plan-scope-sweep.mjs', 'templates/design-template.md',
]) assert.ok(existsSync(join(ROOT, rel)), `${rel} missing`);
});
test('the hard rules carry the bounded critic loop and the gate', () => {
const rules = PLAN_CMD.slice(PLAN_CMD.indexOf('\n## Hard rules'));
assert.match(rules, /\*\*Bounded critic loop\*\*: at most 3 critic rounds/);
assert.match(rules, /never while `plan-gate\.mjs` exits non-zero/);
});
test('design template: every subsection the validator requires is a ### heading, and the borrowings are credited', () => {
const t = read('templates/design-template.md');
for (const s of DESIGN_SUBSECTIONS) assert.match(t, new RegExp(`^### ${s}$`, 'm'), `design template lacks ### ${s}`);
for (const credit of ['Kiro', 'obra/superpowers', 'addyosmani/agent-skills', 'mattpocock/skills', 'Spec Kit', 'ai-native-sdlc']) {
assert.ok(t.includes(credit), `design template credits ${credit}`);
}
});
test(`plan template: plan_version ${PLAN_VERSION_CURRENT} with ceremony, Test first on every example step, one End-to-end step, no hand-written Critic Verdict`, () => {
const t = read('templates/plan-template.md');
assert.ok(t.includes(`\`plan_version: ${PLAN_VERSION_CURRENT}\` · \`ceremony:`));
const steps = findSteps(t);
assert.ok(steps.length >= 2);
const bodies = steps.map((s, i) => t.slice(s.offset, i + 1 < steps.length ? steps[i + 1].offset : undefined));
for (const [i, b] of bodies.entries()) assert.match(b, /- \*\*Test first:\*\*/, `example Step ${i + 1} lacks Test first`);
assert.equal(bodies.filter(b => /- \*\*End-to-end:\*\* `/.test(b)).length, 1);
assert.doesNotMatch(t, /^## Critic Verdict$/m, 'the planner must not copy a verdict heading: only plan-gate --record writes it');
assert.match(t, /plan-gate\.mjs\s+--record` appends it/);
assert.match(t, /^## Global Constraints$/m);
assert.doesNotMatch(t, /omit "Test first"/, 'a 1.8 plan never omits Test first');
});
test('plan-critic carries dimensions 11 (interfaces and slices) and 12 (design and architecture rules)', () => {
const c = read('agents/plan-critic.md');
assert.match(c, /^### 11\. Interfaces and slices$/m);
assert.match(c, /^### 12\. Design and architecture rules$/m);
assert.match(c, /`severity` is exactly one of `blocker`, `major`, `minor`/);
});
test('Handover 4 documents every PLAN_* code the validator and the gate emit', () => {
const contract = read('docs/HANDOVER-CONTRACTS.md');
const h4 = contract.slice(contract.indexOf('## Handover 4'), contract.indexOf('## Handover 5'));
const codes = new Set();
for (const rel of ['lib/validators/plan-validator.mjs', 'lib/validators/plan-gate.mjs']) {
for (const m of read(rel).matchAll(/'(PLAN_[A-Z_]+)'/g)) codes.add(m[1]);
}
assert.ok(codes.size >= 15, `expected the 1.8 and gate codes, found ${codes.size}`);
const missing = [...codes].filter(c => !h4.includes(c));
assert.deepEqual(missing, [], `Handover 4 does not document: ${missing.join(', ')}`);
});
test('Handover 4 states 1.9 as current and 1.8 and 1.7 as still valid', () => {
const contract = read('docs/HANDOVER-CONTRACTS.md');
assert.match(contract, /\| `plan\.md` \| `plan_version` \(plan header line\) \| `1\.9` \(1\.8 and 1\.7 still valid\) \|/);
assert.match(contract, /`"1\.9"` \(current\), `"1\.8"` \(valid\), `"1\.7"` \(valid\)/);
});
test('the verification CLIs credit what they borrow and say they install nothing', () => {
const arch = read('lib/verification/arch-rules.mjs');
assert.match(arch, /installs nothing/);
assert.match(arch, /agent-skills/);
assert.match(arch, /Pocock/);
assert.match(read('lib/verification/plan-scope-sweep.mjs'), /ai-native-sdlc \(MIT\)/);
assert.match(read('lib/validators/plan-gate.mjs'), /Kiro/);
});
// ---- helpers for the pins below ----------------------------------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/**
* The text of a heading's section (level from its #-count), up to the next
* heading of the same or a higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/**
* From the one line of `rel` holding `marker` through the end of the next
* blank-line-separated paragraph after the marker's own paragraph, flattened.
*/
function paraAfter(rel, marker) {
const lines = read(rel).split('\n');
const hits = lines.filter((l) => l.includes(marker)).length;
assert.equal(hits, 1, `${rel}: expected exactly one line holding "${marker}"; found ${hits}`);
const start = lines.findIndex((l) => l.includes(marker));
let i = start;
while (i < lines.length && lines[i].trim() !== '') i++;
while (i < lines.length && lines[i].trim() === '') i++;
while (i < lines.length && lines[i].trim() !== '') i++;
return flat(lines.slice(start, i).join('\n'));
}
// ---- spawn model: phase_signal_result.model, the agent's own model: as the fallback ----
const PASS_MODEL = /Pass `model: \{phase_signal_result\.model\}` at this spawn; without it \(the `--brief` branch, where the resolver does not run, or a resolver that printed nothing\) the agent's own `model:` applies\./;
test('spawn model: commands/trekplan.md pins no spawn to `model: "opus"`', () => {
assert.doesNotMatch(PLAN_CMD, /model: "opus"/);
});
for (const [where, marker] of [
['brief-reviewer (Phase 4b)', 'Launch the **brief-reviewer** agent.'],
['### Always launch (Phase 5)', '### Always launch (all codebase sizes):'],
['research-scout (Phase 5)', 'launch **research-scout** in'],
['plan-critic and scope-guardian (Phase 9)', 'it traces into `## Verification`.'],
]) {
test(`spawn model: ${where}: the spawn takes phase_signal_result.model, the agent's own model: as the fallback`, () => {
const part = paraAfter('commands/trekplan.md', marker);
assert.match(part, PASS_MODEL, `${where}: missing the resolved-model paragraph after "${marker}"`);
assert.doesNotMatch(part, /model: "opus"/);
});
}
test('spawn model: session-decomposer (Phase 1.5): --decompose has no resolved model, so the spawn passes no model:', () => {
const part = paraAfter('commands/trekplan.md', 'Launch the **session-decomposer** agent:');
assert.ok(part.includes('Phase 1 runs the model resolver only for `--project`, so `--decompose` has no resolved model: this spawn '
+ 'passes no `model:`, and the agent\'s own `model:` applies.'), `the session-decomposer paragraph does not say it passes no model: ${part}`);
assert.ok(!part.includes('phase_signal_result'), 'the session-decomposer spawn still names phase_signal_result');
assert.doesNotMatch(part, /model: "opus"/);
});
test('spawn model: convention-scanner (Phase 5): the spawn takes phase_signal_result.model, the frontmatter as the fallback', () => {
const part = paraAfter('commands/trekplan.md', 'use the `convention-scanner` plugin agent');
assert.match(part, /\(model: phase_signal_result\.model, the agent's frontmatter as the fallback\)/);
assert.doesNotMatch(part, /model: "opus"/);
});
test('spawn model: deep-dives (Phase 6): the spawn takes phase_signal_result.model, else opus', () => {
const part = paraAfter('commands/trekplan.md', 'spawn a targeted deep-dive agent');
assert.match(part, /\(model: phase_signal_result\.model, else opus — Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to, subagent_type: "Explore"\)/);
assert.doesNotMatch(part, /model: "opus"/);
});
// ---- Phase 4 sizing: no turn budget, and the count it runs ---------------------
test('Phase 4: commands/trekplan.md promises no maxTurns budget, and Phase 4 calibrates no turns', () => {
assert.doesNotMatch(PLAN_CMD, /maxTurns/);
const p4 = flat(sectionText(PLAN_CMD, '## Phase 4 — Codebase sizing'));
assert.doesNotMatch(p4, /calibrate agent turns/);
assert.match(p4, /Determine codebase scale to decide whether convention-scanner runs\./);
assert.match(PLAN_CMD, /^\*\*All agents run for all codebase sizes\.\*\*$/m);
});
test('Phase 4: the size count counts .mjs, .cjs, .mts and .cts files', () => {
const p4 = sectionText(PLAN_CMD, '## Phase 4 — Codebase sizing');
const cmd = p4.match(/```\n(find [^\n]+)\n```/);
assert.ok(cmd, 'the find command stands in a fence of its own');
const dir = mkdtempSync(join(tmpdir(), 'voyage-sizing-'));
try {
for (const f of ['a.mjs', 'b.cjs', 'c.mts', 'd.cts', 'e.ts', 'skip.txt']) writeFileSync(join(dir, f), 'x\n');
const r = spawnSync('/bin/sh', ['-c', cmd[1]], { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), '5');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// ---- a step of only tests can never be proven red -----------------------------
const TESTS_IN_BUILDING_STEP = /test\s+for\s+a\s+behaviou?r\s+belongs\s+in\s+the\s+step\s+that\s+builds\s+it/i;
test('tests-only step: /trekplan says the test belongs in the step that builds the behaviour, and names the warning', () => {
assert.match(PLAN_CMD, TESTS_IN_BUILDING_STEP);
// At the vertical-slice rule and at the step field list.
assert.match(PLAN_CMD, /Steps are vertical slices[\s\S]{0,900}only tests[\s\S]{0,300}never be proven red/i);
assert.match(PLAN_CMD, /\*\*Test first:\*\* — [^\n]*\n[^\n]*step that builds/i);
assert.match(PLAN_CMD, /PLAN_STEP_TESTS_ONLY/);
});
test('tests-only step: the plan template carries the same rule', () => {
const t = read('templates/plan-template.md');
assert.match(t, TESTS_IN_BUILDING_STEP);
assert.match(t, /only tests[\s\S]{0,200}never be proven red/i);
});
test('tests-only step: plan-critic has it as a finding under Verification gaps, and still 12 dimensions', () => {
const critic = read('agents/plan-critic.md');
const d8 = sectionText(critic, '### 8. Verification gaps');
assert.match(d8, /PLAN_STEP_TESTS_ONLY/);
assert.match(d8, /only tests[\s\S]{0,400}\*\*major\*\*/i);
assert.equal((critic.match(/^### \d+\. /gm) ?? []).length, 12);
});
// ---- minors after the last critic round, Regression:, On failure: -----------
// Moved from the second lekerepo trial's pins (B8, B9, B10, C17, A7).
test('B8: a PASS with minors sends them to the Phase 10 summary, numbered, for the operator to pick', () => {
const p9 = sectionText(PLAN_CMD, '## Phase 9 — Adversarial review (bounded critic loop)');
assert.match(p9, /\*\*PASS\*\*[\s\S]{0,600}minors[\s\S]{0,300}Phase 10/i);
const p10 = sectionText(PLAN_CMD, '## Phase 10 — Present and refine');
assert.match(p10, /\*\*Open minors:\*\*/);
assert.match(p10, /numbered/i);
});
test('B8: fixing picked minors after the last round runs one more round with --extra-round, the pick being the yes', () => {
const p10 = sectionText(PLAN_CMD, '## Phase 10 — Present and refine');
assert.match(p10, /--extra-round/);
assert.match(p10, /pick(ed|s)?\s+(is|counts as)\s+(the|their)\s+yes/i);
});
test('B9: /trekplan Phase 8 names `Regression:` as an allowed optional field and says what it is for', () => {
const p8 = sectionText(PLAN_CMD, '## Phase 8 — Deep planning');
assert.match(p8, /`Regression:`/);
assert.match(p8, /PLAN_STEP_REGRESSION_NOT_RUNNABLE/);
assert.match(p8, /\*\*Regression:\*\* `npm test`/, 'the canonical example shows the field');
assert.match(p8, /before (the|its) checkpoint/i);
});
test('B9: the plan template shows `Regression:` as optional', () => {
const t = read('templates/plan-template.md');
assert.match(t, /- \*\*Regression:\*\* `[^\n]*\*\(optional\)\*/);
assert.match(t, /`Regression:` \(ONE backticked command/);
});
test('B9: plan-critic knows the field and does not demand the suite in `Verify:`', () => {
const c = read('agents/plan-critic.md');
assert.match(c, /`Regression:`/);
assert.match(c, /(never|do\s+not)\s+(demand|require)\s+`npm test`\s+in\s+`Verify:`/i);
});
test('B9/B10: Handover 4 documents `Regression:` (K10) and the `On failure:` words (K11)', () => {
const h4 = sectionText(read('docs/HANDOVER-CONTRACTS.md'), '## Handover 4 — `plan.md` → execute');
for (const code of ['PLAN_STEP_REGRESSION_NOT_RUNNABLE', 'PLAN_STEP_ON_FAILURE_UNKNOWN']) assert.match(h4, new RegExp(code));
assert.match(h4, /K10/);
assert.match(h4, /K11/);
assert.match(h4, /revert[^\n]*retry[^\n]*skip[^\n]*escalate/);
});
test('B10: /trekplan Phase 8 says `On failure:` opens with one of the four words', () => {
const p8 = sectionText(PLAN_CMD, '## Phase 8 — Deep planning');
assert.match(p8, /On failure:\*\*[^\n]*(opens|starts) with/i);
assert.match(p8, /PLAN_STEP_ON_FAILURE_UNKNOWN/);
});
test('C17: git-historian names the remote from `git remote -v`, never by guess', () => {
const g = read('agents/git-historian.md');
assert.match(g, /`git remote -v`/);
assert.match(g, /local (path|repository|bare)/i);
});
test('A7: every checkpoint the plan template and /trekplan\'s example show stages the step\'s files first', () => {
const lines = [
...read('templates/plan-template.md').split('\n'),
...PLAN_CMD.split('\n'),
].filter((l) => /\*\*Checkpoint:\*\*/.test(l) && /`git [^`]*commit -m /.test(l));
assert.ok(lines.length >= 3, `found ${lines.length}`);
for (const l of lines) assert.match(l, /`git add [^`]+ && git commit -m /, l);
});
test('A7: the session-spec template stages before it commits too', () => {
const l = read('templates/session-spec-template.md').split('\n').filter((x) => /\*\*Checkpoint:\*\*/.test(x) && /`git [^`]*commit -m /.test(x));
assert.ok(l.length >= 1);
for (const x of l) assert.match(x, /`git add [^`]+ && git commit -m /, x);
});