plan-gate.mjs --record --brief <brief.md> (a known flag; its value is kept
out of the positional arguments, a missing value is a usage error) applies
the brief's threat model to the deduped findings before countRound when
threat-model.mjs usable() says it may: a backed finding (Voyage's own
security finding whose threat_model_ref equals an Outside: line's text)
leaves the counted set and the stop rule and goes to the new optional
verdict field known_outside ({id, rule_key, text, line}), carried to later
rounds until a reviewer marks it resolved or it is reported again under its
id. The verdict stores brief_path (carried when a later round has none).
The --record line adds known_outside (a count) and threat_model
({applied, why}): a threat model that is not usable backs nothing, every
security finding counts, and the output says why. extractVerdict
shape-checks both new fields.
The bare gate re-reads the brief at brief_path each run: an entry still
backed is a warning, PLAN_GATE_KNOWN_OUTSIDE; one no longer backed (line
gone, approval stale or not logged, brief unreadable, no brief_path, or not
a Voyage security finding) stops the plan, PLAN_GATE_OUTSIDE_NOT_BACKED: it
counts in full. gatePlan takes an optional threatModel for tests.
plan-review-dedup keeps threat_model_ref on a merge only when every merged
copy carries the same one; the S160 known issue is fixed: a null in a
reviewer's findings list is skipped instead of crashing the CLI.
plan-critic and scope-guardian: a security finding needs a concrete attack
path and high confidence (Claude Code's threshold, 167), uses
SECURITY_INJECTION or SECURITY_RISK, and quotes the Outside: line's text in
threat_model_ref when one applies; the json template carries the field.
/trekplan Phase 9 passes the brief path to plan-critic and --brief to
--record. Handover 4 names the two fields, the two codes and the flag.
Part 2, step 2.4 of the E plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
17 KiB
JavaScript
322 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 { findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
|
|
import { sectionText } from '../helpers/md-section.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 9 hands the brief to plan-critic and --brief to --record; the threat model\'s known issues are named (E 2.4)', () => {
|
|
const p9 = phase(PLAN_CMD, 9);
|
|
const critic = p9.slice(p9.indexOf('**plan-critic**'), p9.indexOf('**scope-guardian**'));
|
|
assert.match(critic, /Brief file: \{brief_path\}/, 'plan-critic gets the brief path');
|
|
assert.match(critic, /## Threat Model/);
|
|
assert.match(p9, /--record "\{plan_path\}" --reviewed \{the hash from --hash\} --brief "\{brief_path\}"/);
|
|
assert.match(p9, /`known_outside`/);
|
|
assert.match(p9, /`threat_model`/);
|
|
assert.match(p9, /PLAN_GATE_KNOWN_OUTSIDE/);
|
|
assert.match(p9, /PLAN_GATE_OUTSIDE_NOT_BACKED/);
|
|
});
|
|
|
|
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();
|
|
|
|
/**
|
|
* 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: a Voyage agent gets no model:, a built-in agent gets opus ----
|
|
|
|
const NO_MODEL = "This spawn passes no `model:`: the agent's own `model: opus` applies.";
|
|
const BUILT_IN = 'Spawn it with `model: "opus"`: a built-in agent has no Voyage pin.';
|
|
|
|
test('spawn model: commands/trekplan.md pins `model: "opus"` once, at the built-in Explore deep-dive', () => {
|
|
assert.equal(PLAN_CMD.split('model: "opus"').length - 1, 1, 'expected exactly one `model: "opus"` in commands/trekplan.md');
|
|
});
|
|
|
|
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`.'],
|
|
['session-decomposer (Phase 1.5)', 'Launch the **session-decomposer** agent:'],
|
|
]) {
|
|
test(`spawn model: ${where}: the spawn passes no model:, the agent's own model: opus applies`, () => {
|
|
const part = paraAfter('commands/trekplan.md', marker);
|
|
assert.ok(part.includes(NO_MODEL), `${where}: missing the no-model sentence after "${marker}": ${part}`);
|
|
assert.doesNotMatch(part, /model: "opus"/);
|
|
});
|
|
}
|
|
|
|
test('spawn model: convention-scanner (Phase 5): the spawn passes no model:, its own model: opus applies', () => {
|
|
const part = paraAfter('commands/trekplan.md', 'use the `convention-scanner` plugin agent');
|
|
assert.ok(part.includes('(no `model:`; its own `model: opus` applies)'), part);
|
|
assert.doesNotMatch(part, /model: "opus"/);
|
|
});
|
|
|
|
test('spawn model: deep-dives (Phase 6): the built-in Explore agent is spawned with model opus', () => {
|
|
const part = paraAfter('commands/trekplan.md', 'spawn a targeted deep-dive agent');
|
|
assert.ok(part.includes('(subagent_type: "Explore", Claude Code\'s built-in agent)'), part);
|
|
assert.ok(part.includes(BUILT_IN), part);
|
|
});
|
|
|
|
// ---- 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);
|
|
});
|