pan-wizard 3.35.0 → 3.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +11 -5
  2. package/agents/pan-designer.md +1 -1
  3. package/agents/pan-executor.md +25 -0
  4. package/agents/pan-hardener.md +2 -0
  5. package/agents/pan-phase-researcher.md +14 -0
  6. package/agents/pan-plan-checker.md +28 -1
  7. package/agents/pan-planner.md +11 -1
  8. package/agents/pan-previewer.md +2 -0
  9. package/agents/pan-project-researcher.md +8 -0
  10. package/agents/pan-reviewer.md +5 -0
  11. package/agents/pan-verifier.md +2 -2
  12. package/commands/pan/cost.md +21 -1
  13. package/commands/pan/preview.md +1 -1
  14. package/package.json +1 -1
  15. package/pan-wizard-core/bin/lib/config.cjs +10 -3
  16. package/pan-wizard-core/bin/lib/core.cjs +9 -4
  17. package/pan-wizard-core/bin/lib/cost.cjs +146 -0
  18. package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
  19. package/pan-wizard-core/bin/lib/findings.cjs +115 -6
  20. package/pan-wizard-core/bin/lib/git.cjs +1 -1
  21. package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
  22. package/pan-wizard-core/bin/lib/phase-gate.cjs +15 -9
  23. package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
  24. package/pan-wizard-core/bin/lib/phase.cjs +37 -9
  25. package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
  26. package/pan-wizard-core/bin/lib/preview.cjs +3 -0
  27. package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
  28. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
  29. package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
  30. package/pan-wizard-core/bin/lib/usage.cjs +104 -0
  31. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +1 -1
  32. package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
  33. package/pan-wizard-core/bin/lib/verify-scope.cjs +143 -2
  34. package/pan-wizard-core/bin/lib/verify.cjs +27 -27
  35. package/pan-wizard-core/bin/pan-tools.cjs +94 -6
  36. package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
  37. package/pan-wizard-core/references/design-methodology.md +1 -1
  38. package/pan-wizard-core/references/handoff-decisions.md +1 -1
  39. package/pan-wizard-core/templates/config.json +5 -1
  40. package/pan-wizard-core/templates/design.md +5 -4
  41. package/pan-wizard-core/templates/phase-prompt.md +7 -0
  42. package/pan-wizard-core/templates/research-project/architecture.md +6 -0
  43. package/pan-wizard-core/templates/research-project/features.md +6 -0
  44. package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
  45. package/pan-wizard-core/templates/research-project/stack.md +6 -0
  46. package/pan-wizard-core/templates/research.md +10 -0
  47. package/pan-wizard-core/templates/summary-complex.md +5 -1
  48. package/pan-wizard-core/templates/summary-minimal.md +4 -0
  49. package/pan-wizard-core/templates/summary-standard.md +5 -1
  50. package/pan-wizard-core/templates/summary.md +8 -1
  51. package/pan-wizard-core/workflows/exec-phase.md +43 -7
  52. package/pan-wizard-core/workflows/execute-plan.md +3 -1
  53. package/pan-wizard-core/workflows/help.md +103 -44
  54. package/pan-wizard-core/workflows/plan-phase.md +20 -2
  55. package/pan-wizard-core/workflows/remove-phase.md +11 -1
  56. package/pan-wizard-core/workflows/settings.md +17 -0
  57. package/pan-wizard-core/workflows/verify-phase.md +39 -46
  58. package/scripts/build-help.cjs +106 -0
  59. package/scripts/mutation-probe.cjs +7 -80
  60. package/scripts/test-surface.cjs +168 -6
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ /**
3
+ * RED and catch proof (program V4): does each test this phase wrote actually test what
4
+ * the phase built?
5
+ *
6
+ * A test that passes before the phase's code exists cannot have caught anything: it is
7
+ * tautological, or it tests something the phase did not change. So, independent of the
8
+ * order the commits landed in (Meta's "weak catch"):
9
+ *
10
+ * base the parent of the phase's oldest plan commit (`{type}({phase}-{plan}):`)
11
+ * tests the test files the phase's plan commits added or changed, as they are now
12
+ * red in a throwaway worktree at the base, with the test file copied in from HEAD,
13
+ * the test must FAIL (a crash on a missing module counts: the code is not there)
14
+ * green at HEAD it must PASS
15
+ *
16
+ * caught red at the base, green at HEAD
17
+ * not_red passes without the phase's code: it cannot have caught a regression
18
+ * not_green fails at HEAD: the test gate reports that, not this check
19
+ * not_checked no command can run that one file (cargo, Maven, Gradle, make), with why
20
+ *
21
+ * Each file runs on its own (`workflow.test_file_command`, else derived from the suite's
22
+ * runner: test-gate.cjs `fileCommand`). The worktree links the project's node_modules,
23
+ * which git does not track, so a JS runner resolves the same at the base as at HEAD.
24
+ */
25
+ const fs = require('fs');
26
+ const os = require('os');
27
+ const path = require('path');
28
+ const { execGit, findPhaseInternal, output, error } = require('./core.cjs');
29
+ const { committedFiles } = require('./verify-scope.cjs');
30
+ const { isTestFile } = require('./verify-integrity.cjs');
31
+ const TG = require('./test-gate.cjs');
32
+
33
+ const DEFAULT_FILE_TIMEOUT_MS = 5 * 60 * 1000;
34
+
35
+ /**
36
+ * The commit the phase started from: the parent of its oldest plan commit.
37
+ * @returns {{base: string, files: Set<string>, commits: number}|{base: null, reason: string}}
38
+ */
39
+ function phaseBase(cwd, phase) {
40
+ const c = committedFiles(cwd, phase);
41
+ if (!c.git) return { base: null, reason: 'git is unavailable' };
42
+ if (!c.commits) return { base: null, reason: 'the phase has no plan commits (subjects like feat(03-01): …)' };
43
+ const oldest = c.hashes[c.hashes.length - 1];
44
+ const r = execGit(cwd, ['rev-parse', '--verify', '-q', `${oldest}^`]);
45
+ if (r.exitCode !== 0 || !r.stdout) return { base: null, reason: 'the phase starts at the first commit of the repository: there is no base to run against' };
46
+ return { base: r.stdout.trim(), files: c.files, commits: c.commits };
47
+ }
48
+
49
+ /** Remove a directory link (a junction or a symlink) without touching what it points to. */
50
+ function unlinkDir(p) {
51
+ try { fs.unlinkSync(p); } catch { try { fs.rmdirSync(p); } catch { /* already gone */ } }
52
+ }
53
+
54
+ /**
55
+ * Run `fn(dir)` in a detached worktree at `rev`, then remove the worktree. The project's
56
+ * node_modules is linked in (and unlinked before git removes the worktree, so the removal
57
+ * can never reach the real one).
58
+ * @returns {{value?: *, error?: string}}
59
+ */
60
+ function withWorktree(cwd, rev, fn) {
61
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-wt-'));
62
+ const dir = path.join(root, 'wt');
63
+ const add = execGit(cwd, ['worktree', 'add', '--detach', '-q', dir, rev]);
64
+ if (add.exitCode !== 0) {
65
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
66
+ return { error: `git worktree add failed: ${add.stderr || add.stdout}` };
67
+ }
68
+ const link = path.join(dir, 'node_modules');
69
+ let linked = false;
70
+ try {
71
+ const modules = path.join(cwd, 'node_modules');
72
+ if (fs.existsSync(modules) && !fs.existsSync(link)) {
73
+ try { fs.symlinkSync(modules, link, 'junction'); linked = true; } catch { /* runs without it */ }
74
+ }
75
+ return { value: fn(dir) };
76
+ } finally {
77
+ if (linked) unlinkDir(link);
78
+ execGit(cwd, ['worktree', 'remove', '--force', dir]);
79
+ execGit(cwd, ['worktree', 'prune']);
80
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
81
+ }
82
+ }
83
+
84
+ const tail = (s) => TG.stripAnsi(String(s || '')).replace(/\r\n?/g, '\n').trim().split('\n').slice(-6).join('\n');
85
+
86
+ /**
87
+ * The RED/catch proof for one phase.
88
+ * @param {string} cwd
89
+ * @param {string} phase
90
+ * @param {{timeoutMs?: number}} [opts]
91
+ * @returns {Object} `{phase, checked, ok, base, files: [{file, verdict, command, base_exit, head_exit, detail}], reason?}` or `{error, reason}`
92
+ */
93
+ function checkRed(cwd, phase, opts = {}) {
94
+ const info = findPhaseInternal(cwd, phase);
95
+ if (!info) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
96
+ const phaseNumber = info.phase_number || String(phase);
97
+ const b = phaseBase(cwd, phaseNumber);
98
+ if (!b.base) return { phase: phaseNumber, checked: false, ok: true, files: [], reason: b.reason };
99
+ const tests = [...b.files].filter((f) => isTestFile(f) && fs.existsSync(path.join(cwd, f))).sort();
100
+ if (!tests.length) return { phase: phaseNumber, checked: false, ok: true, base: b.base, files: [], reason: 'the phase changed no test file' };
101
+ const timeoutMs = opts.timeoutMs || DEFAULT_FILE_TIMEOUT_MS;
102
+ const files = [];
103
+ const wt = withWorktree(cwd, b.base, (dir) => {
104
+ for (const file of tests) {
105
+ const fc = TG.fileCommand(cwd, file);
106
+ if (!fc.command) { files.push({ file, verdict: 'not_checked', detail: fc.reason }); continue; }
107
+ const dest = path.join(dir, file);
108
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
109
+ fs.copyFileSync(path.join(cwd, file), dest);
110
+ const atBase = TG.runCommand(dir, fc.command, timeoutMs);
111
+ const atHead = TG.runCommand(cwd, fc.command, timeoutMs);
112
+ const red = atBase.exit !== 0;
113
+ const green = atHead.exit === 0;
114
+ const verdict = red && green ? 'caught' : (!red ? 'not_red' : 'not_green');
115
+ const detail = verdict === 'not_red'
116
+ ? 'passes without the phase\'s code: it is tautological, or tests something the phase did not change'
117
+ : verdict === 'not_green' ? `fails at HEAD: ${tail(atHead.output)}` : null;
118
+ files.push({ file, verdict, command: fc.command, base_exit: atBase.exit, head_exit: atHead.exit, ...(detail ? { detail } : {}) });
119
+ }
120
+ });
121
+ if (wt.error) return { phase: phaseNumber, checked: false, ok: true, base: b.base, files: [], reason: wt.error };
122
+ const ran = files.filter((f) => f.verdict !== 'not_checked');
123
+ return {
124
+ phase: phaseNumber, checked: ran.length > 0, ok: ran.every((f) => f.verdict === 'caught'), base: b.base,
125
+ caught: ran.filter((f) => f.verdict === 'caught').length, files,
126
+ ...(ran.length ? {} : { reason: 'no changed test file could be run on its own' }),
127
+ };
128
+ }
129
+
130
+ /** `pan-tools verify red <phase> [--timeout <ms>]`: exit 1 when a test is not_red or not_green. */
131
+ function cmdVerifyRed(cwd, phase, raw, opts = {}) {
132
+ if (!phase) error('Usage: verify red <phase> [--timeout <ms>]');
133
+ const r = checkRed(cwd, phase, { timeoutMs: opts.timeout ? Number(opts.timeout) : undefined });
134
+ if (r.error) error(r.reason);
135
+ output(r, raw, !r.checked ? 'not_checked' : (r.ok ? 'caught' : 'not_caught'), r.ok ? 0 : 1);
136
+ }
137
+
138
+ module.exports = { checkRed, cmdVerifyRed, phaseBase, withWorktree };
@@ -17,7 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { safeReadFile, execGit, findPhaseInternal, toPosix, output, escapeRegex } = require('./core.cjs');
20
- const { extractFrontmatter } = require('./frontmatter.cjs');
20
+ const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
21
21
  const { isPlanFile, isSummaryFile, isVerificationFile } = require('./constants.cjs');
22
22
  const { planningRel } = require('./utils.cjs');
23
23
 
@@ -151,6 +151,147 @@ function scopePhase(cwd, phase) {
151
151
  };
152
152
  }
153
153
 
154
+ // ─── Review scope (program R1) ───────────────────────────────────────────────
155
+ // The reviewer used to get the summaries' key-files: what the executor said it touched.
156
+ // A file the executor changed and did not list was never reviewed. The review scope is
157
+ // the diff instead (the phase's plan commits, plus work not yet committed), with the
158
+ // intent the code is judged against: each plan's objective, must_haves and Review Focus,
159
+ // and the text of the requirements the plans claim.
160
+
161
+ /** Files modified, staged or untracked in the working tree, relative to cwd. */
162
+ function workingTreeFiles(cwd) {
163
+ // Porcelain v2 with -z: each entry starts with its type (`1`, `2`, `u`, `?`), never a
164
+ // space, and paths come unquoted. v1 starts an unstaged edit with a space (" M a.js"),
165
+ // which execGit's trim() cut off the first line, and the path lost its first letter.
166
+ const r = execGit(cwd, ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.']);
167
+ if (r.exitCode !== 0) return { git: false, uncommitted: [], untracked: [] };
168
+ const top = execGit(cwd, ['rev-parse', '--show-prefix']);
169
+ const prefix = top.exitCode === 0 ? normalisePath(top.stdout.trim()) : '';
170
+ const rel = (p) => { const n = normalisePath(p); return prefix && n.startsWith(prefix) ? n.slice(prefix.length) : n; };
171
+ const uncommitted = [];
172
+ const untracked = [];
173
+ const entries = r.stdout.split('\0');
174
+ for (let i = 0; i < entries.length; i++) {
175
+ const e = entries[i];
176
+ if (!e) continue;
177
+ const f = e.split(' ');
178
+ if (e[0] === '?') untracked.push(rel(e.slice(2)));
179
+ else if (e[0] === '1') { if (!f[1].includes('D')) uncommitted.push(rel(f.slice(8).join(' '))); }
180
+ else if (e[0] === '2') { if (!f[1].includes('D')) uncommitted.push(rel(f.slice(9).join(' '))); i++; } // the next entry is the old path
181
+ else if (e[0] === 'u') uncommitted.push(rel(f.slice(10).join(' ')));
182
+ }
183
+ return { git: true, uncommitted, untracked };
184
+ }
185
+
186
+ /** A markdown section's body, from its `## <title>` heading to the next heading of the same or higher level. */
187
+ function sectionBody(text, title) {
188
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
189
+ const re = new RegExp(`^(#{1,6})\\s+${escapeRegex(title)}\\s*$`, 'i');
190
+ const at = lines.findIndex((l) => re.test(l.trim()));
191
+ if (at === -1) return null;
192
+ const level = lines[at].trim().match(/^#+/)[0].length;
193
+ const out = [];
194
+ for (let i = at + 1; i < lines.length; i++) {
195
+ const h = lines[i].match(/^(#{1,6})\s/);
196
+ if (h && h[1].length <= level) break;
197
+ out.push(lines[i]);
198
+ }
199
+ const body = out.join('\n').trim();
200
+ return body || null;
201
+ }
202
+
203
+ /** The text between `<tag>` and `</tag>`, trimmed, or null. */
204
+ function tagBody(text, tag) {
205
+ const m = String(text || '').match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
206
+ return m && m[1].trim() ? m[1].trim() : null;
207
+ }
208
+
209
+ /**
210
+ * What a reviewer of one phase reads: the diff and the intent.
211
+ * @returns {Object} `{phase, directory, base, files: [{path, sources, listed}], unlisted, excluded,
212
+ * intent: {plans: [{plan, objective, must_haves, review_focus, requirements}], requirements: [{id, text}], missing_requirements}, git, commits}`
213
+ * or `{error, reason}`. `sources` is any of commit · summary · uncommitted · untracked;
214
+ * `listed` is whether a summary's key-files names it; `unlisted` lists the files that are not.
215
+ */
216
+ function reviewScope(cwd, phase) {
217
+ const info = findPhaseInternal(cwd, phase);
218
+ if (!info) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
219
+ const dir = path.join(cwd, info.directory);
220
+ const sources = new Map();
221
+ const note = (file, source) => {
222
+ if (!file) return;
223
+ if (!sources.has(file)) sources.set(file, new Set());
224
+ sources.get(file).add(source);
225
+ };
226
+ const listed = new Set();
227
+ for (const f of info.summaries.filter(isSummaryFile)) {
228
+ const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
229
+ const kf = fm['key-files'] || fm.key_files || {};
230
+ for (const p of [...asList(kf.created), ...asList(kf.modified)]) { const n = normalisePath(p); if (n) { listed.add(n); note(n, 'summary'); } }
231
+ }
232
+ const git = committedFiles(cwd, info.phase_number);
233
+ for (const p of git.files) note(p, 'commit');
234
+ const wt = workingTreeFiles(cwd);
235
+ for (const p of wt.uncommitted) note(p, 'uncommitted');
236
+ for (const p of wt.untracked) note(p, 'untracked');
237
+
238
+ const planningPrefix = normalisePath(planningRel());
239
+ const files = [];
240
+ const excluded = [];
241
+ for (const file of [...sources.keys()].sort()) {
242
+ const why = exclusionReason(file, planningPrefix);
243
+ if (why) { excluded.push({ path: file, reason: why }); continue; }
244
+ // A file deleted by the phase has nothing left to read.
245
+ if (!fs.existsSync(path.join(cwd, file))) continue;
246
+ files.push({ path: file, sources: [...sources.get(file)].sort(), listed: listed.has(file) });
247
+ }
248
+
249
+ const plans = [];
250
+ const reqIds = new Set();
251
+ for (const f of info.plans.filter(isPlanFile).sort()) {
252
+ const text = safeReadFile(path.join(dir, f)) || '';
253
+ const fm = extractFrontmatter(text);
254
+ const requirements = asList(fm.requirements).map((s) => s.trim()).filter(Boolean);
255
+ for (const id of requirements) reqIds.add(id);
256
+ const mh = fm.must_haves && typeof fm.must_haves === 'object' ? fm.must_haves : {};
257
+ plans.push({
258
+ plan: f,
259
+ objective: tagBody(text, 'objective'),
260
+ must_haves: { truths: asList(mh.truths), artifacts: parseMustHavesBlock(text, 'artifacts'), key_links: parseMustHavesBlock(text, 'key_links') },
261
+ review_focus: sectionBody(text, 'Review Focus'),
262
+ requirements,
263
+ });
264
+ }
265
+ const { requirementLines } = require('./roadmap.cjs');
266
+ const reqText = safeReadFile(path.join(cwd, planningRel(), 'requirements.md')) || '';
267
+ const ids = [...reqIds].sort();
268
+ const requirements = ids.map((id) => ({ id, text: requirementLines(reqText, [id]).lines.join('\n').trim() || null }));
269
+
270
+ let base = null;
271
+ if (git.hashes.length) {
272
+ const r = execGit(cwd, ['rev-parse', '--verify', '-q', `${git.hashes[git.hashes.length - 1]}^`]);
273
+ if (r.exitCode === 0 && r.stdout) base = r.stdout.trim();
274
+ }
275
+ return {
276
+ phase: info.phase_number,
277
+ directory: info.directory,
278
+ base,
279
+ files,
280
+ unlisted: files.filter((f) => !f.listed).map((f) => f.path),
281
+ excluded,
282
+ intent: { plans, requirements, missing_requirements: requirements.filter((r) => !r.text).map((r) => r.id) },
283
+ git: git.git,
284
+ commits: git.commits,
285
+ };
286
+ }
287
+
288
+ /** `pan-tools review scope <phase>`: the JSON scope; `--raw` prints the file paths, one per line. */
289
+ function cmdReviewScope(cwd, phase, raw) {
290
+ if (!phase) { output({ error: 'phase_required', reason: 'Usage: review scope <phase>' }, raw); return; }
291
+ const r = reviewScope(cwd, phase);
292
+ output(r, raw, r.error ? undefined : r.files.map((f) => f.path).join('\n'));
293
+ }
294
+
154
295
  function cmdVerifyScope(cwd, phase, raw) {
155
296
  if (!phase) { output({ error: 'phase_required', reason: 'Usage: verify scope <phase>' }, raw); return; }
156
297
  const r = scopePhase(cwd, phase);
@@ -224,4 +365,4 @@ function cmdVerifyStale(cwd, phase, raw) {
224
365
  output(r, raw, r.error ? undefined : r.state);
225
366
  }
226
367
 
227
- module.exports = { scopePhase, cmdVerifyScope, verificationFreshness, cmdVerifyStale, coveredFiles, committedFiles, LOCKFILES, TEST_PATH_RE, TEST_FILE_RE };
368
+ module.exports = { scopePhase, cmdVerifyScope, reviewScope, cmdReviewScope, workingTreeFiles, sectionBody, verificationFreshness, cmdVerifyStale, coveredFiles, committedFiles, LOCKFILES, TEST_PATH_RE, TEST_FILE_RE };
@@ -572,8 +572,21 @@ function reconcilePhase(cwd, phaseNum) {
572
572
  contradictions.push(`verification status "${status}" but ${keyLinks.total - keyLinks.verified}/${keyLinks.total} key-link(s) UNWIRED`);
573
573
  }
574
574
  }
575
+ // The engine's test run (V2) is the record the verifier copies its test gate from: a
576
+ // verification that says otherwise, or passes over a failed run, contradicts it.
577
+ const testRun = require('./test-gate.cjs').readTestRun(cwd, phaseNum);
578
+ const writtenGate = (verRaw.match(/^test_gate:\s*([A-Za-z_]+)/m) || [])[1] || null;
579
+ if (testRun && testRun.status) {
580
+ const why = testRun.reason ? ` (${testRun.reason})` : '';
581
+ if (writtenGate && writtenGate.toLowerCase() !== testRun.status) {
582
+ contradictions.push(`verification test_gate "${writtenGate}" but the engine's test run recorded "${testRun.status}"${why}`);
583
+ } else if (!writtenGate && claimsPass && testRun.status === 'failed') {
584
+ contradictions.push(`verification status "${status}" but the engine's test run failed${why}`);
585
+ }
586
+ }
575
587
  return {
576
588
  phase: String(phaseNum), found: true, verification_status: status, claims_pass: claimsPass,
589
+ test_run: testRun ? { status: testRun.status, head: testRun.head || null, regressions: (testRun.regressions || []).length } : undefined,
577
590
  mechanical_signals: signals, artifacts, key_links: keyLinks, contradictions,
578
591
  reconciled: contradictions.length === 0,
579
592
  note: signals === 0 ? 'no must_haves declared — mechanical reconciliation unavailable; verdict trusted' : undefined,
@@ -1557,35 +1570,22 @@ function cmdValidateHealth(cwd, options, raw) {
1557
1570
  }
1558
1571
 
1559
1572
  /**
1560
- * Run node --test and capture result.
1573
+ * Run the project's tests through the engine's test gate (V2): any language, the
1574
+ * project's own command, and a crash reads failed. `pass` is null when there was nothing
1575
+ * to run (no test command, or one that ran no tests).
1561
1576
  */
1562
1577
  function runFullTestCheck(cwd) {
1563
- try {
1564
- const result = execFileSync('node', ['--test'], {
1565
- cwd,
1566
- timeout: 120000,
1567
- stdio: ['pipe', 'pipe', 'pipe'],
1568
- encoding: 'utf-8',
1569
- });
1570
- // Match both TAP ("# tests N") and spec-reporter ("ℹ tests N") summaries —
1571
- // modern node --test defaults to the spec reporter, which the old "# "-only
1572
- // regex silently missed (returning tests: null).
1573
- const testMatch = result.match(/[#ℹ]\s*tests\s+(\d+)/);
1574
- const passMatch = result.match(/[#ℹ]\s*pass\s+(\d+)/);
1575
- return {
1576
- pass: true,
1577
- exitCode: 0,
1578
- tests: testMatch ? parseInt(testMatch[1], 10) : null,
1579
- passing: passMatch ? parseInt(passMatch[1], 10) : null,
1580
- };
1581
- } catch (err) {
1582
- return {
1583
- pass: false,
1584
- exitCode: err.status || 1,
1585
- tests: null,
1586
- passing: null,
1587
- };
1588
- }
1578
+ const rec = require('./test-gate.cjs').runTests(cwd, { timeoutMs: 120000 });
1579
+ return {
1580
+ pass: rec.status === 'passed' ? true : (rec.status === 'failed' ? false : null),
1581
+ exitCode: rec.exit,
1582
+ tests: rec.total,
1583
+ passing: rec.passed,
1584
+ status: rec.status,
1585
+ command: rec.command,
1586
+ reason: rec.reason || undefined,
1587
+ failing: rec.failing && rec.failing.length ? rec.failing.slice(0, 20) : undefined,
1588
+ };
1589
1589
  }
1590
1590
 
1591
1591
  /**
@@ -41,7 +41,8 @@
41
41
  * phase next-decimal <phase> Calculate next decimal phase number
42
42
  * phase add <description> Append new phase to roadmap + create dir
43
43
  * phase insert <after> <description> Insert decimal phase after existing
44
- * phase remove <phase> [--force] Remove phase, renumber all subsequent
44
+ * phase remove <phase> [--force] [--with-decimals]
45
+ * Remove phase, renumber all subsequent
45
46
  * phase complete <phase> Mark phase done, update state + roadmap
46
47
  *
47
48
  * Roadmap Operations:
@@ -92,6 +93,26 @@
92
93
  * verify commits <h1> [h2] ... Batch verify commit hashes
93
94
  * verify artifacts <plan-file> Check must_haves.artifacts
94
95
  * verify key-links <plan-file> Check must_haves.key_links
96
+ * verify red <phase> Each test the phase changed fails at the
97
+ * [--timeout <ms>] phase's base and passes at HEAD (V4)
98
+ * verify mutate --phase N Mutation report on the phase's changed lines
99
+ * [--max n] [--seed n] (V5): which mutants the suite does not kill;
100
+ * [--timeout <ms>] writes {NN}-mutation.json
101
+ * verify review-focus <phase> Every plan has a Review Focus: <=5 items,
102
+ * each naming its task and test (P4)
103
+ * verify decisions <phase> Plans cite only DD-N ids the design records,
104
+ * and every recorded one is cited (P2)
105
+ * usage Local census: installed commands and agents
106
+ * used, never used, unfinished (L9)
107
+ * research lint <phase|path> Claims table: source and date on every claim,
108
+ * [--now YYYY-MM-DD] stale ones per type window (P3)
109
+ *
110
+ * Test Gate (V2):
111
+ * test run [--phase N] [--baseline] Run the project's tests, any language; with
112
+ * [--command "<cmd>"] --phase, write {NN}-test-run.json (or the
113
+ * [--timeout <ms>] [--rebaseline] --baseline record) to the phase directory; an
114
+ * existing baseline is kept unless --rebaseline
115
+ * test detect The test command the gate would run, and why
95
116
  *
96
117
  * Template Fill:
97
118
  * template fill summary --phase N Create pre-filled summary.md
@@ -240,6 +261,10 @@ const findings = require('./lib/findings.cjs');
240
261
  const verifyPlanSet = require('./lib/verify-plan-set.cjs');
241
262
  const verifyIntegrity = require('./lib/verify-integrity.cjs');
242
263
  const phaseGate = require('./lib/phase-gate.cjs');
264
+ const testGate = require('./lib/test-gate.cjs');
265
+ const verifyRed = require('./lib/verify-red.cjs');
266
+ const verifyScope = require('./lib/verify-scope.cjs');
267
+ const mutate = require('./lib/mutate.cjs');
243
268
  const knowledge = require('./lib/knowledge.cjs');
244
269
  const skillAlign = require('./lib/skill-align.cjs');
245
270
  const hygiene = require('./lib/hygiene.cjs');
@@ -365,7 +390,7 @@ async function main() {
365
390
  + ' --planning-dir <path> act on an arbitrary project-relative planning tree\n'
366
391
  + ' --all-tracks (hygiene) act on the root tree AND every discovered track\n'
367
392
  + ' env: PAN_TRACK, PAN_PLANNING_DIR (flags win)\n'
368
- + '\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, findings, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links, version';
393
+ + '\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, test, review, research, usage, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, findings, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links, version';
369
394
 
370
395
  if (!command) {
371
396
  error(USAGE);
@@ -630,6 +655,24 @@ async function main() {
630
655
  break;
631
656
  }
632
657
 
658
+ case 'test': {
659
+ const subcommand = args[1];
660
+ if (subcommand === 'run') {
661
+ testGate.cmdTestRun(cwd, {
662
+ phase: getArgValue(args, '--phase'),
663
+ baseline: args.includes('--baseline'),
664
+ rebaseline: args.includes('--rebaseline'),
665
+ command: getArgValue(args, '--command'),
666
+ timeout: getArgValue(args, '--timeout'),
667
+ }, raw);
668
+ } else if (subcommand === 'detect') {
669
+ testGate.cmdTestDetect(cwd, raw);
670
+ } else {
671
+ error('Unknown test subcommand. Available: run, detect');
672
+ }
673
+ break;
674
+ }
675
+
633
676
  case 'verify': {
634
677
  const subcommand = args[1];
635
678
  if (subcommand === 'plan-structure') {
@@ -656,8 +699,21 @@ async function main() {
656
699
  verifyPlanSet.cmdVerifyPlanSet(cwd, args[2], raw);
657
700
  } else if (subcommand === 'test-integrity') {
658
701
  verifyIntegrity.cmdVerifyTestIntegrity(cwd, args[2], raw);
702
+ } else if (subcommand === 'red') {
703
+ verifyRed.cmdVerifyRed(cwd, args[2], raw, { timeout: getArgValue(args, '--timeout') });
704
+ } else if (subcommand === 'review-focus') {
705
+ require('./lib/plan-lint.cjs').cmdVerifyReviewFocus(cwd, args[2], raw);
706
+ } else if (subcommand === 'decisions') {
707
+ require('./lib/plan-lint.cjs').cmdVerifyDecisions(cwd, args[2], raw);
708
+ } else if (subcommand === 'mutate') {
709
+ mutate.cmdVerifyMutate(cwd, {
710
+ phase: getArgValue(args, '--phase') || (args[2] && !args[2].startsWith('--') ? args[2] : null),
711
+ max: getArgValue(args, '--max'),
712
+ seed: getArgValue(args, '--seed'),
713
+ timeout: getArgValue(args, '--timeout'),
714
+ }, raw);
659
715
  } else {
660
- error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links, reconcile, stubs, scope, stale, plan-set, test-integrity');
716
+ error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links, reconcile, stubs, scope, stale, plan-set, test-integrity, red, mutate, review-focus, decisions');
661
717
  }
662
718
  break;
663
719
  }
@@ -769,7 +825,7 @@ async function main() {
769
825
  } else if (subcommand === 'remove') {
770
826
  if (!args[2]) error('phase remove requires a phase number');
771
827
  const forceFlag = args.includes('--force');
772
- phase.cmdPhaseRemove(cwd, args[2], { force: forceFlag }, raw);
828
+ phase.cmdPhaseRemove(cwd, args[2], { force: forceFlag, withDecimals: args.includes('--with-decimals') }, raw);
773
829
  } else if (subcommand === 'complete') {
774
830
  if (!args[2]) error('phase complete requires a phase number');
775
831
  const noCommit = args.includes('--no-commit');
@@ -1257,6 +1313,34 @@ async function main() {
1257
1313
  break;
1258
1314
  }
1259
1315
 
1316
+ case 'review': {
1317
+ // Program R1: the diff a reviewer reads, with the plans' intent beside it.
1318
+ const subcommand = args[1];
1319
+ if (subcommand === 'scope') {
1320
+ verifyScope.cmdReviewScope(cwd, args[2], raw);
1321
+ } else {
1322
+ error('Unknown review subcommand. Available: scope');
1323
+ }
1324
+ break;
1325
+ }
1326
+
1327
+ case 'usage': {
1328
+ // Program L9: which installed commands and agents this project used, from its own files.
1329
+ require('./lib/usage.cjs').cmdUsage(cwd, raw);
1330
+ break;
1331
+ }
1332
+
1333
+ case 'research': {
1334
+ // Program P3: research claims carry a source and a checked date.
1335
+ const subcommand = args[1];
1336
+ if (subcommand === 'lint') {
1337
+ require('./lib/research-lint.cjs').cmdResearchLint(cwd, args[2], { now: getArgValue(args, '--now') }, raw);
1338
+ } else {
1339
+ error('Unknown research subcommand. Available: lint');
1340
+ }
1341
+ break;
1342
+ }
1343
+
1260
1344
  case 'review-deep': {
1261
1345
  const subcommand = args[1];
1262
1346
  const phaseNum = args[2];
@@ -1307,8 +1391,10 @@ async function main() {
1307
1391
  }, raw);
1308
1392
  } else if (subcommand === 'debt') {
1309
1393
  findings.cmdFindingsDebt(cwd, { milestone: filters.milestone }, raw);
1394
+ } else if (subcommand === 'suppressions') {
1395
+ findings.cmdFindingsSuppressions(cwd, { phase: filters.phase, agent: filters.agent }, raw);
1310
1396
  } else {
1311
- error('Unknown findings subcommand. Available: record, list, dispose, debt');
1397
+ error('Unknown findings subcommand. Available: record, list, dispose, debt, suppressions');
1312
1398
  }
1313
1399
  break;
1314
1400
  }
@@ -1359,8 +1445,10 @@ async function main() {
1359
1445
  }, raw);
1360
1446
  } else if (subcommand === 'limits') {
1361
1447
  cost.cmdCostLimits(cwd, raw);
1448
+ } else if (subcommand === 'budget') {
1449
+ cost.cmdCostBudget(cwd, getArgValue(args, '--phase') || (args[2] && !args[2].startsWith('--') ? args[2] : null), raw);
1362
1450
  } else {
1363
- error('Unknown cost subcommand. Available: report, append, clear, rebuild, limits');
1451
+ error('Unknown cost subcommand. Available: report, append, clear, rebuild, limits, budget');
1364
1452
  }
1365
1453
  break;
1366
1454
  }
@@ -101,7 +101,10 @@ function normalizeStatus(status) {
101
101
  function isPhaseOpen(phase) {
102
102
  if (!phase || typeof phase !== 'object') return false;
103
103
  const status = normalizeStatus(phase.status);
104
- if (status !== COMPLETE_STATUS) return true;
104
+ // The legacy `verified` alias stands for complete-and-verified: once merged, that phase
105
+ // is finished too. Without this a caller sending it got request_merge on every cycle,
106
+ // for a phase already merged (found by H7's nextAction fuzz).
107
+ if (status !== COMPLETE_STATUS) return !(PHASE_NEXT[status] === 'request_merge' && phase.merged === true);
105
108
  // Complete on disk, but the run facts decide whether it is actually finished.
106
109
  return !(phase.verified === true && phase.merged === true);
107
110
  }
@@ -52,7 +52,7 @@ ordered; a tier makes a contiguous prefix (plus its named additions) mandatory.
52
52
  - **Components & boundaries** — what modules are added/touched; confirm the design honors the discovered boundaries (no layer violations).
53
53
  - **Interface contract** — exact invocation + input/output schema + status/exit codes, per project convention.
54
54
  - **State & filesystem scope** — reads from / writes to (must stay within project root / `.planning/`), state mutations, side effects.
55
- - **Design decisions** — each significant decision: what was decided, why, alternatives rejected.
55
+ - **Design decisions** — each decision that passes the three-question test (hard to reverse, a reasonable engineer could choose otherwise, more than one task depends on it): its id (`DD-N`, which plans cite), what was decided, why, alternatives rejected.
56
56
 
57
57
  ### 4. ADR (`phase`+)
58
58
  - A right-sized Architecture Decision Record: Status · Date · Context (problem, forces, current state, requirements traceability) · Decision (summary + numbered sub-decisions with rationale + integration) · Consequences (positive **and** negative, every negative with a mitigation or explicit acceptance).
@@ -83,7 +83,7 @@ Lives in `summary.md`, after `## Files Created/Modified`:
83
83
  - DT-1: Chose <option> for O-N. Reason: <rationale>.
84
84
 
85
85
  ### Deviations (from plan; must explain)
86
- - DV-1: Plan said <X>; I did <Y>. Reason: <rationale>. Verification: <how I confirmed Y is acceptable>.
86
+ - DV-1: Plan said <X>; I did <Y>. Reason: <rationale>. Verification: <how I confirmed Y is acceptable>. Cost if wrong: <low | medium | high> — <what breaks if this call was wrong>.
87
87
 
88
88
  ### Open questions for verifier
89
89
  - Q-1: <question>. Why it matters: <stake>.
@@ -8,9 +8,13 @@
8
8
  "auto_advance": false,
9
9
  "nyquist_validation": false,
10
10
  "phase_record_compact": false,
11
- "phase_gate": "warn",
11
+ "phase_gate": "enforce",
12
+ "mutation_gate": "report",
12
13
  "phase_reports": { "enabled": false, "open": false, "theme": "auto", "index": true }
13
14
  },
15
+ "cost": {
16
+ "phase_budget_usd": 20
17
+ },
14
18
  "planning": {
15
19
  "commit_docs": true,
16
20
  "search_gitignored": false
@@ -66,10 +66,11 @@ tier may omit them.
66
66
  - Writes to: [paths — within .planning/ or project root]
67
67
  - Side effects: [git ops, dir creation, etc.]
68
68
 
69
- **Design decisions:**
70
- | Decision | Chosen | Alternative rejected | Rationale |
71
- |----------|--------|----------------------|-----------|
72
- | [point] | [what] | [alt] | [why] |
69
+ **Design decisions:** record a decision here only when it passes all three questions: (1) is it hard to reverse once code depends on it? (2) could a reasonable engineer have chosen otherwise? (3) does more than one task or plan depend on it? A choice that fails any of them is the planner's to make, not a design decision. Each recorded decision gets an id (`DD-1`, `DD-2` …) that the plans implementing it cite; `pan-tools verify decisions` flags a plan citing an id not recorded here and a decision no plan cites.
70
+
71
+ | ID | Decision | Chosen | Alternative rejected | Rationale |
72
+ |----|----------|--------|----------------------|-----------|
73
+ | DD-1 | [point] | [what] | [alt] | [why] |
73
74
  </architecture>
74
75
 
75
76
  <adr>
@@ -71,6 +71,13 @@ Output: [What artifacts will be created]
71
71
  ### Considered and rejected
72
72
  - R-1: [alternative]. Rejected because: [reason].
73
73
 
74
+ <!-- A Locked decision that implements a design decision cites its id: "Source: design DD-2". -->
75
+
76
+ ## Review Focus
77
+ <!-- At most five inputs or failure modes the requirements imply that no test exercises yet,
78
+ each pinned to the task that owns the code. If genuinely none: "None — [why]". -->
79
+ - RF-1: [input or failure mode]. Test: Task [N] adds [test that fails if it is mishandled].
80
+
74
81
  <tasks>
75
82
 
76
83
  <task type="auto">
@@ -161,6 +161,12 @@ src/
161
161
  |----------|---------------|-------|
162
162
  | [module A ↔ module B] | [API/events/direct] | [considerations] |
163
163
 
164
+ ## Claims
165
+
166
+ | Claim | Type | Source | Checked |
167
+ |-------|------|--------|---------|
168
+ | [what is true] | [version / api / pricing / security / practice / general] | [URL, doc section or Context7 id] | [YYYY-MM-DD] |
169
+
164
170
  ## Sources
165
171
 
166
172
  - [Architecture references]
@@ -104,6 +104,12 @@ Features to defer until product-market fit is established.
104
104
  | [feature] | [how they do it] | [how they do it] | [our plan] |
105
105
  | [feature] | [how they do it] | [how they do it] | [our plan] |
106
106
 
107
+ ## Claims
108
+
109
+ | Claim | Type | Source | Checked |
110
+ |-------|------|--------|---------|
111
+ | [what is true] | [version / api / pricing / security / practice / general] | [URL, doc section or Context7 id] | [YYYY-MM-DD] |
112
+
107
113
  ## Sources
108
114
 
109
115
  - [Competitor products analyzed]