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
@@ -51,86 +51,13 @@ const TARGETS = [
51
51
  { file: 'pan-wizard-core/bin/pan-tools.cjs', tests: ['tests/dispatcher.test.cjs', 'tests/dispatcher-arms.test.cjs'] },
52
52
  ];
53
53
 
54
- /**
55
- * The mutation operators. Each is a plain textual swap with a token that must appear
56
- * OUTSIDE a string or comment to be worth mutating (see mutableLine). They are deliberately
57
- * few and blunt: the point is to find unconstrained logic, not to enumerate every possible
58
- * defect.
59
- */
60
- const OPERATORS = [
61
- { id: 'gte→gt', find: ' >= ', replace: ' > ' },
62
- { id: 'gt→gte', find: ' > ', replace: ' >= ' },
63
- { id: 'lte→lt', find: ' <= ', replace: ' < ' },
64
- { id: 'lt→lte', find: ' < ', replace: ' <= ' },
65
- { id: 'eq→neq', find: ' === ', replace: ' !== ' },
66
- { id: 'neq→eq', find: ' !== ', replace: ' === ' },
67
- { id: 'and→or', find: ' && ', replace: ' || ' },
68
- { id: 'or→and', find: ' || ', replace: ' && ' },
69
- { id: 'true→false', find: 'return true', replace: 'return false' },
70
- { id: 'false→true', find: 'return false', replace: 'return true' },
71
- { id: 'plus→minus', find: ' + 1', replace: ' - 1' },
72
- ];
73
-
74
- /**
75
- * Is this line worth mutating? Skips comments, and skips a line whose only occurrence of
76
- * the token is inside a string literal — mutating a message changes nothing a test should
77
- * be pinning, and a survivor there would be noise.
78
- *
79
- * Crude but conservative: a line containing a quote is only mutated when the token also
80
- * occurs before the first quote.
81
- */
82
- function mutableLine(line, token) {
83
- const trimmed = line.trim();
84
- if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return false;
85
- const at = line.indexOf(token);
86
- if (at === -1) return false;
87
- const firstQuote = Math.min(
88
- ...["'", '"', '`'].map((q) => { const i = line.indexOf(q); return i === -1 ? Infinity : i; }),
89
- );
90
- return at < firstQuote;
91
- }
92
-
93
- /**
94
- * Every mutation available in a source file: one per (line, operator) pair, first
95
- * occurrence on the line.
96
- *
97
- * @returns {Array<{line: number, op: string, before: string, after: string}>}
98
- */
99
- function mutationsFor(src) {
100
- const lines = src.split(/\r?\n/);
101
- const out = [];
102
- lines.forEach((line, i) => {
103
- for (const op of OPERATORS) {
104
- if (!mutableLine(line, op.find)) continue;
105
- out.push({ line: i + 1, op: op.id, before: line, after: line.replace(op.find, op.replace) });
106
- }
107
- });
108
- return out;
109
- }
110
-
111
- /** Apply one mutation to a source string, by line number. Throws if the line moved. */
112
- function applyMutation(src, mutation) {
113
- const eol = src.includes('\r\n') ? '\r\n' : '\n';
114
- const lines = src.split(/\r?\n/);
115
- const idx = mutation.line - 1;
116
- if (lines[idx] !== mutation.before) {
117
- throw new Error(`line ${mutation.line} is not what the mutation was built from`);
118
- }
119
- lines[idx] = mutation.after;
120
- return lines.join(eol);
121
- }
122
-
123
- /** Deterministic shuffle, so a run is reproducible from its seed. */
124
- function sample(items, n, seed) {
125
- let s = seed >>> 0;
126
- const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; };
127
- const copy = items.slice();
128
- for (let i = copy.length - 1; i > 0; i--) {
129
- const j = Math.floor(rand() * (i + 1));
130
- [copy[i], copy[j]] = [copy[j], copy[i]];
131
- }
132
- return copy.slice(0, n);
133
- }
54
+ // The operators, the line filter, mutation and the seeded sample live in the shipped
55
+ // mutation report (pan-wizard-core/bin/lib/mutate.cjs, program V5), which a user's phase
56
+ // diff is measured with; this probe applies the JavaScript table to PAN itself.
57
+ const M = require('../pan-wizard-core/bin/lib/mutate.cjs');
58
+ const OPERATORS = M.OPERATORS.js;
59
+ const { mutableLine, applyMutation, sample } = M;
60
+ const mutationsFor = (src) => M.mutationsFor(src, { family: 'js' });
134
61
 
135
62
  function parseArgs(argv) {
136
63
  const a = { max: 20, seed: 1, json: false, targets: [], timeoutMs: 600000, help: false };
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * Prompt-contract lint (program L2): the contract a shipped prompt has with the
5
+ * engine and the host, checked in code. Applied to commands/pan, agents and
6
+ * pan-wizard-core/workflows by tests/prompt-lint.test.cjs, with
7
+ * tests/fixtures/prompt-lint-allowlist.json (every entry names its reason, usually
8
+ * the plan item that fixes it).
9
+ *
10
+ * unknown-verb a `pan-tools <verb> [<sub>]` call naming a verb or a subcommand
11
+ * the engine does not have (tests/fixtures/surface.json)
12
+ * tool-not-granted an agent told to use a tool its frontmatter `tools:` does not grant
13
+ * pasted-source an @-include of engine source (`*.cjs` / `*.js`) into a prompt
14
+ * oversized-question an AskUserQuestion block offering more than four options (the
15
+ * host's limit: two to four)
16
+ * untyped-spawn a Task( spawn without `model=`, or a general-purpose spawn told
17
+ * to read a role file instead of naming the agent type
18
+ * dev-skill a shipped prompt sending the user to a dev-only skill of this
19
+ * repository (.claude/commands/*.md), which no install carries
20
+ *
21
+ * node scripts/prompt-lint.cjs # report the current tree, exit 1 on a finding
22
+ */
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const ROOT = path.join(__dirname, '..');
27
+ const TOOLS = ['Write', 'Edit', 'WebSearch', 'WebFetch', 'Task'];
28
+
29
+ function surface(root = ROOT) {
30
+ const s = JSON.parse(fs.readFileSync(path.join(root, 'tests', 'fixtures', 'surface.json'), 'utf8'));
31
+ const subs = new Map();
32
+ for (const entry of s.subcommands) {
33
+ const [verb, sub] = entry.split(' ');
34
+ if (!subs.has(verb)) subs.set(verb, new Set());
35
+ subs.get(verb).add(sub);
36
+ }
37
+ return { verbs: new Set(s.verbs), subs };
38
+ }
39
+
40
+ function devSkills(root = ROOT) {
41
+ try {
42
+ return fs.readdirSync(path.join(root, '.claude', 'commands')).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
43
+ } catch { return []; }
44
+ }
45
+
46
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
47
+
48
+ function frontmatterTools(text) {
49
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
50
+ if (!fm) return null;
51
+ const m = fm[1].match(/^tools:[ \t]*(.+)$/m);
52
+ return m ? new Set(m[1].split(',').map((t) => t.trim().replace(/\*$/, '')).filter(Boolean)) : null;
53
+ }
54
+
55
+ /**
56
+ * Lint one file's text.
57
+ * @param {string} rel - repository-relative path (decides which rules apply)
58
+ * @param {string} raw - file content
59
+ * @param {{verbs: Set, subs: Map, dev: string[]}} ctx
60
+ * @returns {Array<{rule, file, line, text}>}
61
+ */
62
+ function lintText(rel, raw, ctx) {
63
+ const text = raw.replace(/\r\n/g, '\n');
64
+ const out = [];
65
+ const hit = (rule, index, snippet) => out.push({ rule, file: rel, line: lineOf(text, index), text: String(snippet).trim().slice(0, 140) });
66
+
67
+ // unknown-verb
68
+ // An invocation, not prose: the script path (`pan-tools.cjs <verb>`) or a backticked `pan-tools <verb>`.
69
+ const callRe = /(?:pan-tools\.cjs"?|`pan-tools)[ \t]+([a-z][a-z0-9-]*)(?:[ \t]+([a-z][a-z0-9-]*))?/g;
70
+ let m;
71
+ while ((m = callRe.exec(text))) {
72
+ const [, verb, sub] = m;
73
+ if (!ctx.verbs.has(verb)) { hit('unknown-verb', m.index, m[0]); continue; }
74
+ const known = ctx.subs.get(verb);
75
+ if (known && sub && !known.has(sub) && !ctx.verbs.has(sub)) hit('unknown-verb', m.index, m[0]);
76
+ }
77
+
78
+ // pasted-source
79
+ const incRe = /^@\S+\.(?:cjs|mjs|js)\s*$/gm;
80
+ while ((m = incRe.exec(text))) hit('pasted-source', m.index, m[0]);
81
+
82
+ // tool-not-granted (agents only)
83
+ if (rel.startsWith('agents/')) {
84
+ const granted = frontmatterTools(text);
85
+ if (granted) {
86
+ for (const tool of TOOLS) {
87
+ if (granted.has(tool)) continue;
88
+ const re = new RegExp(`\\b(?:use|using|with|via|call|run)\\s+(?:the\\s+)?\`?${tool}\`?(?:\\s+tool)?\\b|\\bthe\\s+\`?${tool}\`?\\s+tool\\b`, 'g');
89
+ let mm;
90
+ while ((mm = re.exec(text))) {
91
+ // "Never use Edit or Write tools" is the rule being kept, not broken.
92
+ const before = text.slice(Math.max(0, mm.index - 24), mm.index).toLowerCase();
93
+ if (/\b(never|not|don't|do not|no)\s*$/.test(before)) continue;
94
+ hit('tool-not-granted', mm.index, `${tool} (granted: ${[...granted].join(', ')}): ${mm[0]}`);
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // oversized-question
102
+ const askRe = /AskUserQuestion/g;
103
+ while ((m = askRe.exec(text))) {
104
+ const window = text.slice(m.index, m.index + 6000);
105
+ const optRe = /options\s*:\s*\[/g;
106
+ let o;
107
+ while ((o = optRe.exec(window))) {
108
+ let depth = 0;
109
+ let end = -1;
110
+ for (let i = o.index + o[0].length - 1; i < window.length; i++) {
111
+ if (window[i] === '[') depth++;
112
+ else if (window[i] === ']') { depth--; if (depth === 0) { end = i; break; } }
113
+ }
114
+ if (end < 0) break;
115
+ const labels = (window.slice(o.index, end).match(/\blabel\s*:/g) || []).length;
116
+ if (labels > 4) hit('oversized-question', m.index + o.index, `${labels} options in one question`);
117
+ optRe.lastIndex = end;
118
+ }
119
+ askRe.lastIndex = m.index + 1;
120
+ }
121
+
122
+ // untyped-spawn (workflows and commands)
123
+ if (!rel.startsWith('agents/')) {
124
+ const lines = text.split('\n');
125
+ lines.forEach((l, i) => {
126
+ if (!/^\s*Task\(/.test(l)) return;
127
+ let j = i;
128
+ while (j < lines.length - 1 && !/^\s*\)\s*$/.test(lines[j]) && !/description="[^"]*"\s*\)\s*$/.test(lines[j])) j++;
129
+ const block = lines.slice(i, j + 1).join('\n');
130
+ const at = lines.slice(0, i).join('\n').length + 1;
131
+ if (!/\bmodel\s*=/.test(block)) hit('untyped-spawn', at, 'a Task( spawn without model=');
132
+ else if (/subagent_type="general-purpose"/.test(block) && /agents\/pan-[a-z-]+\.md for your role/.test(block)) hit('untyped-spawn', at, 'general-purpose spawn told to read a role file');
133
+ });
134
+ }
135
+
136
+ // dev-skill
137
+ if (ctx.dev.length) {
138
+ // Every regex metacharacter escaped, backslash included (inline: CodeQL does not follow a helper).
139
+ const names = ctx.dev.map((d) => d.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'));
140
+ const devRe = new RegExp(`(^|[\\s\`'"(])/(${names.join('|')})(?=[\\s\`'".,;:)]|$)`, 'gm');
141
+ while ((m = devRe.exec(text))) hit('dev-skill', m.index + m[1].length, m[0]);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** The shipped prompt files, repository-relative. */
147
+ function shippedPrompts(root = ROOT) {
148
+ const list = [];
149
+ for (const dir of ['commands/pan', 'agents', 'pan-wizard-core/workflows']) {
150
+ for (const f of fs.readdirSync(path.join(root, dir)).filter((x) => x.endsWith('.md')).sort()) list.push(`${dir}/${f}`);
151
+ }
152
+ return list;
153
+ }
154
+
155
+ function lintTree(root = ROOT) {
156
+ const ctx = { ...surface(root), dev: devSkills(root) };
157
+ return shippedPrompts(root).flatMap((rel) => lintText(rel, fs.readFileSync(path.join(root, rel), 'utf8'), ctx));
158
+ }
159
+
160
+ if (require.main === module) {
161
+ const findings = lintTree();
162
+ for (const f of findings) process.stdout.write(`${f.rule} ${f.file}:${f.line} — ${f.text}\n`);
163
+ process.stdout.write(`${findings.length} finding(s)\n`);
164
+ process.exitCode = findings.length ? 1 : 0;
165
+ }
166
+
167
+ module.exports = { lintText, lintTree, shippedPrompts, surface, devSkills, frontmatterTools };
@@ -19,7 +19,9 @@
19
19
  * Modes:
20
20
  * node scripts/test-surface.cjs summary
21
21
  * node scripts/test-surface.cjs --write write tests/fixtures/surface.json (the committed registry)
22
- * node scripts/test-surface.cjs --check exit 1 when the committed registry differs from the code
22
+ * node scripts/test-surface.cjs --check exit 1 when the committed registry differs from the code, or
23
+ * a command, agent, verb or config key has no user (liveness, L1)
24
+ * node scripts/test-surface.cjs --liveness who uses each command, agent, verb, subcommand and config key
23
25
  * node scripts/test-surface.cjs --map which test files reference each surface row; lists the misses
24
26
  * node scripts/test-surface.cjs --scaffold <dir> one todo stub per unreferenced row (for a suite rebuilt from scratch)
25
27
  *
@@ -65,6 +67,7 @@ const EXTRA_HOOK_ROWS = Object.freeze([
65
67
  ]);
66
68
  const EVENT_HOOKS = Object.freeze({
67
69
  sessionStart: ['pan-check-update.js'],
70
+ preToolUse: ['pan-guard.js'],
68
71
  postToolUse: ['pan-context-monitor.js'],
69
72
  subagentStop: ['pan-cost-logger.js', 'pan-trace-logger.js'],
70
73
  stop: ['pan-stop-guard.js'],
@@ -296,6 +299,153 @@ function scaffold(missingRows, outDir) {
296
299
 
297
300
  function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf8')); }
298
301
 
302
+ // ─── Liveness (program L1) ──────────────────────────────────────────────────
303
+ //
304
+ // The map above asks "does a test name it?". Liveness asks "does anything USE it?":
305
+ // - every shipped command and agent is named by a harness scenario, so some behavioural
306
+ // run exercises it as a user would;
307
+ // - every pan-tools verb and subcommand has a caller in what PAN ships: a prompt
308
+ // (commands, agents, workflows, references, templates), a native workflow's prompt
309
+ // (bin/install-lib.cjs), or an MCP tool or resource (the bridge registry);
310
+ // - every config default has a reader: code under the core, hooks, installer or MCP
311
+ // bridge that reads the key, or a shipped prompt that runs `config-get <key>`.
312
+ // An orphan either gains a user or sits in tests/fixtures/liveness-allowlist.json with a
313
+ // reason; `--check` fails on an orphan without one, and on an entry no longer needed.
314
+
315
+ const LIVENESS_ALLOWLIST_REL = path.join('tests', 'fixtures', 'liveness-allowlist.json');
316
+ const PROMPT_DIRS = Object.freeze(['commands/pan', 'agents', 'pan-wizard-core/workflows', 'pan-wizard-core/references', 'pan-wizard-core/templates']);
317
+ const NATIVE_WORKFLOW_SOURCES = Object.freeze(['bin/install-lib.cjs']);
318
+ const READER_DIRS = Object.freeze(['pan-wizard-core/bin', 'pan-wizard-core/mcp', 'pan-wizard-core/opencode', 'hooks', 'bin']);
319
+ const SCENARIO_DIR = 'harness/scenarios';
320
+
321
+ /** Every file under rel (recursively) whose name matches re, as posix paths; skips dist/ and node_modules/. */
322
+ function walkFiles(root, rel, re) {
323
+ const out = [];
324
+ const walk = (r) => {
325
+ let ents;
326
+ try { ents = fs.readdirSync(path.join(root, r), { withFileTypes: true }); } catch { return; }
327
+ for (const e of ents) {
328
+ const p = path.posix.join(r, e.name);
329
+ if (e.isDirectory()) { if (e.name !== 'dist' && e.name !== 'node_modules') walk(p); } else if (re.test(e.name)) out.push(p);
330
+ }
331
+ };
332
+ walk(rel);
333
+ return out.sort();
334
+ }
335
+
336
+ /**
337
+ * The verbs and `verb sub` pairs a text invokes: the script path (`pan-tools.cjs <verb>`),
338
+ * a backticked `pan-tools <verb>`, or `pan-tools <verb>` as a shell command (opening a
339
+ * line, or after `|`, `;`, `&` or `(`), then the verb and an optional subcommand. Prose
340
+ * that mentions pan-tools is not a call.
341
+ */
342
+ function pantoolsCalls(text) {
343
+ const out = new Set();
344
+ const re = /(?:pan-tools\.cjs["']?|`pan-tools|(?:^|[|;&(])[ \t]*pan-tools)[ \t]+([a-z][a-z0-9-]*)(?:[ \t]+([a-z][a-z0-9-]*))?/gm;
345
+ let m;
346
+ while ((m = re.exec(text))) { out.add(m[1]); if (m[2]) out.add(`${m[1]} ${m[2]}`); }
347
+ return out;
348
+ }
349
+
350
+ /** The text a liveness check reads, from the tree. Tests pass a hand-built object instead. */
351
+ function loadLivenessInputs(root = ROOT) {
352
+ const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
353
+ const prompts = PROMPT_DIRS.flatMap((d) => walkFiles(root, d, /\.(md|js)$/)).map((file) => ({ file, src: read(file) }));
354
+ const native = NATIVE_WORKFLOW_SOURCES.filter((f) => fs.existsSync(path.join(root, f))).map((file) => ({ file, src: read(file) }));
355
+ const registry = require(path.join(root, SOURCES.mcpRegistry));
356
+ const mcp = [...(registry.TOOLS || []), ...(registry.RESOURCES || [])].map((t) => ({ name: t.name || t.uri, verb: t.verb, sub: (t.args && t.args[0]) || null }));
357
+ const scenarios = walkFiles(root, SCENARIO_DIR, /\.json$/).map((file) => ({ file, src: read(file) }));
358
+ const configSrc = path.posix.normalize(SOURCES.config);
359
+ const readers = READER_DIRS.flatMap((d) => walkFiles(root, d, /\.(c?js|mjs)$/)).map((file) => ({ file, src: read(file), isConfig: file === configSrc }));
360
+ return { prompts, native, mcp, scenarios, readers };
361
+ }
362
+
363
+ /** The body of buildConfigDefaults in config.cjs: where defaults are written, which is not a read. */
364
+ function stripDefaultsBuilder(src) {
365
+ const start = src.indexOf('function buildConfigDefaults');
366
+ if (start === -1) return src;
367
+ const open = src.indexOf('{', start);
368
+ let depth = 0;
369
+ for (let i = open; i < src.length; i++) {
370
+ if (src[i] === '{') depth++;
371
+ else if (src[i] === '}' && --depth === 0) return src.slice(0, start) + src.slice(i + 1);
372
+ }
373
+ return src;
374
+ }
375
+
376
+ /**
377
+ * Who uses each piece of the surface. Pure over `surface` (extractSurface) and `inputs`
378
+ * (loadLivenessInputs). Returns one row per command, agent, verb, subcommand and config
379
+ * key: `{id, kind, users: string[]}`; a row with no users is an orphan.
380
+ */
381
+ function liveness(surface, inputs) {
382
+ const rows = [];
383
+ const scen = inputs.scenarios;
384
+ const named = (re) => scen.filter((s) => re.test(s.src)).map((s) => s.file);
385
+ for (const f of surface.content['commands/pan'] || []) {
386
+ const name = f.replace(/\.md$/, '');
387
+ // /pan:<name> (Claude, Gemini), /pan-<name> or pan-<name> as a skill (Codex, Copilot, OpenCode), or the file itself.
388
+ rows.push({ id: `command:${name}`, kind: 'command', users: named(new RegExp(`(?:/pan[:-]|commands/pan/|\\bpan-)${esc(name)}(?![a-z0-9-])`)) });
389
+ }
390
+ for (const f of surface.content.agents || []) {
391
+ const name = f.replace(/\.md$/, '');
392
+ rows.push({ id: `agent:${name}`, kind: 'agent', users: named(new RegExp(`(?<![a-z0-9-])${esc(name)}(?![a-z0-9-])`)) });
393
+ }
394
+ const callers = new Map();
395
+ const add = (key, who) => { if (!callers.has(key)) callers.set(key, new Set()); callers.get(key).add(who); };
396
+ for (const t of [...inputs.prompts, ...inputs.native]) for (const c of pantoolsCalls(t.src)) add(c, t.file);
397
+ // A registry entry with no verb (one served by the bridge itself) calls nothing.
398
+ for (const t of inputs.mcp.filter((x) => x.verb)) { add(t.verb, `mcp:${t.name}`); if (t.sub) add(`${t.verb} ${t.sub}`, `mcp:${t.name}`); }
399
+ for (const v of surface.verbs) {
400
+ const direct = callers.get(v) || new Set();
401
+ // A verb is alive when it is called, bare or through any of its subcommands.
402
+ const viaSub = [...callers.entries()].filter(([k]) => k.startsWith(`${v} `)).flatMap(([, s]) => [...s]);
403
+ rows.push({ id: `caller:verb:${v}`, kind: 'verb', users: [...new Set([...direct, ...viaSub])].sort() });
404
+ }
405
+ for (const s of surface.subcommands) rows.push({ id: `caller:sub:${s}`, kind: 'sub', users: [...(callers.get(s) || [])].sort() });
406
+ const readerText = inputs.readers.map((r) => ({ file: r.file, src: r.isConfig ? stripDefaultsBuilder(r.src) : r.src }));
407
+ const promptText = [...inputs.prompts, ...inputs.native];
408
+ for (const key of surface.config_keys) {
409
+ const last = esc(key.split('.').pop());
410
+ const codeRe = new RegExp(`['"\`]${esc(key)}['"\`]|\\.${last}\\b|\\[['"]${last}['"]\\]|['"]${last}['"]`);
411
+ // A prompt reads a key with `config-get <key>`, or names the full dotted key in backticks
412
+ // for the agent to check in the config or the init payload (`workflow.x: true`).
413
+ const promptRe = new RegExp(`config-get[ \\t]+["']?${esc(key)}\\b|\`${esc(key)}[\`:= ]`);
414
+ const users = [...readerText.filter((r) => codeRe.test(r.src)).map((r) => r.file), ...promptText.filter((p) => promptRe.test(p.src)).map((p) => p.file)];
415
+ rows.push({ id: `reader:config:${key}`, kind: 'config', users: [...new Set(users)].sort() });
416
+ }
417
+ return rows;
418
+ }
419
+
420
+ /**
421
+ * Orphans not allowlisted, and allowlist entries that are wrong (no reason, no such row, or
422
+ * a row that has users now). `--check` fails when either list is non-empty.
423
+ */
424
+ function livenessProblems(rows, allowlist) {
425
+ const allow = new Map(allowlist.map((a) => [a.id, a]));
426
+ const problems = [];
427
+ for (const a of allowlist) {
428
+ if (!a.reason || !String(a.reason).trim()) problems.push(`liveness allowlist entry ${a.id} has no reason`);
429
+ const row = rows.find((r) => r.id === a.id);
430
+ if (!row) problems.push(`liveness allowlist entry ${a.id} names nothing that exists — remove it`);
431
+ else if (row.users.length) problems.push(`liveness allowlist entry ${a.id} has a user now (${row.users[0]}) — remove the entry`);
432
+ }
433
+ const hint = { command: 'name it in a harness scenario', agent: 'name it in a harness scenario', verb: 'call it from a shipped prompt', sub: 'call it from a shipped prompt', config: 'read it in code, or drop the default' };
434
+ for (const r of rows) if (!r.users.length && !allow.has(r.id)) problems.push(`orphan ${r.id} — ${hint[r.kind]}, or allowlist it in ${LIVENESS_ALLOWLIST_REL} with a reason`);
435
+ return problems;
436
+ }
437
+
438
+ /** What `--check` decides: registry drift plus liveness problems. Pure. */
439
+ function checkReport(committed, surface, inputs, allowlist) {
440
+ const drift = diffSurface(committed, surface);
441
+ const live = livenessProblems(liveness(surface, inputs), allowlist);
442
+ return { drift, live, ok: !drift.added.length && !drift.removed.length && !live.length };
443
+ }
444
+
445
+ function readLivenessAllowlist(root = ROOT) {
446
+ try { return readJson(path.join(root, LIVENESS_ALLOWLIST_REL)); } catch { return []; }
447
+ }
448
+
299
449
  // ─── CLI ────────────────────────────────────────────────────────────────────
300
450
 
301
451
  function main(argv) {
@@ -309,13 +459,25 @@ function main(argv) {
309
459
  if (argv.includes('--check')) {
310
460
  let committed;
311
461
  try { committed = readJson(registryPath); } catch { console.error(`no committed registry at ${REGISTRY_REL} — run --write`); return 1; }
312
- const d = diffSurface(committed, surface);
313
- if (!d.added.length && !d.removed.length) { console.log('surface registry matches the code'); return 0; }
314
- console.error(`surface registry is stale — run \`node scripts/test-surface.cjs --write\` and commit it`);
315
- for (const x of d.added) console.error(` + ${x}`);
316
- for (const x of d.removed) console.error(` - ${x}`);
462
+ const { drift: d, live, ok } = checkReport(committed, surface, loadLivenessInputs(ROOT), readLivenessAllowlist(ROOT));
463
+ if (ok) { console.log('surface registry matches the code; every command, agent, verb and config key is alive'); return 0; }
464
+ if (d.added.length || d.removed.length) {
465
+ console.error(`surface registry is stale — run \`node scripts/test-surface.cjs --write\` and commit it`);
466
+ for (const x of d.added) console.error(` + ${x}`);
467
+ for (const x of d.removed) console.error(` - ${x}`);
468
+ }
469
+ if (live.length) {
470
+ console.error('liveness: orphans without a user or an allowlist reason');
471
+ for (const x of live) console.error(` ${x}`);
472
+ }
317
473
  return 1;
318
474
  }
475
+ if (argv.includes('--liveness')) {
476
+ const rows = liveness(surface, loadLivenessInputs(ROOT));
477
+ for (const r of rows) console.log(`${r.users.length ? 'ok ' : 'ORPHAN'} ${r.id.padEnd(48)} ${r.users.slice(0, 2).join(', ')}${r.users.length > 2 ? ` +${r.users.length - 2}` : ''}`);
478
+ console.log(`liveness rows: ${rows.length} · orphans: ${rows.filter((r) => !r.users.length).length}`);
479
+ return 0;
480
+ }
319
481
  const rows = mapSurface(surfaceRows(surface), loadTestSources(ROOT));
320
482
  const missing = rows.filter((r) => !r.hits.length);
321
483
  if (argv.includes('--scaffold')) {
@@ -338,4 +500,5 @@ module.exports = {
338
500
  ROOT, REGISTRY_REL, ALLOWLIST_REL, SOURCES, EXTRA_HOOK_ROWS,
339
501
  parseTopLevelCommands, parseGroupSubcommands, parseCaseArms, parseInstallerFlags, flattenKeys, hookMatrix,
340
502
  extractSurface, surfaceRows, referencePattern, mapSurface, listTestFiles, loadTestSources, diffSurface, scaffold,
503
+ LIVENESS_ALLOWLIST_REL, pantoolsCalls, loadLivenessInputs, stripDefaultsBuilder, liveness, livenessProblems, checkReport, readLivenessAllowlist,
341
504
  };