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.
- package/README.md +16 -10
- package/agents/pan-conductor.md +2 -2
- package/agents/pan-debugger.md +3 -1
- package/agents/pan-design-checker.md +1 -0
- package/agents/pan-designer.md +1 -1
- package/agents/pan-executor.md +43 -1
- package/agents/pan-experiment-runner.md +1 -1
- package/agents/pan-hardener.md +3 -1
- package/agents/pan-meta-reviewer.md +1 -1
- package/agents/pan-phase-researcher.md +16 -2
- package/agents/pan-plan-checker.md +37 -9
- package/agents/pan-planner.md +14 -2
- package/agents/pan-previewer.md +2 -0
- package/agents/pan-project-researcher.md +9 -1
- package/agents/pan-research-synthesizer.md +1 -1
- package/agents/pan-reviewer.md +8 -2
- package/agents/pan-verifier.md +3 -3
- package/bin/install-lib.cjs +119 -16
- package/bin/install.js +34 -62
- package/commands/pan/cost.md +21 -5
- package/commands/pan/dashboard.md +0 -3
- package/commands/pan/design-phase.md +2 -2
- package/commands/pan/hud.md +1 -5
- package/commands/pan/knowledge.md +0 -1
- package/commands/pan/mcp-bridge.md +1 -3
- package/commands/pan/optimize.md +2 -0
- package/commands/pan/phase-tests.md +1 -1
- package/commands/pan/preview.md +2 -3
- package/commands/pan/report.md +1 -5
- package/commands/pan/research-phase.md +4 -4
- package/commands/pan/review-deep.md +0 -2
- package/commands/pan/what-if.md +1 -2
- package/hooks/dist/pan-context-monitor.js +12 -3
- package/hooks/dist/pan-guard.js +244 -0
- package/hooks/dist/pan-statusline.js +5 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +24 -2
- package/pan-wizard-core/bin/lib/config.cjs +13 -1
- package/pan-wizard-core/bin/lib/core.cjs +9 -4
- package/pan-wizard-core/bin/lib/cost.cjs +146 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
- package/pan-wizard-core/bin/lib/findings.cjs +239 -23
- package/pan-wizard-core/bin/lib/git.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
- package/pan-wizard-core/bin/lib/init.cjs +6 -0
- package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
- package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
- package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
- package/pan-wizard-core/bin/lib/phase.cjs +61 -9
- package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
- package/pan-wizard-core/bin/lib/preview.cjs +35 -31
- package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
- package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
- package/pan-wizard-core/bin/lib/usage.cjs +104 -0
- package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
- package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
- package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
- package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
- package/pan-wizard-core/bin/lib/verify.cjs +27 -27
- package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
- package/pan-wizard-core/bin/pan-tools.cjs +106 -9
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
- package/pan-wizard-core/opencode/pan-wizard.js +36 -8
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/handoff-decisions.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +1 -1
- package/pan-wizard-core/references/planning-config.md +3 -3
- package/pan-wizard-core/references/verdict-contract.md +6 -1
- package/pan-wizard-core/templates/config.json +6 -6
- package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
- package/pan-wizard-core/templates/design.md +5 -4
- package/pan-wizard-core/templates/phase-prompt.md +9 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
- package/pan-wizard-core/templates/preview-report.md +1 -1
- package/pan-wizard-core/templates/research-project/architecture.md +6 -0
- package/pan-wizard-core/templates/research-project/features.md +6 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
- package/pan-wizard-core/templates/research-project/stack.md +6 -0
- package/pan-wizard-core/templates/research.md +10 -0
- package/pan-wizard-core/templates/state.md +4 -4
- package/pan-wizard-core/templates/summary-complex.md +5 -1
- package/pan-wizard-core/templates/summary-minimal.md +4 -0
- package/pan-wizard-core/templates/summary-standard.md +5 -1
- package/pan-wizard-core/templates/summary.md +8 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
- package/pan-wizard-core/workflows/discuss-phase.md +2 -1
- package/pan-wizard-core/workflows/exec-phase.md +92 -45
- package/pan-wizard-core/workflows/execute-plan.md +3 -1
- package/pan-wizard-core/workflows/help.md +103 -44
- package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
- package/pan-wizard-core/workflows/new-project.md +8 -16
- package/pan-wizard-core/workflows/plan-phase.md +40 -8
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/quick.md +2 -2
- package/pan-wizard-core/workflows/remove-phase.md +11 -1
- package/pan-wizard-core/workflows/settings.md +17 -0
- package/pan-wizard-core/workflows/todo-check.md +3 -2
- package/pan-wizard-core/workflows/transition.md +4 -0
- package/pan-wizard-core/workflows/verify-phase.md +40 -47
- package/scripts/build-help.cjs +106 -0
- package/scripts/build-hooks.js +1 -0
- package/scripts/generate-skills-docs.py +2 -2
- package/scripts/mutation-probe.cjs +7 -80
- package/scripts/prompt-lint.cjs +167 -0
- 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
|
|
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
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|