// 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. // Counted as doc pins by lib/util/test-census.mjs (PIN_FILE_RE). 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 --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); });