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:
parent
2c89a6b580
commit
f5ca220bc0
24 changed files with 3115 additions and 61 deletions
202
lib/verification/plan-scope-sweep.mjs
Normal file
202
lib/verification/plan-scope-sweep.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue