feat(plan): plan 1.8, the plan gate, the design part and the plan-side checks

Wave 1, order 3 (P3 plan mode as the start, P2 design as its own part, P9
plan side). Contracts K3-K6 for the later merges.

- plan_version 1.8 (1.7 plans stay valid): Test first on every step, one
  End-to-end ("run the thing") step, optional Interface in / Interface out /
  Review focus fields, a ceremony class in the header (absent =
  architectural), and for architectural plans a ## Design part: module map
  without cycles, architecture rules that name their enforcer and the step
  that proves them, a requirements check asked as keep / change.
- K3 lib/validators/plan-gate.mjs: the critic verdict is recorded INSIDE the
  plan (Claude Code's plan approval hands a hook only the plan file's text),
  bound to the hash taken before the reviewers read it; 0 go / 1 stop / 2
  could not run; at most 3 critic rounds, then stop and ask.
- K5 lib/verification/arch-rules.mjs: runs the target repo's own
  dependency-cruiser or import-linter (installs nothing; 2 = not measured,
  never green); --prove --rule shows pass -> fail on a planted violation of
  that rule -> pass. Verified against dependency-cruiser 18.5.0 and
  import-linter 2.15; madge 8.0.0 left out (missed a planted .mjs cycle).
- K6 lib/verification/plan-scope-sweep.mjs: changed files no step names.
- /trekplan Phases 8-10: design part, bounded critic loop, the gate before
  "execute", a plan-mode path through ExitPlanMode (not yet run end to end
  inside plan mode). plan-critic 10 -> 12 dimensions; scope-guardian checks
  the ceremony.
- Fixed on the way: plan-validator and plan-review-dedup used the
  file://argv[1] main guard (no-op under a symlink or a space); CRLF plans
  failed every manifest; the dedup dropped finding severity.

Borrowed, credited in place: obra/superpowers, Kiro (docs), addyosmani/
agent-skills, mattpocock/skills, GitHub Spec Kit, bashebr/ai-native-sdlc,
Boris Cherny's public threads.

Suite 1497 (1495 / 0 / 2), 86 new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:10:57 +02:00
commit f5ca220bc0
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
24 changed files with 3115 additions and 61 deletions

View file

@ -0,0 +1,352 @@
// lib/validators/plan-gate.mjs
// K3 — the plan gate: may this plan go on to execution?
//
// node plan-gate.mjs <plan.md> [--json]
// 0 = the plan may go on
// 1 = stop: invalid plan (plan-validator --strict, plan_version >= 1.8),
// no critic verdict, a malformed verdict, a verdict the plan has
// moved away from (stale), or an open BLOCKER
// 2 = could not run (missing/unreadable plan, bad arguments)
//
// node plan-gate.mjs --hash <plan.md>
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
// the reviewers read the plan and hands it to --record.
//
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
// the plan. 0 = written; 1 = refused: the round cap is spent (stop and ask
// the operator; `--extra-round` only after their yes, and only then);
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
// the plan changed since --hash (the verdict would bless unreviewed text),
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
//
// Where the verdict lives: INSIDE the plan, as the `## Critic Verdict` section
// with one fenced json block. Claude Code's plan approval hands a hook the
// plan file's text (`tool_input.plan`, `planFilePath`) and nothing next to
// it, so a verdict in a sibling file would be invisible there.
//
// What the verdict is bound to: sha256 of the plan's operative text — the plan
// minus its review log, with CRLF and line-end whitespace normalized. The
// review log is the `> **Plan quality:` header line and the level-2 sections
// titled exactly Critic Verdict, Revisions, Plan Quality Score and
// "Adversarial Pass 2 (v5.1.1 high-effort)" — each only up to its first
// other heading, so a step or section placed under a log heading is hashed.
// Edit a step, the design or the verification after the critic ran, and the
// verdict is stale until the next round.
//
// The loop is bounded (Kiro's bounded review loop): MAX_ROUNDS critic rounds,
// then stop and ask; never silently on. Unrated findings count as blockers.
// The verdict is a trace of a process, not a signature: anything that can
// write the plan can write the block.
import { readFileSync, existsSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { validatePlanContent } from './plan-validator.mjs';
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
export const MAX_ROUNDS = 3;
export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
export const VERDICT_HEADING = '## Critic Verdict';
export const REQUIRED_REVIEWERS = Object.freeze(['plan-critic', 'scope-guardian']);
// Level-2 sections that are review log, not plan: excluded from the hash.
export const REVIEW_LOG_SECTIONS = Object.freeze([
'Critic Verdict', 'Revisions', 'Plan Quality Score', 'Adversarial Pass 2 (v5.1.1 high-effort)',
]);
// The quality line alone; a line that also carries `plan_version`/`ceremony` stays hashed.
const QUALITY_LINE = /^> \*\*Plan quality:[^`]*$/;
const isLogTitle = t => t !== null && REVIEW_LOG_SECTIONS.includes(t);
const norm = t => String(t).replace(/\r\n?/g, '\n');
/** A log section counts as log only up to its first other heading (outside code). */
function logPart(sectionText) {
const lines = sectionText.split('\n');
const fence = fenceTracker();
for (let i = 1; i < lines.length; i++) {
if (fence.feed(lines[i])) continue;
if (/^#{1,6} /.test(lines[i])) return { log: lines.slice(0, i).join('\n'), rest: lines.slice(i).join('\n') };
}
return { log: sectionText, rest: '' };
}
/** The plan minus its review log, normalized (LF, no line-end whitespace, one trailing newline). */
export function operativeText(text) {
const kept = splitSections(norm(text), 2)
.map(s => (isLogTitle(s.title) ? logPart(s.text).rest : s.text))
.join('\n');
return kept
.split('\n')
.filter(l => !QUALITY_LINE.test(l))
.map(l => l.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim() + '\n';
}
export function planHash(text) {
return createHash('sha256').update(operativeText(text)).digest('hex');
}
/**
* @returns {{found: boolean, placeholder: boolean, verdict: object|null, error: string|null}}
* placeholder = a `## Critic Verdict` heading with no fenced block at all
* (copied prose, not a verdict): --record replaces it, the gate stops on it.
*/
export function extractVerdict(text) {
const secs = splitSections(norm(text), 2).filter(s => s.title === 'Critic Verdict');
const none = { found: false, placeholder: false, verdict: null };
if (secs.length === 0) return { ...none, error: null };
if (secs.length > 1) return { ...none, found: true, error: `${secs.length} "## Critic Verdict" sections; expected one` };
const log = logPart(secs[0].text).log;
const m = log.match(/```json[ \t]*\n([\s\S]*?)\n[ \t]*```/);
if (!m) {
const placeholder = !/^\s{0,3}(```|~~~)/m.test(log);
return { ...none, found: true, placeholder, error: 'no ```json block in ## Critic Verdict' };
}
let v;
try { v = JSON.parse(m[1]); } catch (e) { return { ...none, found: true, error: `verdict JSON does not parse: ${e.message}` }; }
const okShape = v && typeof v === 'object' && v.schema === VERDICT_SCHEMA && Number.isInteger(v.round) && v.round >= 1 &&
Number.isInteger(v.max_rounds) && v.max_rounds >= 1 && v.max_rounds <= MAX_ROUNDS &&
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string');
if (!okShape) {
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity)` };
}
return { found: true, placeholder: false, verdict: v, error: null };
}
function verdictFor(counts) {
if (counts.blocker > 0) return 'BLOCK';
if (counts.major > 0) return 'REVISE';
return 'PASS';
}
function renderSection(v) {
return [
VERDICT_HEADING,
'',
`<!-- Written by lib/validators/plan-gate.mjs --record (round ${v.round} of ${v.max_rounds}` +
`${v.extra_rounds ? ` + ${v.extra_rounds} extra` : ''}). Do not edit by hand: the gate re-hashes the plan and reads this block. -->`,
'',
'```json',
JSON.stringify(v, null, 2),
'```',
'',
].join('\n');
}
/** Replace (or append) the `## Critic Verdict` section; anything after its log part is kept. */
function placeSection(text, section) {
const secs = splitSections(norm(text), 2);
const idx = secs.findIndex(s => s.title === 'Critic Verdict');
if (idx === -1) return norm(text).replace(/\s*$/, '\n\n') + section;
const parts = secs.map(s => s.text);
const { rest } = logPart(parts[idx]);
parts[idx] = section.replace(/\n$/, '') + (rest ? `\n\n${rest}` : '');
return parts.join('\n').replace(/\s*$/, '\n');
}
/** Both reviewers must have been present in the dedup input; an absent one is not a clean review. */
function missingReviewers(dedup) {
const present = new Set(
(Array.isArray(dedup.sources) ? dedup.sources : []).filter(s => s && s.present === true).map(s => s.agent),
);
return REQUIRED_REVIEWERS.filter(r => !present.has(r));
}
/**
* Record one critic round into the plan text.
* @param {string} text - the plan
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
* plan-review-dedup.mjs output
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string}} [opts]
* reviewed = planHash of the text the reviewers read (from --hash)
* @returns {{code: 0|1|2, text?: string, verdict?: object, message: string}}
*/
export function recordVerdict(text, dedup, opts = {}) {
if (!dedup || typeof dedup !== 'object' || !Array.isArray(dedup.findings)) {
return { code: 2, message: 'record input must be plan-review-dedup output: {"findings": [...], "sources": [...]}' };
}
if (dedup.findings.some(f => !f || typeof f !== 'object')) {
return { code: 2, message: 'record input has a finding that is not an object' };
}
const missing = missingReviewers(dedup);
if (missing.length > 0) {
return { code: 2, message: `reviewer(s) missing from the dedup input: ${missing.join(', ')} — an absent review is not a clean one; rerun the round` };
}
const current = planHash(text);
if (typeof opts.reviewed !== 'string' || !/^[0-9a-f]{64}$/.test(opts.reviewed)) {
return { code: 2, message: '--reviewed <sha256> is required: take it with --hash before the reviewers read the plan' };
}
if (opts.reviewed !== current) {
return { code: 2, message: 'the plan changed after the reviewers read it (--reviewed differs from the plan now); rerun the round on the current plan' };
}
const prev = extractVerdict(text);
if (prev.error && !prev.placeholder) return { code: 2, message: `existing verdict unreadable: ${prev.error}` };
const maxRounds = Math.min(prev.verdict?.max_rounds ?? opts.maxRounds ?? MAX_ROUNDS, MAX_ROUNDS);
const prevExtra = prev.verdict?.extra_rounds ?? 0;
const round = (prev.verdict?.round ?? 0) + 1;
const capped = round > maxRounds + prevExtra;
if (opts.extraRound && !capped) {
return { code: 2, message: `--extra-round refused: round ${round} is within the cap; it is only for a round after the cap, after the operator's yes` };
}
if (capped && !opts.extraRound) {
return {
code: 1,
message: `round ${round} refused: ${maxRounds} critic rounds${prevExtra ? ` (+${prevExtra} extra)` : ''} are spent. ` +
'Stop and ask the operator: one more round (rerun with --extra-round after their yes), or stop here.',
};
}
const extra = prevExtra + (capped ? 1 : 0);
const findings = dedup.findings.map((f) => {
const sev = normalizeSeverity(f.severity);
const out = {
id: f.id ?? null,
severity: sev ?? 'blocker',
rule_key: f.rule_key ?? null,
file: f.file ?? null,
line: f.line ?? null,
text: typeof f.text === 'string' ? f.text : '',
raised_by: Array.isArray(f.raised_by) ? f.raised_by : [],
};
if (sev === null) out.severity_defaulted = true;
return out;
});
const counts = { blocker: 0, major: 0, minor: 0 };
for (const f of findings) counts[f.severity] += 1;
const verdict = {
schema: VERDICT_SCHEMA,
round,
max_rounds: maxRounds,
extra_rounds: extra,
recorded_at: opts.now ?? new Date().toISOString(),
plan_sha256: current,
verdict: verdictFor(counts),
counts,
findings,
};
return { code: 0, text: placeSection(text, renderSection(verdict)), verdict, message: `round ${round} recorded: ${verdict.verdict}` };
}
/**
* K3 — decide whether the plan may go on.
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, validation: object}}
*/
export function gatePlan(rawText) {
const text = norm(rawText);
const reasons = [];
const warnings = [];
const validation = validatePlanContent(text, { strict: true });
if (!validation.valid) {
reasons.push({ code: 'PLAN_GATE_INVALID_PLAN', message: `plan-validator --strict: ${validation.errors.map(e => e.code).join(', ')}`, errors: validation.errors });
}
const version = extractPlanVersion(text);
if (!versionAtLeast(version, '1.8')) {
reasons.push({ code: 'PLAN_GATE_VERSION', message: `plan_version ${version ?? '(none)'}: the gate passes only plans written to plan_version 1.8 or later` });
}
const ex = extractVerdict(text);
const v = ex.verdict;
if (!ex.found) {
reasons.push({ code: 'PLAN_GATE_NO_VERDICT', message: 'No "## Critic Verdict": the plan has not been through the critic (run Phase 9).' });
} else if (ex.error) {
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
} else {
if (v.plan_sha256 !== planHash(text)) {
reasons.push({ code: 'PLAN_GATE_VERDICT_STALE', message: `The plan changed after critic round ${v.round}; run the next round.` });
}
const sev = f => normalizeSeverity(f.severity);
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
const majors = v.findings.filter(f => sev(f) === 'major');
if (blockers.length > 0) {
reasons.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} open BLOCKER(s) in critic round ${v.round}: ${blockers.map(b => b.text).join(' | ')}` });
if (v.round >= v.max_rounds + (v.extra_rounds ?? 0)) {
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
}
}
if (majors.length > 0) {
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) left open in round ${v.round}; show them to the operator.` });
}
}
return { code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings, verdict: v ?? null, validation };
}
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function out(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
if (isMain()) {
const args = process.argv.slice(2);
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json';
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round']);
const rIdx = args.indexOf('--reviewed');
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
const planPath = positional[0];
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
process.stderr.write(`${usage}\n`);
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
process.exit(2);
}
let text;
try {
if (!existsSync(planPath)) throw new Error('file not found');
text = readFileSync(planPath, 'utf-8');
} catch (e) {
out({ code: 2, status: 'error', plan: planPath, message: `cannot read plan: ${e.message}` });
process.exit(2);
}
if (args.includes('--hash')) {
process.stdout.write(`${planHash(text)}\n`);
process.exit(0);
}
if (args.includes('--record')) {
let dedup;
try { dedup = JSON.parse(readFileSync(0, 'utf-8')); } catch (e) {
out({ code: 2, recorded: false, message: `stdin is not JSON: ${e.message}` });
process.exit(2);
}
const r = recordVerdict(text, dedup, { extraRound: args.includes('--extra-round'), reviewed });
if (r.code === 0) {
const tmp = `${planPath}.plan-gate.tmp`;
try {
writeFileSync(tmp, r.text);
renameSync(tmp, planPath);
} catch (e) {
rmSync(tmp, { force: true });
out({ code: 2, recorded: false, message: `cannot write the plan: ${e.message}` });
process.exit(2);
}
}
out({
code: r.code, recorded: r.code === 0, message: r.message,
round: r.verdict?.round ?? null, max_rounds: r.verdict?.max_rounds ?? null,
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
});
process.exit(r.code);
}
const g = gatePlan(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, verdict: g.verdict }, null, 2) + '\n');
} else {
process.stdout.write(
`plan-gate: ${g.status.toUpperCase()} ${planPath}\n` +
g.reasons.map(r => ` STOP [${r.code}] ${r.message}\n`).join('') +
g.warnings.map(w => ` WARN [${w.code}] ${w.message}\n`).join(''),
);
}
process.exit(g.code);
}

View file

@ -6,15 +6,150 @@
// tolerated silently. Strict-key checks are intentionally avoided so new
// optional fields (jf. the source_findings precedent) can be added without
// a plan_version bump.
//
// plan_version 1.8 rules (apply only to plans declaring >= 1.8; 1.7 plans
// validate exactly as before):
// - every step has `Test first:` naming a File and what it Verifies;
// - the plan has a "run the thing" step: an `End-to-end:` field with a
// backticked command (without it a result is "not verified end to end");
// - the optional step fields `Interface in:`, `Interface out:`,
// `Review focus:` are never empty when present;
// - `ceremony:` is small | bounded | architectural (absent = architectural);
// an architectural plan carries `## Design` (see checkDesign).
// Strict mode reports these as errors; --soft downgrades them to warnings.
import { readFileSync, existsSync } from 'node:fs';
import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs';
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
export function validatePlanContent(text, opts = {}) {
export const OPTIONAL_STEP_FIELDS = Object.freeze(['Interface in', 'Interface out', 'Review focus']);
export const DESIGN_SUBSECTIONS = Object.freeze([
'Architecture', 'Data Flow', 'Interfaces', 'Data Models', 'Error Handling',
'Module Map', 'Architecture Rules', 'Requirements Check',
]);
const BACKTICKED = /`[^`\n]+`/;
function checkSteps(sections) {
const out = [];
let endToEnd = 0;
for (const s of sections) {
const f = parseStepFields(s.body);
const tf = f.get('Test first');
const names = tf && /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf.text) && /\bVerifies:\**[ \t]*\S/i.test(tf.text);
if (!names) {
out.push(issue(
'PLAN_STEP_NO_TEST_FIRST',
`Step ${s.n}: ${tf ? '`Test first:` does not name a File (backticked path) and what it Verifies' : 'no `Test first:` field'}`,
'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.',
));
}
for (const name of OPTIONAL_STEP_FIELDS) {
if (f.has(name) && f.get(name).text === '') {
out.push(issue('PLAN_STEP_FIELD_EMPTY', `Step ${s.n}: \`${name}:\` is present but empty`,
'Fill the field or remove it; it is optional.'));
}
}
if (f.has('End-to-end') && BACKTICKED.test(f.get('End-to-end').text)) endToEnd += 1;
}
if (sections.length > 0 && endToEnd === 0) {
out.push(issue(
'PLAN_NO_END_TO_END',
'No "run the thing" step: no step carries `End-to-end:` with a backticked command',
'Add a step that runs the product the way a user does (`End-to-end: `<command>` → expected: …`). ' +
'Without it the result is "not verified end to end", never green.',
));
}
return out;
}
/** Table body rows (skips the header and the |---| separator). */
function tableRows(text) {
const rows = text.split('\n').filter(l => l.trim().startsWith('|'));
return rows.slice(2).map(r => r.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()));
}
function checkDesign(text, stepNumbers) {
const out = [];
const design = findSection(text, 'Design', 2);
if (!design) {
return [issue('PLAN_DESIGN_MISSING', 'Architectural plan without a `## Design` section',
'Add the design part (templates/design-template.md), or plan under `ceremony: small|bounded` if the brief says so.')];
}
const subs = splitSections(design.text, 3);
const titles = new Set(subs.filter(s => s.title).map(s => s.title.toLowerCase()));
const missing = DESIGN_SUBSECTIONS.filter(t => !titles.has(t.toLowerCase()));
if (missing.length > 0) {
out.push(issue('PLAN_DESIGN_SECTION_MISSING', `## Design lacks: ${missing.join(', ')}`,
`Required subsections: ${DESIGN_SUBSECTIONS.join(', ')}.`));
}
if (!findSection(text, 'Test Strategy', 2)) {
out.push(issue('PLAN_DESIGN_SECTION_MISSING', 'Architectural plan lacks `## Test Strategy`',
'The design part\'s test strategy lives in the plan\'s `## Test Strategy` section.'));
}
const sub = t => subs.find(s => s.title && s.title.toLowerCase() === t.toLowerCase());
const map = sub('Module Map');
if (map) {
const blocks = fencedBlocks(map.text, 'mermaid');
if (blocks.length === 0) {
out.push(issue('PLAN_MODULE_MAP_EMPTY', '### Module Map has no ```mermaid block',
'Draw the modules and their dependency direction as a mermaid graph.'));
}
// All blocks form one map: a cycle may run across two of them.
const parsed = blocks.map(b => parseMermaid(b));
const unparsed = parsed.flatMap(p => p.unparsed);
if (unparsed.length > 0) {
out.push(issue('PLAN_MODULE_MAP_UNPARSED', `Module map lines that look like links but give no edge: ${unparsed.join(' | ')}`,
'Write links as `a --> b` (ids of letters, digits, _ . - /); the validator cannot check what it cannot read.'));
}
const cycle = findModuleCycle(parsed.flatMap(p => p.edges));
if (cycle) {
out.push(issue('PLAN_MODULE_CYCLE', `Module map has a dependency cycle: ${cycle.join(' -> ')}`,
'Dependencies point one way. Break the cycle (extract the shared part, or invert one edge).'));
}
}
const rules = sub('Architecture Rules');
if (rules) {
const rows = tableRows(rules.text);
if (rows.length === 0) {
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', '### Architecture Rules lists no rule',
'List at least one rule as a table row: | Rule | Enforced by | Proof |.'));
}
for (const row of rows) {
const line = row.join(' | ');
const checked = /checked by\s+`[^`]+`/i.test(line);
const advisory = /\badvisory\b/i.test(line) && /\.claude\/rules\/[^\s`|]+\.md/.test(line);
if (!checked && !advisory) {
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', `Rule without an enforcer: ${row[0] || line}`,
'Each rule says "checked by `<command>`" or "advisory — `.claude/rules/<file>.md`".'));
continue;
}
if (checked) {
const proof = (row[2] ?? '').match(/Step (\d+)/);
if (!proof || !stepNumbers.includes(Number(proof[1]))) {
out.push(issue('PLAN_ARCH_RULE_NO_PROOF',
`Checked rule without an existing proving step: ${row[0] || line}`,
'Name the step (Proof column, "Step N") that shows the check pass, fail on a planted violation, and pass again.'));
}
}
}
}
return out;
}
export function validatePlanContent(rawText, opts = {}) {
const strict = opts.strict !== false;
// CRLF plans (an editor on Windows) used to fail every manifest with
// FM_SYNTAX: the YAML line parser does not expect a trailing \r.
const text = typeof rawText === 'string' ? rawText.replace(/\r\n?/g, '\n') : rawText;
const headRes = validatePlanHeadings(text, { strict });
const errors = [...headRes.errors];
const warnings = [...headRes.warnings];
@ -34,9 +169,22 @@ export function validatePlanContent(text, opts = {}) {
const planVersion = extractPlanVersion(text);
if (planVersion === null) {
warnings.push(issue('PLAN_NO_VERSION', 'No plan_version detected; current target is 1.7'));
} else if (planVersion !== '1.7') {
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`));
warnings.push(issue('PLAN_NO_VERSION', `No plan_version detected; current target is ${PLAN_VERSION_CURRENT}`));
} else if (!PLAN_VERSIONS_SUPPORTED.includes(planVersion)) {
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is ${PLAN_VERSION_CURRENT}`));
}
const ceremony = extractPlanCeremony(text);
if (versionAtLeast(planVersion, '1.8')) {
const v18 = [...checkSteps(sections)];
if (!ceremony.valid) {
v18.push(issue('PLAN_CEREMONY_INVALID', `ceremony=${ceremony.raw}`,
`Allowed: ${CEREMONY_VALUES.join(' | ')} (absent = architectural).`));
} else if (ceremony.value === 'architectural') {
v18.push(...checkDesign(text, steps.map(s => s.n)));
}
if (strict) errors.push(...v18);
else warnings.push(...v18);
}
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
@ -66,7 +214,7 @@ export function validatePlanContent(text, opts = {}) {
valid: errors.length === 0,
errors,
warnings,
parsed: { steps, manifests: manRes.parsed, planVersion },
parsed: { steps, manifests: manRes.parsed, planVersion, ceremony: ceremony.value },
};
}
@ -79,7 +227,17 @@ export function validatePlan(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
// or a path with a space (the CLI then exits 0 having checked nothing).
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));
@ -95,6 +253,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
warnings: r.warnings,
steps: r.parsed?.steps?.length ?? 0,
planVersion: r.parsed?.planVersion ?? null,
ceremony: r.parsed?.ceremony ?? null,
}, null, 2) + '\n');
} else {
process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`);