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,433 @@
1
+ 'use strict';
2
+ /**
3
+ * The engine-run test gate (program V2): the test suite is run by pan-tools, in any
4
+ * language, and its result is a record on disk, not a sentence a model wrote.
5
+ *
6
+ * Until now the verifier ran `npm test` itself, read the counts with grep and wrote
7
+ * `test_gate:` into its verification. That only worked for Node's own runner, and the
8
+ * result was the model's word. Here:
9
+ *
10
+ * command `workflow.test_command` from config.json, else detected: package.json's
11
+ * `test` script, pytest, `go test ./...`, cargo, Maven or Gradle, `make test`
12
+ * run `pan-tools test run --phase N [--baseline]` runs it and writes
13
+ * `{NN}-test-baseline.json` (before execution) or `{NN}-test-run.json`
14
+ * parse the runner's own output, by its shape: Node's runner (spec, TAP, JUnit),
15
+ * jest, pytest, go, cargo, and JUnit XML reports (Maven, Gradle, any runner
16
+ * that writes them), into counts and the ids of the failing tests
17
+ * classify passed · failed · skipped. A suite that exits non-zero and reports no
18
+ * failing test did not run, and that is `failed`, never `skipped`. With a
19
+ * baseline, a failure the baseline already had is pre-existing, not a
20
+ * regression; any new one fails the gate. `skipped` means there was nothing
21
+ * to run (no command, or a command that ran no tests)
22
+ *
23
+ * The verifier copies `test_gate` from the record, reconcile reports a verification
24
+ * that disagrees with it, and the verdict recorder takes a failed run as a code fact
25
+ * that lowers a pass (V9's two-key verdict).
26
+ */
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { spawnSync } = require('child_process');
30
+ const { findPhaseInternal, execGit, output, error, toPosix, safeReadFile } = require('./core.cjs');
31
+ const { planningPath } = require('./utils.cjs');
32
+
33
+ const RECORD_VERSION = 1;
34
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
35
+ const OUTPUT_TAIL = 4000;
36
+ const MAX_IDS = 200;
37
+ // npm init's placeholder script: a test script that is not a test suite.
38
+ const NPM_PLACEHOLDER_RE = /no test specified/i;
39
+
40
+ const stripAnsi = (s) => String(s || '').replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
41
+ const toLf = (s) => String(s || '').replace(/\r\n?/g, '\n');
42
+ // A file in a test id, relative to the project and with forward slashes, whatever OS
43
+ // wrote the output and whatever OS reads it: ids must match between a baseline and a run.
44
+ const posixRel = (cwd, file) => {
45
+ const f = String(file || '').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
46
+ if (!f) return '';
47
+ const root = cwd ? String(cwd).replace(/\\/g, '/').replace(/\/+$/, '') : '';
48
+ if (root && f.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return f.slice(root.length + 1);
49
+ return f.replace(/^\.\//, '');
50
+ };
51
+ const xmlText = (s) => String(s || '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&#10;/g, '\n').replace(/&amp;/g, '&');
52
+ const num = (m) => (m ? Number(m) : 0);
53
+
54
+ // ─── Detection ───────────────────────────────────────────────────────────────
55
+
56
+ /** config.json as written (loadConfig flattens the workflow section away). */
57
+ function rawConfig(cwd) {
58
+ try { return JSON.parse(fs.readFileSync(planningPath(cwd, 'config.json'), 'utf8')); } catch { return {}; }
59
+ }
60
+
61
+ /**
62
+ * The project's test command.
63
+ * @returns {{command: string|null, source: string|null, reason?: string}}
64
+ */
65
+ function detectTestCommand(cwd, config = rawConfig(cwd)) {
66
+ const configured = config && config.workflow && typeof config.workflow.test_command === 'string' ? config.workflow.test_command.trim() : '';
67
+ if (configured) return { command: configured, source: 'config' };
68
+ const has = (f) => fs.existsSync(path.join(cwd, f));
69
+ const read = (f) => safeReadFile(path.join(cwd, f)) || '';
70
+ if (has('package.json')) {
71
+ let pkg = null;
72
+ try { pkg = JSON.parse(read('package.json')); } catch { /* unreadable reads as absent */ }
73
+ const script = pkg && pkg.scripts && typeof pkg.scripts.test === 'string' ? pkg.scripts.test : '';
74
+ if (script && !NPM_PLACEHOLDER_RE.test(script)) return { command: 'npm test', source: 'package.json' };
75
+ }
76
+ if (has('pytest.ini') || has('conftest.py') || /\[tool\.pytest/.test(read('pyproject.toml')) || /\[tool:pytest\]/.test(read('setup.cfg')) || /\[pytest\]/.test(read('tox.ini'))) {
77
+ return { command: 'python -m pytest', source: 'pytest' };
78
+ }
79
+ if (has('go.mod')) return { command: 'go test ./...', source: 'go.mod' };
80
+ if (has('Cargo.toml')) return { command: 'cargo test', source: 'Cargo.toml' };
81
+ if (has('pom.xml')) return { command: 'mvn -q test', source: 'pom.xml' };
82
+ if (has('build.gradle') || has('build.gradle.kts')) return { command: has('gradlew') ? './gradlew test' : 'gradle test', source: 'gradle' };
83
+ if (/^test\s*:/m.test(read('Makefile'))) return { command: 'make test', source: 'Makefile' };
84
+ return { command: null, source: null, reason: 'no test command: set workflow.test_command, or add a test script (package.json, pytest, go.mod, Cargo.toml, Maven, Gradle, Makefile)' };
85
+ }
86
+
87
+ /**
88
+ * The command that runs one test file (V4, V5), or null with a reason. A configured
89
+ * `workflow.test_file_command` with a `{file}` placeholder wins; otherwise it is derived
90
+ * from the suite's runner: node's runner, jest, vitest, mocha, pytest, or go (the file's
91
+ * package). Cargo, Maven, Gradle and make cannot target one file this way.
92
+ */
93
+ function fileCommand(cwd, file, config = rawConfig(cwd)) {
94
+ const quoted = JSON.stringify(String(file).replace(/\\/g, '/'));
95
+ const configured = config && config.workflow && typeof config.workflow.test_file_command === 'string' ? config.workflow.test_file_command.trim() : '';
96
+ if (configured) return configured.includes('{file}') ? { command: configured.split('{file}').join(quoted) } : { command: null, reason: 'workflow.test_file_command has no {file} placeholder' };
97
+ const suite = detectTestCommand(cwd, config);
98
+ let script = suite.command || '';
99
+ if (script === 'npm test') {
100
+ try { script = JSON.parse(safeReadFile(path.join(cwd, 'package.json')) || '{}').scripts.test || ''; } catch { script = ''; }
101
+ }
102
+ if (/\bnode\b[^&|;]*--test\b/.test(script)) return { command: `node --test ${quoted}` };
103
+ if (/\bjest\b/.test(script)) return { command: `npx --no-install jest ${quoted}` };
104
+ if (/\bvitest\b/.test(script)) return { command: `npx --no-install vitest run ${quoted}` };
105
+ if (/\bmocha\b/.test(script)) return { command: `npx --no-install mocha ${quoted}` };
106
+ if (/pytest/.test(script)) return { command: `python -m pytest ${quoted}` };
107
+ if (/^go test\b/.test(script)) return { command: `go test ./${path.posix.dirname(String(file).replace(/\\/g, '/'))}` };
108
+ return { command: null, reason: suite.command ? `no per-file command for "${suite.command}": set workflow.test_file_command with a {file} placeholder` : (suite.reason || 'no test command') };
109
+ }
110
+
111
+ /**
112
+ * Run a shell command with the gate's environment; `{exit, timed_out, output}`.
113
+ * NODE_TEST_CONTEXT is how node's runner marks its own children: a `node --test` that
114
+ * inherits it skips every file and exits 0, so a gate run from inside a test process
115
+ * would pass a suite that never ran.
116
+ */
117
+ function runCommand(cwd, command, timeoutMs = DEFAULT_TIMEOUT_MS) {
118
+ const env = { ...process.env, CI: process.env.CI || '1', FORCE_COLOR: '0', NO_COLOR: '1' };
119
+ delete env.NODE_TEST_CONTEXT;
120
+ const r = spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env });
121
+ const timedOut = !!(r.error && r.error.code === 'ETIMEDOUT');
122
+ return { exit: typeof r.status === 'number' ? r.status : (timedOut ? null : 1), timed_out: timedOut, output: `${r.stdout || ''}\n${r.stderr || ''}` };
123
+ }
124
+
125
+ // ─── Parsers (each tested on real emitted output: tests/fixtures/test-runs/) ─
126
+
127
+ function result(runner, counts, failing, extra = {}) {
128
+ const ids = [...new Set(failing)].slice(0, MAX_IDS);
129
+ return { runner, parsed: true, total: counts.total ?? null, passed: counts.passed ?? null, failed: counts.failed ?? null, skipped: counts.skipped ?? null, failing: ids, ...extra };
130
+ }
131
+
132
+ /** Node's built-in runner, spec reporter. */
133
+ function parseNodeSpec(text, cwd) {
134
+ const count = (k) => { const m = text.match(new RegExp(`^ℹ ${k} (\\d+)`, 'm')); return m ? Number(m[1]) : null; };
135
+ const failing = [];
136
+ const at = text.indexOf('✖ failing tests:');
137
+ if (at !== -1) {
138
+ let file = '';
139
+ for (const line of text.slice(at).split('\n').slice(1)) {
140
+ const loc = line.match(/^test at (.+?):\d+:\d+$/);
141
+ if (loc) { file = posixRel(cwd, loc[1]); continue; }
142
+ const name = line.match(/^✖ (.+?) \([\d.]+m?s\)$/);
143
+ if (name) failing.push(file ? `${file} > ${name[1]}` : name[1]);
144
+ }
145
+ }
146
+ return result('node', { total: count('tests'), passed: count('pass'), failed: (count('fail') || 0) + (count('cancelled') || 0), skipped: (count('skipped') || 0) + (count('todo') || 0) }, failing);
147
+ }
148
+
149
+ /** Node's built-in runner, TAP reporter (and any TAP 13 producer with node's summary). */
150
+ function parseTap(text, cwd) {
151
+ const count = (k) => { const m = text.match(new RegExp(`^# ${k} (\\d+)`, 'm')); return m ? Number(m[1]) : null; };
152
+ const lines = text.split('\n');
153
+ const failing = [];
154
+ for (let i = 0; i < lines.length; i++) {
155
+ const m = lines[i].match(/^(\s*)not ok \d+ - (.+?)(?: # .*)?$/);
156
+ if (!m) continue;
157
+ // A suite is reported `not ok` when a child fails; only leaf tests are failures.
158
+ let type = 'test';
159
+ let file = '';
160
+ for (let j = i + 1; j < lines.length && !/^\s*\.\.\.\s*$/.test(lines[j]); j++) {
161
+ const t = lines[j].match(/^\s*type: '(\w+)'/);
162
+ if (t) type = t[1];
163
+ const loc = lines[j].match(/^\s*location: '(.+?):\d+:\d+'/);
164
+ if (loc) file = posixRel(cwd, loc[1].replace(/\\\\/g, '\\'));
165
+ if (/^\s*(not )?ok \d+/.test(lines[j])) break;
166
+ }
167
+ if (type === 'suite') continue;
168
+ failing.push(file ? `${file} > ${m[2]}` : m[2]);
169
+ }
170
+ return result('node-tap', { total: count('tests'), passed: count('pass'), failed: (count('fail') || 0) + (count('cancelled') || 0), skipped: (count('skipped') || 0) + (count('todo') || 0) }, failing);
171
+ }
172
+
173
+ /** jest (Windows and POSIX glyphs; ANSI already stripped). */
174
+ function parseJest(text) {
175
+ const testsLine = (text.match(/^Tests:\s+(.+)$/m) || [])[1] || '';
176
+ const pick = (line, k) => { const m = line.match(new RegExp(`(\\d+) ${k}`)); return m ? Number(m[1]) : 0; };
177
+ const failing = [];
178
+ let file = '';
179
+ let crashes = 0;
180
+ for (const line of text.split('\n')) {
181
+ const f = line.match(/^(?:FAIL|PASS)\s+(\S+)/);
182
+ if (f) { file = toPosix(f[1]); continue; }
183
+ const b = line.match(/^\s+● (.+)$/);
184
+ if (!b) continue;
185
+ if (/^Test suite failed to run$/.test(b[1].trim())) { crashes++; failing.push(`${file} (suite failed to run)`); continue; }
186
+ failing.push(`${file} > ${b[1].trim()}`);
187
+ }
188
+ // A suite that failed to run counts as one failure: its tests never reached the total.
189
+ return result('jest', { total: pick(testsLine, 'total'), passed: pick(testsLine, 'passed'), failed: pick(testsLine, 'failed') + crashes, skipped: pick(testsLine, 'skipped') + pick(testsLine, 'todo') }, failing, crashes ? { crashed: true } : {});
190
+ }
191
+
192
+ /** pytest. */
193
+ function parsePytest(text) {
194
+ const summary = [...text.matchAll(/^=+ (.+?) in [\d.]+s(?: \([^)]*\))? =+$/gm)].pop();
195
+ const s = summary ? summary[1] : '';
196
+ const pick = (k) => { const m = s.match(new RegExp(`(\\d+) ${k}\\b`)); return m ? Number(m[1]) : 0; };
197
+ const failed = pick('failed');
198
+ const errors = pick('errors') + pick('error');
199
+ const passed = pick('passed');
200
+ const skipped = pick('skipped') + pick('xfailed') + pick('deselected');
201
+ const failing = [...text.matchAll(/^(?:FAILED|ERROR) (\S+)/gm)].map((m) => m[1]);
202
+ const crashed = /error(s)? during collection/.test(text) || (errors > 0 && failed === 0 && passed === 0);
203
+ return result('pytest', { total: passed + failed + errors + skipped + pick('xpassed'), passed, failed: failed + errors, skipped }, failing, crashed ? { crashed: true } : {});
204
+ }
205
+
206
+ /** go test (plain or -v). */
207
+ function parseGo(text) {
208
+ const failing = [];
209
+ let pending = [];
210
+ let passCount = 0;
211
+ let failCount = 0;
212
+ let skipCount = 0;
213
+ let crashed = false;
214
+ let verbose = false;
215
+ for (const line of text.split('\n')) {
216
+ const t = line.match(/^\s*--- (FAIL|PASS|SKIP): (\S+)/);
217
+ if (t) {
218
+ if (t[1] === 'FAIL') { pending.push(t[2]); failCount++; } else if (t[1] === 'PASS') { passCount++; verbose = true; } else skipCount++;
219
+ continue;
220
+ }
221
+ const pkg = line.match(/^(ok|FAIL)\s+(\S+)(?:\s+\[([^\]]+)\])?/);
222
+ if (pkg && pkg[2] !== '') {
223
+ if (pkg[1] === 'FAIL' && pkg[3]) { crashed = true; failing.push(`${pkg[2]} [${pkg[3]}]`); }
224
+ if (pkg[1] === 'FAIL') failing.push(...pending.map((n) => `${pkg[2]} > ${n}`));
225
+ pending = [];
226
+ }
227
+ }
228
+ failing.push(...pending);
229
+ return result('go', { total: verbose ? passCount + failCount + skipCount : null, passed: verbose ? passCount : null, failed: failCount, skipped: skipCount }, failing, crashed ? { crashed: true } : {});
230
+ }
231
+
232
+ /** cargo test (every `test result:` line summed). */
233
+ function parseCargo(text) {
234
+ const totals = { passed: 0, failed: 0, skipped: 0 };
235
+ for (const m of text.matchAll(/^test result: \w+\. (\d+) passed; (\d+) failed; (\d+) ignored;/gm)) {
236
+ totals.passed += Number(m[1]); totals.failed += Number(m[2]); totals.skipped += Number(m[3]);
237
+ }
238
+ const failing = [...text.matchAll(/^test (\S+) \.\.\. FAILED$/gm)].map((m) => m[1]);
239
+ return result('cargo', { total: totals.passed + totals.failed + totals.skipped, ...totals }, failing);
240
+ }
241
+
242
+ /** JUnit XML: one report, or several concatenated (Maven, Gradle, pytest --junitxml, node --test-reporter=junit). */
243
+ function parseJUnit(text, cwd) {
244
+ let total = 0; let failed = 0; let skipped = 0;
245
+ const failing = [];
246
+ for (const m of text.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
247
+ total++;
248
+ const attr = (k) => { const a = m[1].match(new RegExp(`\\b${k}="([^"]*)"`)); return a ? xmlText(a[1]) : ''; };
249
+ const body = m[3] || '';
250
+ if (/<skipped\b/.test(body)) { skipped++; continue; }
251
+ if (/<(failure|error)\b/.test(body) || /\bfailure="/.test(m[1])) {
252
+ failed++;
253
+ const where = attr('file') ? posixRel(cwd, attr('file')) : attr('classname');
254
+ failing.push(where ? `${where} > ${attr('name')}` : attr('name'));
255
+ }
256
+ }
257
+ return result('junit', { total, passed: total - failed - skipped, failed, skipped }, failing);
258
+ }
259
+
260
+ /**
261
+ * Parse a runner's output by its shape. `parsed: false` when no known shape is found.
262
+ * @param {string} raw - stdout and stderr together
263
+ * @param {string} [cwd] - for making absolute file paths in ids relative
264
+ */
265
+ function parseTestOutput(raw, cwd) {
266
+ const text = toLf(stripAnsi(raw));
267
+ if (/<testcase\b/.test(text)) return parseJUnit(text, cwd);
268
+ if (/^# tests \d+/m.test(text) && /^\s*(not )?ok \d+/m.test(text)) return parseTap(text, cwd);
269
+ if (/^ℹ tests \d+/m.test(text)) return parseNodeSpec(text, cwd);
270
+ if (/^Tests:\s+.*\d+ total/m.test(text)) return parseJest(text);
271
+ if (/^=+ .*\b(passed|failed|errors?|skipped|no tests ran)\b.* in [\d.]+s.*=+$/m.test(text)) return parsePytest(text);
272
+ if (/^test result: /m.test(text)) return parseCargo(text);
273
+ if (/^(ok|FAIL)\s+\S+/m.test(text) || /^\s*--- (FAIL|PASS):/m.test(text)) return parseGo(text);
274
+ return { runner: null, parsed: false, total: null, passed: null, failed: null, skipped: null, failing: [] };
275
+ }
276
+
277
+ /** JUnit XML reports a Maven or Gradle run leaves behind, read after the run. */
278
+ function junitReports(cwd) {
279
+ const dirs = ['target/surefire-reports', 'target/failsafe-reports', 'build/test-results/test'];
280
+ const out = [];
281
+ for (const d of dirs) {
282
+ let names = [];
283
+ try { names = fs.readdirSync(path.join(cwd, d)).filter((n) => /\.xml$/i.test(n)); } catch { continue; }
284
+ for (const n of names) out.push(safeReadFile(path.join(cwd, d, n)) || '');
285
+ }
286
+ return out.join('\n');
287
+ }
288
+
289
+ // ─── Classification ──────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * passed · failed · skipped, from one run and an optional baseline. Pure.
293
+ * @param {{command: string|null, exit: number|null, timed_out?: boolean, parse: Object, reason?: string}} run
294
+ * @param {Object|null} baseline - an earlier record of the same command
295
+ */
296
+ function classifyRun(run, baseline) {
297
+ if (!run.command) return { status: 'skipped', reason: run.reason || 'no test command', regressions: [], preexisting: [] };
298
+ const p = run.parse || { parsed: false, failing: [] };
299
+ if (run.timed_out) return { status: 'failed', reason: 'the suite timed out', regressions: [], preexisting: [] };
300
+ if (run.exit === 0) {
301
+ if (p.parsed && p.failed > 0) return { status: 'failed', reason: `the runner exited 0 but reported ${p.failed} failure(s)`, regressions: p.failing, preexisting: [] };
302
+ if (p.parsed && p.total === 0) return { status: 'skipped', reason: 'the test command ran no tests', regressions: [], preexisting: [] };
303
+ return { status: 'passed', reason: null, regressions: [], preexisting: [] };
304
+ }
305
+ // pytest's "no tests collected" exit code.
306
+ if (run.exit === 5 && p.runner === 'pytest' && !p.failing.length) return { status: 'skipped', reason: 'the test command ran no tests', regressions: [], preexisting: [] };
307
+ if (!p.parsed || !p.failing.length || p.crashed) {
308
+ return { status: 'failed', reason: `suite did not run: exit ${run.exit}${p.parsed ? '' : ', no test result in its output'}`, regressions: p.failing, preexisting: [] };
309
+ }
310
+ const base = baseline && baseline.command === run.command && Array.isArray(baseline.failing) && baseline.parsed !== false ? new Set(baseline.failing) : null;
311
+ if (!base) return { status: 'failed', reason: `${p.failing.length} failing test(s)`, regressions: p.failing, preexisting: [] };
312
+ const regressions = p.failing.filter((id) => !base.has(id));
313
+ const preexisting = p.failing.filter((id) => base.has(id));
314
+ if (regressions.length) return { status: 'failed', reason: `${regressions.length} regression(s): failing now, passing at the baseline`, regressions, preexisting };
315
+ return { status: 'passed', reason: `${preexisting.length} failure(s) already failing at the baseline (not regressions)`, regressions: [], preexisting };
316
+ }
317
+
318
+ // ─── Running ─────────────────────────────────────────────────────────────────
319
+
320
+ function headOf(cwd) {
321
+ const r = execGit(cwd, ['rev-parse', 'HEAD']);
322
+ return r.exitCode === 0 ? r.stdout.trim() : null;
323
+ }
324
+
325
+ /** Record paths for a phase. */
326
+ function recordPaths(cwd, info) {
327
+ const dir = path.join(cwd, info.directory);
328
+ const prefix = String(info.phase_number).split('.').map((p, i) => (i === 0 ? p.padStart(2, '0') : p)).join('.');
329
+ return { dir, run: path.join(dir, `${prefix}-test-run.json`), baseline: path.join(dir, `${prefix}-test-baseline.json`) };
330
+ }
331
+
332
+ function readRecord(file) {
333
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
334
+ }
335
+
336
+ /**
337
+ * Run the project's test command and classify the result.
338
+ * @param {string} cwd
339
+ * @param {{command?: string, timeoutMs?: number, baseline?: Object|null, kind?: 'run'|'baseline', phase?: string}} [opts]
340
+ * @returns {Object} the record
341
+ */
342
+ function runTests(cwd, opts = {}) {
343
+ const detected = opts.command ? { command: opts.command, source: 'flag' } : detectTestCommand(cwd);
344
+ const started = Date.now();
345
+ const rec = {
346
+ version: RECORD_VERSION, kind: opts.kind || 'run', phase: opts.phase || null,
347
+ command: detected.command, source: detected.source, head: headOf(cwd), started_at: new Date(started).toISOString(),
348
+ };
349
+ if (!detected.command) {
350
+ const c = classifyRun({ command: null, reason: detected.reason });
351
+ return { ...rec, exit: null, duration_ms: 0, runner: null, parsed: false, total: null, passed: null, failed: null, skipped: null, failing: [], status: c.status, reason: c.reason, regressions: [], preexisting_failures: [] };
352
+ }
353
+ const r = runCommand(cwd, detected.command, opts.timeoutMs || DEFAULT_TIMEOUT_MS);
354
+ const raw = r.output;
355
+ const timedOut = r.timed_out;
356
+ let parse = parseTestOutput(raw, cwd);
357
+ if (!parse.parsed && /^(mvn|gradle|\.\/gradlew)\b/.test(detected.command)) {
358
+ const reports = junitReports(cwd);
359
+ if (reports) parse = parseJUnit(toLf(reports), cwd);
360
+ }
361
+ const exit = r.exit;
362
+ const c = classifyRun({ command: detected.command, exit, timed_out: timedOut, parse }, opts.baseline || null);
363
+ return {
364
+ ...rec, exit, timed_out: timedOut || undefined, duration_ms: Date.now() - started,
365
+ runner: parse.runner, parsed: parse.parsed, total: parse.total, passed: parse.passed, failed: parse.failed, skipped: parse.skipped,
366
+ failing: parse.failing, status: c.status, reason: c.reason, regressions: c.regressions, preexisting_failures: c.preexisting,
367
+ baseline: opts.baseline ? { head: opts.baseline.head, status: opts.baseline.status, failing: (opts.baseline.failing || []).length } : undefined,
368
+ output_tail: toLf(stripAnsi(raw)).slice(-OUTPUT_TAIL),
369
+ };
370
+ }
371
+
372
+ /** The phase's latest test-run record, or null. */
373
+ function readTestRun(cwd, phase) {
374
+ const info = findPhaseInternal(cwd, phase);
375
+ if (!info) return null;
376
+ return readRecord(recordPaths(cwd, info).run);
377
+ }
378
+
379
+ /**
380
+ * The code fact a phase's test run gives the verdict recorder (V9): a failed run lowers a
381
+ * pass. None when the phase has no run record.
382
+ */
383
+ function testRunFacts(cwd, phase) {
384
+ const r = readTestRun(cwd, phase);
385
+ if (!r || !r.status) return [];
386
+ if (r.status === 'failed') return [{ fact: 'test_run', ok: false, summary: `The engine's test run failed: ${r.reason || 'see the test-run record'}` }];
387
+ if (r.status === 'passed') return [{ fact: 'test_run', ok: true, summary: 'the engine\'s test run passed' }];
388
+ return [];
389
+ }
390
+
391
+ /**
392
+ * `pan-tools test run [--phase N] [--baseline] [--command "<cmd>"] [--timeout <ms>]`.
393
+ * With --phase, the record is written to the phase directory; a run (not a baseline)
394
+ * is compared with the phase's baseline when there is one.
395
+ */
396
+ function cmdTestRun(cwd, opts, raw) {
397
+ let paths = null;
398
+ let baseline = null;
399
+ if (opts.phase) {
400
+ const info = findPhaseInternal(cwd, opts.phase);
401
+ if (!info) error(`phase ${opts.phase} not found`);
402
+ paths = recordPaths(cwd, info);
403
+ if (!opts.baseline) baseline = readRecord(paths.baseline);
404
+ // A baseline is the phase's starting point. A resumed run or a gaps-only round that
405
+ // re-baselined would excuse the regressions of the first round, so the first one is
406
+ // kept unless --rebaseline asks otherwise.
407
+ const existing = opts.baseline && !opts.rebaseline ? readRecord(paths.baseline) : null;
408
+ if (existing) {
409
+ output({ ...existing, kept_existing: true, record: toPosix(path.relative(cwd, paths.baseline)) }, raw, existing.status);
410
+ return;
411
+ }
412
+ }
413
+ const timeoutMs = opts.timeout ? Number(opts.timeout) : undefined;
414
+ const rec = runTests(cwd, { command: opts.command, timeoutMs, baseline, kind: opts.baseline ? 'baseline' : 'run', phase: opts.phase || null });
415
+ if (paths) {
416
+ const file = opts.baseline ? paths.baseline : paths.run;
417
+ fs.writeFileSync(file, JSON.stringify(rec, null, 2) + '\n');
418
+ rec.record = toPosix(path.relative(cwd, file));
419
+ }
420
+ output(rec, raw, rec.status);
421
+ }
422
+
423
+ /** `pan-tools test detect`: the command the gate would run, and why. */
424
+ function cmdTestDetect(cwd, raw) {
425
+ const d = detectTestCommand(cwd);
426
+ output(d, raw, d.command || '');
427
+ }
428
+
429
+ module.exports = {
430
+ detectTestCommand, parseTestOutput, parseNodeSpec, parseTap, parseJest, parsePytest, parseGo, parseCargo, parseJUnit,
431
+ classifyRun, runTests, readTestRun, testRunFacts, recordPaths, cmdTestRun, cmdTestDetect, stripAnsi,
432
+ fileCommand, runCommand, rawConfig,
433
+ };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ /**
3
+ * Local usage census (program L9): `pan-tools usage`.
4
+ *
5
+ * Which of the installed commands and agents this project has used, from its own
6
+ * files only: the cost ledger (`.planning/metrics/tokens.jsonl`: `command` and `agent`
7
+ * per row) and the trace sessions (`.planning/optimization/traces/<sess>/session.json`:
8
+ * `command`, `ended_at`). It reports what was used (calls, last use), what was never
9
+ * used, and commands that were started but never finished: every trace session for
10
+ * the command stopped without ending, the newest more than UNFINISHED_AFTER_MS ago.
11
+ * Nothing leaves the machine.
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { output } = require('./core.cjs');
16
+ const { readRecords } = require('./cost.cjs');
17
+ const { planningPath } = require('./utils.cjs');
18
+
19
+ // A trace session still open after this long is not "running", it stopped.
20
+ const UNFINISHED_AFTER_MS = 6 * 60 * 60 * 1000;
21
+ // The install this engine ships in: <runtime dir>/pan-wizard-core/bin/lib → <runtime dir>.
22
+ const DEFAULT_INVENTORY = path.resolve(__dirname, '..', '..', '..');
23
+
24
+ /** A command name as the inventory spells it: `/pan:quick`, `pan-quick`, `pan:quick` → `quick`. */
25
+ function commandName(s) {
26
+ return String(s || '').trim().replace(/^\//, '').replace(/^pan[:-]/, '');
27
+ }
28
+
29
+ /** The installed commands and agents: commands/pan/*.md or skills/pan-*, and agents/pan-*. */
30
+ function readInventory(root) {
31
+ const list = (d, re) => { try { return fs.readdirSync(d).filter((f) => re.test(f)); } catch { return []; } };
32
+ const commands = new Set(list(path.join(root, 'commands', 'pan'), /\.md$/).map((f) => f.replace(/\.md$/, '')));
33
+ for (const d of list(path.join(root, 'skills'), /^pan-/)) commands.add(commandName(d));
34
+ const agents = new Set(list(path.join(root, 'agents'), /^pan-.*\.(md|toml)$/).map((f) => f.replace(/\.(md|toml)$/, '')));
35
+ return { commands: [...commands].sort(), agents: [...agents].sort() };
36
+ }
37
+
38
+ function readSessions(cwd) {
39
+ const dir = planningPath(cwd, 'optimization', 'traces');
40
+ let names = [];
41
+ try { names = fs.readdirSync(dir).filter((n) => n.startsWith('sess_')); } catch { return []; }
42
+ const out = [];
43
+ for (const n of names) {
44
+ try { out.push(JSON.parse(fs.readFileSync(path.join(dir, n, 'session.json'), 'utf-8'))); } catch { /* unreadable session */ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * @param {string} cwd
51
+ * @param {{inventory?: string, now?: string}} [opts]
52
+ */
53
+ function usageCensus(cwd, opts = {}) {
54
+ const inventory = readInventory(opts.inventory || DEFAULT_INVENTORY);
55
+ const now = opts.now ? Date.parse(opts.now) : Date.now();
56
+ const cmds = new Map(inventory.commands.map((n) => [n, { name: n, calls: 0, last_used: null, sessions: 0, finished: 0, _lastStart: null }]));
57
+ const agents = new Map(inventory.agents.map((n) => [n, { name: n, calls: 0, last_used: null }]));
58
+ const later = (a, b) => (!a || (b && b > a) ? b : a);
59
+
60
+ const rows = readRecords(cwd);
61
+ for (const r of rows) {
62
+ const c = cmds.get(commandName(r.command));
63
+ if (c) { c.calls++; c.last_used = later(c.last_used, r.ts || null); }
64
+ const a = agents.get(String(r.agent || ''));
65
+ if (a) { a.calls++; a.last_used = later(a.last_used, r.ts || null); }
66
+ }
67
+ const sessions = readSessions(cwd);
68
+ for (const s of sessions) {
69
+ const c = cmds.get(commandName(s.command));
70
+ if (!c) continue;
71
+ c.sessions++;
72
+ if (s.ended_at) c.finished++;
73
+ c._lastStart = later(c._lastStart, s.started_at || null);
74
+ c.last_used = later(c.last_used, s.ended_at || s.started_at || null);
75
+ }
76
+ const byUse = (a, b) => b.calls - a.calls || String(b.last_used).localeCompare(String(a.last_used)) || a.name.localeCompare(b.name);
77
+ const pub = ({ _lastStart, ...rest }) => rest;
78
+ const cmdList = [...cmds.values()];
79
+ const used = cmdList.filter((c) => c.calls > 0 || c.sessions > 0);
80
+ const unfinished = used.filter((c) => c.sessions > 0 && c.finished === 0 && c._lastStart && now - Date.parse(c._lastStart) > UNFINISHED_AFTER_MS);
81
+ const agentList = [...agents.values()];
82
+ return {
83
+ local_only: true,
84
+ commands: {
85
+ used: used.sort(byUse).map(pub),
86
+ never_used: cmdList.filter((c) => c.calls === 0 && c.sessions === 0).map((c) => c.name).sort(),
87
+ unfinished: unfinished.sort(byUse).map(pub),
88
+ installed: inventory.commands.length,
89
+ },
90
+ agents: {
91
+ used: agentList.filter((a) => a.calls > 0).sort(byUse),
92
+ never_used: agentList.filter((a) => a.calls === 0).map((a) => a.name).sort(),
93
+ installed: inventory.agents.length,
94
+ },
95
+ sources: { cost_rows: rows.length, trace_sessions: sessions.length },
96
+ };
97
+ }
98
+
99
+ function cmdUsage(cwd, raw) {
100
+ const r = usageCensus(cwd);
101
+ output(r, raw, `${r.commands.used.length} of ${r.commands.installed} commands used, ${r.commands.unfinished.length} unfinished; ${r.agents.used.length} of ${r.agents.installed} agents used`);
102
+ }
103
+
104
+ module.exports = { usageCensus, readInventory, commandName, cmdUsage, UNFINISHED_AFTER_MS };