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,202 @@
// lib/verification/plan-scope-sweep.mjs
// K6 — the sweep for files the plan does not name.
//
// node plan-scope-sweep.mjs --plan <plan.md> --base <ref> [--repo <dir>] [--process <path|glob>]...
// 0 = every changed file is named by the plan or is a process file
// 1 = deviation: changed files outside the plan (listed in `outside`)
// 2 = could not run (not a git repo, unknown ref, plan missing or without steps)
// JSON on stdout: {code, status, base, merge_base, changed, outside, planned, process}
//
// changed = `git diff --name-only --no-renames <merge-base>` (committed AND
// uncommitted tracked changes) + untracked, non-ignored files
// planned = every step's `Files:` paths, its `Test first:` File, and its
// manifest `expected_paths`
// process = PROCESS_FILES (the plan itself, its project directory, Voyage's
// own planning areas) + any `--process` entries
// outside = changed − planned − process. It must be empty.
//
// A deviation is allowed only when the plan names the file in the same
// commit: the sweep reads the plan from the working tree, so adding the path
// to the step's `Files:` is what clears it.
//
// `--repo` defaults to the current directory. Paths in the plan are relative
// to the repository root (git top level), as git reports changed files.
// Paths in a plan may end in `/` (a directory) or use `*` / `**` globs; a glob
// must start with a fixed directory (`src/**` is fine, `**/*` would plan the
// whole repo and is ignored and reported in `ignored`). git output is read
// NUL-separated (-z), so non-ASCII names and spaces arrive unquoted.
//
// Borrowed: the sweep for files outside the plan from ai-native-sdlc (MIT).
// Voyage before this checked only each step's expected and forbidden paths.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs';
// Files Voyage's own process writes while planning and executing.
export const PROCESS_FILES = Object.freeze([
'.claude/projects/**',
'.claude/plans/**',
'.claude/trekplan-sessions/**',
]);
const BACKTICK_SPAN = /`([^`\n]+)`/g;
// Backticked: spaces allowed (`docs/my file.md`); bare: no whitespace.
const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === s;
const looksLikePath = s => !/\s/.test(s) && looksLikeTickedPath(s);
function pathsIn(text) {
const out = [];
for (const m of text.matchAll(BACKTICK_SPAN)) if (looksLikeTickedPath(m[1])) out.push(m[1]);
return out;
}
const norm = p => p.replace(/\\/g, '/').replace(/^\.\//, '');
/** Every path the plan names: Files, Test first File, manifest expected_paths. */
export function plannedPaths(planText) {
const out = new Set();
for (const s of sliceSteps(planText)) {
const f = parseStepFields(s.body);
const files = f.get('Files');
if (files) {
const ticked = pathsIn(files.text);
// Older plans write bare paths: `- Files: a.ts, b.ts`.
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
for (const p of [...ticked, ...bare]) out.add(norm(p));
}
const tf = f.get('Test first');
if (tf) {
for (const line of tf.text.split('\n')) {
if (/\bFile:/i.test(line)) for (const p of pathsIn(line)) out.add(norm(p));
}
}
const man = parseManifest(s.body);
for (const p of man?.parsed?.expected_paths ?? []) {
if (typeof p === 'string') out.add(norm(p));
}
}
return [...out].sort();
}
function globToRegExp(glob) {
let re = '';
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === '*' && glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
else if (c === '*') re += '[^/]*';
else if (c === '?') re += '[^/]';
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${re}$`);
}
// A glob with no fixed directory in front (`*`, `**/*`, `*.js`) names no place.
export const unboundedGlob = p => /[*?]/.test(p) && /[*?]/.test(norm(p).split('/')[0]);
function matcher(patterns) {
const exact = new Set();
const dirs = [];
const globs = [];
for (const p of patterns.map(norm)) {
if (p.endsWith('/')) dirs.push(p);
else if (/[*?]/.test(p)) globs.push(globToRegExp(p));
else exact.add(p);
}
return f => exact.has(f) || dirs.some(d => f.startsWith(d)) || globs.some(g => g.test(f));
}
function git(repo, args) {
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args[0]}: ${(r.stderr || r.error?.message || '').trim().split('\n')[0]}`);
return r.stdout;
}
const nulList = out => out.split('\0').filter(Boolean);
/**
* @returns {{code: 0|1|2, status: string, outside?: string[], changed?: string[], planned?: string[], reason?: string}}
*/
export function sweep({ plan, base, repo = process.cwd(), process: extra = [] }) {
const repoAbs = resolve(repo);
const planAbs = resolve(repoAbs, plan);
if (!existsSync(planAbs)) return { code: 2, status: 'error', reason: `plan not found: ${plan}` };
let top;
let mergeBase;
let changed;
let planText;
try {
planText = readFileSync(planAbs, 'utf-8');
top = git(repoAbs, ['rev-parse', '--show-toplevel']).trim();
mergeBase = git(repoAbs, ['merge-base', base, 'HEAD']).trim();
const tracked = nulList(git(top, ['diff', '--name-only', '--no-renames', '-z', mergeBase]));
const untracked = nulList(git(top, ['ls-files', '--others', '--exclude-standard', '-z']));
changed = [...new Set([...tracked, ...untracked])].sort();
} catch (e) {
return { code: 2, status: 'error', reason: e.message };
}
if (sliceSteps(planText).length === 0) return { code: 2, status: 'error', reason: `plan has no "### Step N:" steps: ${plan}` };
const all = plannedPaths(planText);
const ignored = all.filter(unboundedGlob);
const planned = all.filter(p => !unboundedGlob(p));
const planRel = norm(relative(realpathSync(top), realpathSync(planAbs)).split(sep).join('/'));
const processList = [...PROCESS_FILES, planRel, ...extra];
const planDir = dirname(planRel);
if (planDir !== '.' && planDir.startsWith('.claude/')) processList.push(`${planDir}/`);
const isPlanned = matcher(planned);
const isProcess = matcher(processList);
const outside = changed.filter(f => !isPlanned(f) && !isProcess(f));
return {
code: outside.length === 0 ? 0 : 1,
status: outside.length === 0 ? 'clean' : 'deviation',
base,
merge_base: mergeBase,
changed,
outside,
planned,
ignored,
process: processList,
};
}
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const argv = process.argv.slice(2);
const o = { process: [] };
let bad = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const v = argv[i + 1];
if (['--plan', '--base', '--repo', '--process'].includes(a)) {
if (v === undefined || v.startsWith('--')) { bad = bad ?? `${a} needs a value`; continue; }
i++;
if (a === '--plan') o.plan = v;
else if (a === '--base') o.base = v;
else if (a === '--repo') o.repo = v;
else o.process.push(v);
} else bad = bad ?? `unknown argument: ${a}`;
}
let result;
if (bad || !o.plan || !o.base) {
result = { code: 2, status: 'error', reason: bad ?? 'usage: plan-scope-sweep.mjs --plan <plan.md> --base <ref> [--repo <dir>] [--process <path|glob>]...' };
} else {
result = sweep(o);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (result.code === 1) process.stderr.write(`plan-scope-sweep: ${result.outside.length} file(s) outside the plan:\n${result.outside.map(f => ` ${f}`).join('\n')}\n`);
process.exit(result.code);
}