pan-wizard 3.34.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 (110) hide show
  1. package/README.md +16 -10
  2. package/agents/pan-conductor.md +2 -2
  3. package/agents/pan-debugger.md +3 -1
  4. package/agents/pan-design-checker.md +1 -0
  5. package/agents/pan-designer.md +1 -1
  6. package/agents/pan-executor.md +43 -1
  7. package/agents/pan-experiment-runner.md +1 -1
  8. package/agents/pan-hardener.md +3 -1
  9. package/agents/pan-meta-reviewer.md +1 -1
  10. package/agents/pan-phase-researcher.md +16 -2
  11. package/agents/pan-plan-checker.md +37 -9
  12. package/agents/pan-planner.md +14 -2
  13. package/agents/pan-previewer.md +2 -0
  14. package/agents/pan-project-researcher.md +9 -1
  15. package/agents/pan-research-synthesizer.md +1 -1
  16. package/agents/pan-reviewer.md +8 -2
  17. package/agents/pan-verifier.md +3 -3
  18. package/bin/install-lib.cjs +119 -16
  19. package/bin/install.js +34 -62
  20. package/commands/pan/cost.md +21 -5
  21. package/commands/pan/dashboard.md +0 -3
  22. package/commands/pan/design-phase.md +2 -2
  23. package/commands/pan/hud.md +1 -5
  24. package/commands/pan/knowledge.md +0 -1
  25. package/commands/pan/mcp-bridge.md +1 -3
  26. package/commands/pan/optimize.md +2 -0
  27. package/commands/pan/phase-tests.md +1 -1
  28. package/commands/pan/preview.md +2 -3
  29. package/commands/pan/report.md +1 -5
  30. package/commands/pan/research-phase.md +4 -4
  31. package/commands/pan/review-deep.md +0 -2
  32. package/commands/pan/what-if.md +1 -2
  33. package/hooks/dist/pan-context-monitor.js +12 -3
  34. package/hooks/dist/pan-guard.js +244 -0
  35. package/hooks/dist/pan-statusline.js +5 -2
  36. package/package.json +1 -1
  37. package/pan-wizard-core/bin/lib/commands.cjs +24 -2
  38. package/pan-wizard-core/bin/lib/config.cjs +13 -1
  39. package/pan-wizard-core/bin/lib/core.cjs +9 -4
  40. package/pan-wizard-core/bin/lib/cost.cjs +146 -0
  41. package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
  42. package/pan-wizard-core/bin/lib/findings.cjs +239 -23
  43. package/pan-wizard-core/bin/lib/git.cjs +1 -1
  44. package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
  45. package/pan-wizard-core/bin/lib/init.cjs +6 -0
  46. package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
  47. package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
  48. package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
  49. package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
  50. package/pan-wizard-core/bin/lib/phase.cjs +61 -9
  51. package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
  52. package/pan-wizard-core/bin/lib/preview.cjs +35 -31
  53. package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
  54. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
  55. package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
  56. package/pan-wizard-core/bin/lib/usage.cjs +104 -0
  57. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  58. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  59. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  60. package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
  61. package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
  62. package/pan-wizard-core/bin/lib/verify.cjs +27 -27
  63. package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
  64. package/pan-wizard-core/bin/pan-tools.cjs +106 -9
  65. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  66. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  67. package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
  68. package/pan-wizard-core/opencode/pan-wizard.js +36 -8
  69. package/pan-wizard-core/references/design-methodology.md +1 -1
  70. package/pan-wizard-core/references/handoff-decisions.md +1 -1
  71. package/pan-wizard-core/references/model-profiles.md +1 -1
  72. package/pan-wizard-core/references/planning-config.md +3 -3
  73. package/pan-wizard-core/references/verdict-contract.md +6 -1
  74. package/pan-wizard-core/templates/config.json +6 -6
  75. package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
  76. package/pan-wizard-core/templates/design.md +5 -4
  77. package/pan-wizard-core/templates/phase-prompt.md +9 -0
  78. package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
  79. package/pan-wizard-core/templates/preview-report.md +1 -1
  80. package/pan-wizard-core/templates/research-project/architecture.md +6 -0
  81. package/pan-wizard-core/templates/research-project/features.md +6 -0
  82. package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
  83. package/pan-wizard-core/templates/research-project/stack.md +6 -0
  84. package/pan-wizard-core/templates/research.md +10 -0
  85. package/pan-wizard-core/templates/state.md +4 -4
  86. package/pan-wizard-core/templates/summary-complex.md +5 -1
  87. package/pan-wizard-core/templates/summary-minimal.md +4 -0
  88. package/pan-wizard-core/templates/summary-standard.md +5 -1
  89. package/pan-wizard-core/templates/summary.md +8 -1
  90. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  91. package/pan-wizard-core/workflows/discuss-phase.md +2 -1
  92. package/pan-wizard-core/workflows/exec-phase.md +92 -45
  93. package/pan-wizard-core/workflows/execute-plan.md +3 -1
  94. package/pan-wizard-core/workflows/help.md +103 -44
  95. package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
  96. package/pan-wizard-core/workflows/new-project.md +8 -16
  97. package/pan-wizard-core/workflows/plan-phase.md +40 -8
  98. package/pan-wizard-core/workflows/progress.md +1 -1
  99. package/pan-wizard-core/workflows/quick.md +2 -2
  100. package/pan-wizard-core/workflows/remove-phase.md +11 -1
  101. package/pan-wizard-core/workflows/settings.md +17 -0
  102. package/pan-wizard-core/workflows/todo-check.md +3 -2
  103. package/pan-wizard-core/workflows/transition.md +4 -0
  104. package/pan-wizard-core/workflows/verify-phase.md +40 -47
  105. package/scripts/build-help.cjs +106 -0
  106. package/scripts/build-hooks.js +1 -0
  107. package/scripts/generate-skills-docs.py +2 -2
  108. package/scripts/mutation-probe.cjs +7 -80
  109. package/scripts/prompt-lint.cjs +167 -0
  110. package/scripts/test-surface.cjs +169 -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
 
@@ -68,27 +68,28 @@ function exclusionReason(file, planningPrefix) {
68
68
 
69
69
  /**
70
70
  * Files the phase's plan commits touched, paths relative to `cwd` (git's --relative).
71
- * @returns {{git: boolean, commits: number, files: Set<string>}}
71
+ * @returns {{git: boolean, commits: number, files: Set<string>, hashes: string[]}} hashes newest first
72
72
  */
73
73
  function committedFiles(cwd, phaseNumber) {
74
74
  const r = execGit(cwd, ['-c', 'core.quotepath=off', 'log', '--no-merges', '-n', String(COMMIT_SCAN_LIMIT), '--relative', '--name-only', '--format=@@%H%x09%s']);
75
- if (r.exitCode !== 0) return { git: false, commits: 0, files: new Set() };
75
+ if (r.exitCode !== 0) return { git: false, commits: 0, files: new Set(), hashes: [] };
76
76
  // `{type}({phase}-{plan}):` with the phase's zero padding optional ("feat(03-01):", "fix(3-02)!:").
77
77
  const unpadded = String(phaseNumber).replace(/^0+(?=\d)/, '');
78
78
  const subjectRe = new RegExp(`^[a-z]+\\(0*${escapeRegex(unpadded)}-[0-9]+\\)!?:`);
79
79
  const files = new Set();
80
+ const hashes = [];
80
81
  let commits = 0;
81
82
  let inPhase = false;
82
83
  for (const line of r.stdout.split('\n')) {
83
84
  if (line.startsWith('@@')) {
84
85
  const subject = line.slice(2).split('\t').slice(1).join('\t');
85
86
  inPhase = subjectRe.test(subject);
86
- if (inPhase) commits++;
87
+ if (inPhase) { commits++; hashes.push(line.slice(2).split('\t')[0]); }
87
88
  continue;
88
89
  }
89
90
  if (inPhase && line.trim()) files.add(normalisePath(line));
90
91
  }
91
- return { git: true, commits, files };
92
+ return { git: true, commits, files, hashes };
92
93
  }
93
94
 
94
95
  /**
@@ -150,6 +151,147 @@ function scopePhase(cwd, phase) {
150
151
  };
151
152
  }
152
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
+
153
295
  function cmdVerifyScope(cwd, phase, raw) {
154
296
  if (!phase) { output({ error: 'phase_required', reason: 'Usage: verify scope <phase>' }, raw); return; }
155
297
  const r = scopePhase(cwd, phase);
@@ -223,4 +365,4 @@ function cmdVerifyStale(cwd, phase, raw) {
223
365
  output(r, raw, r.error ? undefined : r.state);
224
366
  }
225
367
 
226
- module.exports = { scopePhase, cmdVerifyScope, verificationFreshness, cmdVerifyStale, coveredFiles, LOCKFILES };
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
  /**
@@ -210,10 +210,38 @@ function createWorktree(cwd, phaseNum, scenario, opts) {
210
210
  };
211
211
  }
212
212
 
213
+ /** Registered worktrees as {path, branch}; branch is null on a detached HEAD. */
214
+ function listWorktrees(cwd) {
215
+ const r = execGit(cwd, ['worktree', 'list', '--porcelain']);
216
+ if (r.exitCode !== 0) return [];
217
+ const list = [];
218
+ for (const line of r.stdout.split(/\r?\n/)) {
219
+ if (line.startsWith('worktree ')) list.push({ path: line.slice('worktree '.length), branch: null });
220
+ else if (line.startsWith('branch ') && list.length) list[list.length - 1].branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
221
+ }
222
+ return list;
223
+ }
224
+
225
+ function samePath(a, b) {
226
+ const norm = (p) => {
227
+ let r = path.resolve(p);
228
+ try { r = fs.realpathSync.native(r); } catch { /* gone: compare the resolved form */ }
229
+ r = toPosix(r);
230
+ return process.platform === 'win32' ? r.toLowerCase() : r;
231
+ };
232
+ return norm(a) === norm(b);
233
+ }
234
+
213
235
  /**
214
236
  * Remove a worktree + its branch. Best-effort: errors are surfaced but
215
237
  * don't block subsequent cleanups.
216
238
  *
239
+ * Only what-if's own objects are touched: the branch must carry BRANCH_PREFIX and the
240
+ * worktree, when git still lists it, must be checked out on that branch (or, with no
241
+ * branch given, on some BRANCH_PREFIX branch). Anything else is refused before a single
242
+ * git write, because `branch -D` and `worktree remove --force` destroy unmerged commits
243
+ * and uncommitted files.
244
+ *
217
245
  * @param {string} cwd - Main project root (for the cleanup git call)
218
246
  * @param {string} worktreePath - Path returned by createWorktree
219
247
  * @param {string} branch - Branch name returned by createWorktree
@@ -224,15 +252,29 @@ function cleanupWorktree(cwd, worktreePath, branch, opts) {
224
252
  if (!isGitRepo(cwd)) {
225
253
  return { error: 'Not a git repo' };
226
254
  }
255
+ if (branch && !branch.startsWith(BRANCH_PREFIX)) {
256
+ return { error: `Refusing to delete branch '${branch}': what-if only cleans up branches it created (${BRANCH_PREFIX}*)` };
257
+ }
258
+ const entry = listWorktrees(cwd).find(w => samePath(w.path, worktreePath));
259
+ if (entry) {
260
+ const owned = entry.branch && entry.branch.startsWith(BRANCH_PREFIX) && (!branch || entry.branch === branch);
261
+ if (!owned) {
262
+ return { error: `Refusing to remove worktree ${toPosix(worktreePath)}: it is on '${entry.branch || 'a detached HEAD'}', not ${branch || BRANCH_PREFIX + '*'}` };
263
+ }
264
+ }
227
265
  const warnings = [];
228
266
  const force = opts?.force === true;
229
267
 
230
- const rmArgs = ['worktree', 'remove'];
231
- if (force) rmArgs.push('--force');
232
- rmArgs.push(worktreePath);
233
- const removeResult = execGit(cwd, rmArgs);
234
- if (removeResult.exitCode !== 0) {
235
- warnings.push(`worktree remove: ${removeResult.stderr}`);
268
+ if (entry) {
269
+ const rmArgs = ['worktree', 'remove'];
270
+ if (force) rmArgs.push('--force');
271
+ rmArgs.push(worktreePath);
272
+ const removeResult = execGit(cwd, rmArgs);
273
+ if (removeResult.exitCode !== 0) {
274
+ warnings.push(`worktree remove: ${removeResult.stderr}`);
275
+ }
276
+ } else {
277
+ warnings.push(`worktree remove: ${toPosix(worktreePath)} is not a registered worktree (already removed?)`);
236
278
  }
237
279
 
238
280
  // Branch cleanup — only if branch still exists.