pan-wizard 3.27.0 → 3.29.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 (40) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/cost.md +14 -2
  7. package/commands/pan/preview.md +2 -2
  8. package/hooks/dist/pan-check-update.js +4 -0
  9. package/hooks/dist/pan-cost-logger.js +322 -43
  10. package/hooks/dist/pan-trace-logger.js +275 -32
  11. package/package.json +8 -2
  12. package/pan-wizard-core/bin/lib/commands.cjs +3 -1
  13. package/pan-wizard-core/bin/lib/constants.cjs +39 -0
  14. package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
  15. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
  16. package/pan-wizard-core/bin/lib/cost.cjs +174 -18
  17. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  18. package/pan-wizard-core/bin/lib/git.cjs +5 -1
  19. package/pan-wizard-core/bin/lib/hud.cjs +5 -3
  20. package/pan-wizard-core/bin/lib/hygiene.cjs +52 -24
  21. package/pan-wizard-core/bin/lib/init.cjs +8 -0
  22. package/pan-wizard-core/bin/lib/memory.cjs +14 -8
  23. package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
  24. package/pan-wizard-core/bin/lib/utils.cjs +22 -0
  25. package/pan-wizard-core/bin/lib/verify.cjs +46 -12
  26. package/pan-wizard-core/bin/pan-tools.cjs +8 -1
  27. package/pan-wizard-core/mcp/server.cjs +92 -8
  28. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  29. package/pan-wizard-core/references/model-profiles.md +2 -2
  30. package/pan-wizard-core/workflows/health.md +2 -0
  31. package/pan-zcode/README.md +1 -1
  32. package/scripts/build-agent-plugin.js +220 -0
  33. package/scripts/build-plugin.js +48 -3
  34. package/scripts/coverage-gate.cjs +257 -0
  35. package/scripts/generate-skills-docs.py +1 -1
  36. package/scripts/install-git-hooks.js +5 -0
  37. package/scripts/mutation-probe.cjs +272 -0
  38. package/scripts/release-check.js +80 -13
  39. package/scripts/test-quality-lint.cjs +240 -0
  40. package/scripts/test-surface.cjs +335 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Build the PAN Wizard **Agent Plugins 1.0** bundle (ADR-0045) — the vendor-
3
+ * neutral package that Copilot CLI / VS Code, Codex, Cursor and Kiro load
4
+ * natively. Emits a self-contained directory at dist/pan-agent-plugin/:
5
+ *
6
+ * plugin.json closed-schema manifest ($schema + name + metadata)
7
+ * skills/pan-<name>/SKILL.md every PAN command as an Agent Skill, from the
8
+ * ONE unified-skills compiler (ADR-0028)
9
+ * mcp.json the bundled bridge, launched as
10
+ * `node ${PLUGIN_ROOT}/pan-wizard-core/mcp/server.cjs`
11
+ * pan-wizard-core/ dispatcher + modules + workflows + templates +
12
+ * references + learnings (internal stripped) +
13
+ * canonical agent reference copies under agents/
14
+ *
15
+ * hooks/pan-*.js PAN's hook scripts (pure Node), shared by every vendor
16
+ * hooks/hooks.json Codex: default plugin hooks location; matcher-group
17
+ * shape with `${PLUGIN_ROOT}` paths and `async` observers
18
+ * (developers.openai.com/plugins/build/plugins, 2026-09-10)
19
+ * com.github.copilot/ Copilot's reverse-domain namespace (ADR-0045 D5):
20
+ * agents/pan-*.agent.md agents in Copilot's format
21
+ * hooks/hooks.json flat PascalCase format, `${CLAUDE_PLUGIN_ROOT}` paths
22
+ * (VS-Code-verified; Copilot CLI live install is the gate)
23
+ *
24
+ * NOT emitted: Codex agents (no plugin agent component is documented) and any
25
+ * Antigravity variant (its manifest schema is closed and different — a separate
26
+ * layout, deferred until its file shapes are read from a primary source).
27
+ *
28
+ * Paths inside skill and core markdown use PAN's `{{PAN_PLUGIN_ROOT}}` token,
29
+ * defined for the model by the adapter note in every skill; `${PLUGIN_ROOT}`
30
+ * (the client-expanded variable) appears only in mcp.json, the one place the
31
+ * spec expands it.
32
+ *
33
+ * Usage: node scripts/build-agent-plugin.js (or npm run build:agent-plugin)
34
+ * PAN_AGENT_PLUGIN_OUT=<dir> overrides the output directory (tests build into
35
+ * private temp dirs so parallel test files never race on dist/).
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+
43
+ const ROOT = path.join(__dirname, '..');
44
+ const OUT = process.env.PAN_AGENT_PLUGIN_OUT
45
+ ? path.resolve(process.env.PAN_AGENT_PLUGIN_OUT)
46
+ : path.join(ROOT, 'dist', 'pan-agent-plugin');
47
+ const pkg = require(path.join(ROOT, 'package.json'));
48
+ const lib = require(path.join(ROOT, 'bin', 'install-lib.cjs'));
49
+
50
+ const TOKEN_PREFIX = `${lib.AGENT_PLUGIN_ROOT_TOKEN}/`;
51
+ const REWRITE = {
52
+ // Core and agent references → inside the bundle.
53
+ corePrefix: TOKEN_PREFIX,
54
+ // Residual `~/.claude/…` → the consuming runtime's USER config dir; residual
55
+ // `./.claude/…` → its PROJECT dir. Neither is known at build time, so both are
56
+ // tokens the adapter note defines (install-lib AGENT_PLUGIN_RUNTIME_*_TOKEN).
57
+ pathPrefix: `${lib.AGENT_PLUGIN_RUNTIME_HOME_TOKEN}/`,
58
+ projectDirPrefix: `${lib.AGENT_PLUGIN_RUNTIME_DIR_TOKEN}/`,
59
+ attribution: undefined, // keep the documents' default attribution — no runtime to consult
60
+ };
61
+
62
+ /**
63
+ * Refuse to wipe a directory that is not a previous bundle build (same rule as
64
+ * build-plugin.js). Empty or absent directories, and our own previous output —
65
+ * recognised by a manifest carrying the Agent Plugins schema — are fair game.
66
+ */
67
+ function assertSafeToReplace(dir) {
68
+ if (!fs.existsSync(dir)) return;
69
+ if (fs.readdirSync(dir).length === 0) return;
70
+ try {
71
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8'));
72
+ if (manifest && manifest.$schema === lib.AGENT_PLUGIN_MANIFEST_SCHEMA) return;
73
+ } catch { /* fall through to refusal */ }
74
+ throw new Error(`build-agent-plugin: refusing to replace ${dir} — it is non-empty and does not look like a previous bundle build (no Agent Plugins plugin.json)`);
75
+ }
76
+
77
+ /** commands/pan/**.md → skills/pan-<name>/SKILL.md, mirroring the installer's recursion. */
78
+ function emitSkills(srcDir, skillsDir, prefix) {
79
+ let count = 0;
80
+ (function recurse(currentSrc, currentPrefix) {
81
+ for (const entry of fs.readdirSync(currentSrc, { withFileTypes: true })) {
82
+ const srcPath = path.join(currentSrc, entry.name);
83
+ if (entry.isDirectory()) { recurse(srcPath, `${currentPrefix}-${entry.name}`); continue; }
84
+ if (!entry.name.endsWith('.md')) continue;
85
+ const skillName = `${currentPrefix}-${entry.name.replace(/\.md$/, '')}`;
86
+ const skillDir = path.join(skillsDir, skillName);
87
+ fs.mkdirSync(skillDir, { recursive: true });
88
+ let content = fs.readFileSync(srcPath, 'utf8');
89
+ content = lib.rewriteUnifiedSkillCommandContent(content, REWRITE);
90
+ content = lib.convertClaudeCommandToUnifiedSkill(content, skillName, { adapterNote: lib.agentPluginSkillAdapterNote() });
91
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
92
+ count++;
93
+ }
94
+ })(srcDir, prefix);
95
+ return count;
96
+ }
97
+
98
+ /** pan-wizard-core → bundle, markdown rewritten, everything else verbatim. */
99
+ function emitCore(srcDir, destDir) {
100
+ (function recurse(currentSrc, currentDest) {
101
+ fs.mkdirSync(currentDest, { recursive: true });
102
+ for (const entry of fs.readdirSync(currentSrc, { withFileTypes: true })) {
103
+ const srcPath = path.join(currentSrc, entry.name);
104
+ const destPath = path.join(currentDest, entry.name);
105
+ if (entry.isDirectory()) recurse(srcPath, destPath);
106
+ else if (entry.name.endsWith('.md')) fs.writeFileSync(destPath, lib.rewriteSharedCoreMarkdown(fs.readFileSync(srcPath, 'utf8'), REWRITE));
107
+ else fs.copyFileSync(srcPath, destPath);
108
+ }
109
+ })(srcDir, destDir);
110
+
111
+ // learnings/internal is source-only — strip the files AND the index entries,
112
+ // exactly as the installer and the Claude plugin builder do.
113
+ fs.rmSync(path.join(destDir, 'learnings', 'internal'), { recursive: true, force: true });
114
+ const indexPath = path.join(destDir, 'learnings', 'index.json');
115
+ try {
116
+ const stripped = lib.stripInternalLearningsTopics(JSON.parse(fs.readFileSync(indexPath, 'utf8')));
117
+ if (stripped) fs.writeFileSync(indexPath, JSON.stringify(stripped, null, 2) + '\n');
118
+ } catch (err) {
119
+ if (err.code !== 'ENOENT') throw err;
120
+ }
121
+ fs.writeFileSync(path.join(destDir, 'VERSION'), pkg.version);
122
+ }
123
+
124
+ /** Canonical agent reference copies under <core>/agents/ (ADR-0028). */
125
+ function emitAgentReferenceCopies(agentsSrc, agentsRefDir) {
126
+ fs.mkdirSync(agentsRefDir, { recursive: true });
127
+ let count = 0;
128
+ for (const f of fs.readdirSync(agentsSrc).filter(n => n.endsWith('.md'))) {
129
+ fs.writeFileSync(path.join(agentsRefDir, f), lib.rewriteAgentReferenceCopy(fs.readFileSync(path.join(agentsSrc, f), 'utf8'), TOKEN_PREFIX));
130
+ count++;
131
+ }
132
+ return count;
133
+ }
134
+
135
+ /** Hook scripts: the built copies from hooks/dist when present, else the pure-Node sources. */
136
+ function emitHookScripts(destDir) {
137
+ fs.mkdirSync(destDir, { recursive: true });
138
+ const dist = path.join(ROOT, 'hooks', 'dist');
139
+ const src = fs.existsSync(dist) ? dist : path.join(ROOT, 'hooks');
140
+ const names = fs.readdirSync(src).filter(n => /^pan-[a-z-]+\.js$/.test(n)).sort();
141
+ for (const n of names) fs.copyFileSync(path.join(src, n), path.join(destDir, n));
142
+ return names;
143
+ }
144
+
145
+ /** The four hook commands, anchored at the plugin root through whichever variable the consumer expands. */
146
+ function hookCommands(rootVar) {
147
+ const cmd = (script) => `node ${rootVar}/hooks/${script}`;
148
+ return {
149
+ updateCheckCommand: cmd('pan-check-update.js'),
150
+ contextMonitorCommand: cmd('pan-context-monitor.js'),
151
+ costLoggerCommand: cmd('pan-cost-logger.js'),
152
+ traceLoggerCommand: cmd('pan-trace-logger.js'),
153
+ };
154
+ }
155
+
156
+ /** Copilot vendor directory: agents in Copilot's `.agent.md` format + plugin hooks. */
157
+ function emitCopilotNamespace(agentsSrc, nsDir) {
158
+ const agentsDest = path.join(nsDir, 'agents');
159
+ fs.mkdirSync(agentsDest, { recursive: true });
160
+ let agents = 0;
161
+ for (const f of fs.readdirSync(agentsSrc).filter(n => n.endsWith('.md'))) {
162
+ let content = fs.readFileSync(path.join(agentsSrc, f), 'utf8');
163
+ // Core references → the bundle token; mentions → /pan-<name>; then the same
164
+ // two steps the installer applies to a Copilot agent (thinking frontmatter
165
+ // strip, Copilot frontmatter/tool-name conversion).
166
+ content = lib.rewriteAgentReferenceCopy(content, TOKEN_PREFIX);
167
+ content = lib.stripThinkingFrontmatter(content, 'copilot');
168
+ content = lib.convertClaudeToCopilotAgent(content);
169
+ fs.writeFileSync(path.join(agentsDest, f.replace(/\.md$/, '.agent.md')), content);
170
+ agents++;
171
+ }
172
+ fs.mkdirSync(path.join(nsDir, 'hooks'), { recursive: true });
173
+ fs.writeFileSync(
174
+ path.join(nsDir, 'hooks', 'hooks.json'),
175
+ JSON.stringify(lib.buildCopilotPluginHooksConfig(hookCommands('${CLAUDE_PLUGIN_ROOT}')), null, 2) + '\n'
176
+ );
177
+ return agents;
178
+ }
179
+
180
+ function main() {
181
+ assertSafeToReplace(OUT);
182
+ fs.rmSync(OUT, { recursive: true, force: true });
183
+ fs.mkdirSync(OUT, { recursive: true });
184
+
185
+ // 1. Manifest (closed schema — nothing beyond the ten permitted keys)
186
+ fs.writeFileSync(path.join(OUT, 'plugin.json'), JSON.stringify(lib.buildAgentPluginManifest(pkg), null, 2) + '\n');
187
+
188
+ // 2. Skills
189
+ const skills = emitSkills(path.join(ROOT, 'commands', 'pan'), path.join(OUT, 'skills'), 'pan');
190
+
191
+ // 3. Core (+ 4. canonical agent copies inside it)
192
+ const coreDest = path.join(OUT, 'pan-wizard-core');
193
+ emitCore(path.join(ROOT, 'pan-wizard-core'), coreDest);
194
+ const agents = emitAgentReferenceCopies(path.join(ROOT, 'agents'), path.join(coreDest, 'agents'));
195
+
196
+ // 5. MCP declaration
197
+ fs.writeFileSync(path.join(OUT, 'mcp.json'), JSON.stringify(lib.buildAgentPluginMcpConfig(), null, 2) + '\n');
198
+
199
+ // 6. Hooks: scripts once, at the root; a Codex hooks.json at the documented
200
+ // default location (`hooks/hooks.json`, `${PLUGIN_ROOT}` expanded in commands,
201
+ // observers async — the same builder the installer uses for .codex/hooks.json).
202
+ const hooksDir = path.join(OUT, 'hooks');
203
+ const hookScripts = emitHookScripts(hooksDir);
204
+ fs.writeFileSync(
205
+ path.join(hooksDir, 'hooks.json'),
206
+ JSON.stringify(lib.mergeCodexHooksConfig(null, hookCommands('${PLUGIN_ROOT}')), null, 2) + '\n'
207
+ );
208
+
209
+ // 7. Copilot vendor namespace
210
+ const copilotAgents = emitCopilotNamespace(path.join(ROOT, 'agents'), path.join(OUT, lib.COPILOT_PLUGIN_NAMESPACE));
211
+
212
+ console.log('PAN Agent Plugins bundle built at', path.relative(ROOT, OUT) || OUT);
213
+ console.log(' skills:', skills);
214
+ console.log(' agent reference copies:', agents);
215
+ console.log(' hook scripts:', hookScripts.length);
216
+ console.log(` ${lib.COPILOT_PLUGIN_NAMESPACE}/agents:`, copilotAgents);
217
+ console.log(' version:', pkg.version);
218
+ }
219
+
220
+ main();
@@ -8,6 +8,9 @@
8
8
  * agents/pan-*.md agent definitions
9
9
  * hooks/hooks.json PAN hooks with ${CLAUDE_PLUGIN_ROOT} paths
10
10
  * hooks/pan-*.js hook scripts
11
+ * .mcp.json MCP bridge declaration (${CLAUDE_PLUGIN_ROOT} path)
12
+ * workflows/pan-*.js native workflow scripts, agentType namespaced
13
+ * `<plugin>:<agent>` (plugin agents load scoped)
11
14
  * pan-wizard-core/ dispatcher + modules + workflows + templates
12
15
  *
13
16
  * Distribution status: built ALONGSIDE the loose-file installer. Marketplace
@@ -38,7 +41,28 @@ const fs = require('fs');
38
41
  const path = require('path');
39
42
 
40
43
  const ROOT = path.join(__dirname, '..');
41
- const OUT = path.join(ROOT, 'dist', 'pan-wizard-plugin');
44
+ // Output directory. `PAN_PLUGIN_OUT` overrides the default so that callers which
45
+ // may run CONCURRENTLY — test files under `node --test`, which runs files in
46
+ // parallel — each build into their own directory instead of racing on one:
47
+ // one process's `rmSync` below landed in the middle of another's copy
48
+ // (ENOENT mid-tree, and an empty stdout for plugin-path.js) on 2026-09-10.
49
+ const OUT = process.env.PAN_PLUGIN_OUT
50
+ ? path.resolve(process.env.PAN_PLUGIN_OUT)
51
+ : path.join(ROOT, 'dist', 'pan-wizard-plugin');
52
+
53
+ /**
54
+ * Refuse to wipe a directory that is not a previous plugin build. The default
55
+ * path is ours by construction; an override is a user-supplied path, and
56
+ * `rmSync(recursive)` on the wrong one is unrecoverable. A directory that does
57
+ * not exist yet, is empty, or carries our own manifest is fair game.
58
+ */
59
+ function assertSafeToReplace(dir) {
60
+ if (!fs.existsSync(dir)) return;
61
+ const entries = fs.readdirSync(dir);
62
+ if (entries.length === 0) return;
63
+ if (fs.existsSync(path.join(dir, '.claude-plugin', 'plugin.json'))) return;
64
+ throw new Error(`build-plugin: refusing to replace ${dir} — it is non-empty and does not look like a previous plugin build (no .claude-plugin/plugin.json)`);
65
+ }
42
66
  const pkg = require(path.join(ROOT, 'package.json'));
43
67
  const lib = require(path.join(ROOT, 'bin', 'install-lib.cjs'));
44
68
 
@@ -73,13 +97,15 @@ function copyTree(srcDir, destDir, transformMd) {
73
97
 
74
98
  function main() {
75
99
  // Clean output
100
+ assertSafeToReplace(OUT);
76
101
  fs.rmSync(OUT, { recursive: true, force: true });
77
102
  fs.mkdirSync(path.join(OUT, '.claude-plugin'), { recursive: true });
78
103
 
79
104
  // 1. Manifest
105
+ const manifest = lib.buildPluginManifest(pkg);
80
106
  fs.writeFileSync(
81
107
  path.join(OUT, '.claude-plugin', 'plugin.json'),
82
- JSON.stringify(lib.buildPluginManifest(pkg), null, 2) + '\n'
108
+ JSON.stringify(manifest, null, 2) + '\n'
83
109
  );
84
110
 
85
111
  // 2. Commands (Claude flavor, plugin-root-relative paths)
@@ -109,7 +135,7 @@ function main() {
109
135
  // deliberately bypasses rewriteContent().
110
136
  fs.writeFileSync(
111
137
  path.join(OUT, 'commands', 'pan-plugin-selftest.md'),
112
- lib.buildPluginSelfTestCommand(CONTENT_PREFIX.replace(/\/$/, ''))
138
+ lib.buildPluginSelfTestCommand(CONTENT_PREFIX.replace(/\/$/, ''), manifest.name)
113
139
  );
114
140
 
115
141
  // 4b. MCP registration. The server itself rides along inside pan-wizard-core
@@ -125,12 +151,31 @@ function main() {
125
151
  fs.rmSync(path.join(OUT, 'pan-wizard-core', 'learnings', 'internal'), { recursive: true, force: true });
126
152
  fs.writeFileSync(path.join(OUT, 'pan-wizard-core', 'VERSION'), pkg.version);
127
153
 
154
+ // 6. Native workflows. A plugin loads `workflows/` at its root and exposes each
155
+ // script as `/<plugin>:<meta.name>`. Until 2026-09 the builder never wrote this
156
+ // directory, so the plugin shipped LESS than a loose-file install (which has
157
+ // written `.claude/workflows/` since 2026-06). One thing differs from that
158
+ // install: the scripts spawn PAN agents by name, and plugin agents load under a
159
+ // SCOPED name — `agents/pan-reviewer.md` here is `pan-wizard:pan-reviewer`
160
+ // (plugins-reference, read 2026-09-10) — so a bare `agentType: 'pan-…'` that
161
+ // resolves in a loose install would not resolve inside the plugin. The rewrite
162
+ // is applied to the plugin copy only; the installer keeps bare names.
163
+ fs.mkdirSync(path.join(OUT, 'workflows'), { recursive: true });
164
+ const workflowScripts = lib.buildNativeWorkflowScripts();
165
+ for (const { name, content } of workflowScripts) {
166
+ fs.writeFileSync(
167
+ path.join(OUT, 'workflows', name),
168
+ lib.namespaceWorkflowAgentTypes(content, manifest.name)
169
+ );
170
+ }
171
+
128
172
  // Sanity report
129
173
  const count = (p) => { try { return fs.readdirSync(p).length; } catch { return 0; } };
130
174
  console.log('PAN plugin built at', path.relative(ROOT, OUT));
131
175
  console.log(' commands/pan:', count(path.join(OUT, 'commands', 'pan')));
132
176
  console.log(' agents:', count(path.join(OUT, 'agents')));
133
177
  console.log(' hooks:', count(path.join(OUT, 'hooks')));
178
+ console.log(' workflows:', workflowScripts.length);
134
179
  console.log(' version:', pkg.version);
135
180
  }
136
181
 
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * coverage-gate.cjs — did the shipped code actually run under the tests?
5
+ *
6
+ * Runs the whole suite under Node's own coverage instrumentation (no dependency:
7
+ * `node --test --experimental-test-coverage`, lcov reporter; the child processes
8
+ * the tests spawn — pan-tools, the installer, the hooks — are captured through the
9
+ * inherited NODE_V8_COVERAGE), then enforces:
10
+ * - line and function floors overall and per module group (tests/fixtures/
11
+ * coverage-policy.json — a floor sits a point below the measured baseline, so
12
+ * a real regression fails and normal churn does not);
13
+ * - every dispatcher `case` arm executed at least once — the binary rule that
14
+ * catches "a verb no test dispatches", which a percentage hides. An arm may be
15
+ * allowlisted in the policy with a reason (interactive, network, a P2 item).
16
+ * The never-called functions are printed, ranked, so a gap has a name.
17
+ *
18
+ * node scripts/coverage-gate.cjs run the suite, evaluate, exit 1 on a violation
19
+ * node scripts/coverage-gate.cjs --lcov f evaluate an existing lcov file (no run)
20
+ * node scripts/coverage-gate.cjs --json machine-readable result
21
+ *
22
+ * Node < 22 lacks the coverage include/exclude flags: the gate reports "skipped"
23
+ * and exits 0 there, so the 18/20 CI jobs stay green and the 22 job carries it.
24
+ * Wired as release-check Gate 9 and as an advisory CI step on the Node 22 job.
25
+ */
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const { spawnSync } = require('child_process');
30
+ const { parseCaseArms } = require('./test-surface.cjs');
31
+
32
+ const ROOT = path.resolve(__dirname, '..');
33
+ const POLICY_REL = path.join('tests', 'fixtures', 'coverage-policy.json');
34
+ const DISPATCHER_REL = 'pan-wizard-core/bin/pan-tools.cjs';
35
+ const TEST_DIRS = ['tests', 'tests/scenarios'];
36
+ const INCLUDE = ['pan-wizard-core/**/*.cjs', 'pan-wizard-core/**/*.js', 'bin/**', 'hooks/*.js', 'scripts/**'];
37
+ const EXCLUDE = ['tests/**', '**/node_modules/**'];
38
+ const MIN_NODE_MAJOR = 22;
39
+
40
+ const DEFAULT_POLICY = Object.freeze({
41
+ floors: { overall_lines: 92, overall_functions: 93, groups: { lib: 92, installer: 90, hooks: 90, mcp: 95 } },
42
+ arms_allow: [],
43
+ });
44
+
45
+ // ─── lcov ───────────────────────────────────────────────────────────────────
46
+
47
+ /** Parse lcov text into [{ path, lines: Map(line→hits), fn: Map(name→line), fnda: Map(name→hits), lf, lh, brf, brh }]. */
48
+ function parseLcov(text) {
49
+ const files = [];
50
+ let cur = null;
51
+ for (const raw of String(text || '').split(/\r?\n/)) {
52
+ const ci = raw.indexOf(':');
53
+ const k = ci >= 0 ? raw.slice(0, ci) : raw;
54
+ const v = ci >= 0 ? raw.slice(ci + 1) : '';
55
+ if (k === 'SF') { cur = { path: v.split('\\').join('/'), lines: new Map(), fn: new Map(), fnda: new Map(), lf: 0, lh: 0, brf: 0, brh: 0 }; continue; }
56
+ if (!cur) continue;
57
+ if (k === 'DA') { const [ln, c] = v.split(',').map(Number); cur.lines.set(ln, c); }
58
+ else if (k === 'FN') { const i = v.indexOf(','); cur.fn.set(v.slice(i + 1), Number(v.slice(0, i))); }
59
+ else if (k === 'FNDA') { const i = v.indexOf(','); cur.fnda.set(v.slice(i + 1), Number(v.slice(0, i))); }
60
+ else if (k === 'LF') cur.lf = Number(v); else if (k === 'LH') cur.lh = Number(v);
61
+ else if (k === 'BRF') cur.brf = Number(v); else if (k === 'BRH') cur.brh = Number(v);
62
+ else if (raw === 'end_of_record') { files.push(cur); cur = null; }
63
+ }
64
+ return files;
65
+ }
66
+
67
+ function relPath(p, root = ROOT) {
68
+ const r = root.split('\\').join('/');
69
+ const i = p.indexOf(r);
70
+ return i >= 0 ? p.slice(i + r.length).replace(/^\//, '') : p;
71
+ }
72
+
73
+ function groupOf(rel) {
74
+ if (/^pan-wizard-core\/bin\/lib\//.test(rel)) return 'lib';
75
+ if (/^pan-wizard-core\/mcp\//.test(rel)) return 'mcp';
76
+ if (/^pan-wizard-core\/bin\//.test(rel)) return 'cli';
77
+ if (/^pan-wizard-core\/workflows\//.test(rel)) return 'native-workflows';
78
+ if (/^bin\//.test(rel)) return 'installer';
79
+ if (/^hooks\//.test(rel)) return 'hooks';
80
+ if (/^scripts\//.test(rel)) return 'scripts';
81
+ return 'other';
82
+ }
83
+
84
+ /**
85
+ * Which case arms executed. An arm is executed when the first instrumented line
86
+ * after its label (before the next arm at the same or a shallower indent) ran; a
87
+ * label immediately followed by another label shares that arm's body (fallthrough).
88
+ */
89
+ function armCoverage(dispatcherSrc, dispatcherFile) {
90
+ const arms = parseCaseArms(dispatcherSrc);
91
+ const byLine = new Map(arms.map((a) => [a.line, a]));
92
+ const lines = dispatcherSrc.split(/\r?\n/);
93
+ const da = dispatcherFile ? dispatcherFile.lines : new Map();
94
+ const result = [];
95
+ for (const a of arms) {
96
+ let verdict = null;
97
+ for (let ln = a.line + 1; ln <= lines.length; ln++) {
98
+ const nxt = byLine.get(ln);
99
+ if (nxt && nxt.indent <= a.indent) {
100
+ if (nxt.indent === a.indent && /^\s*case\s+'/.test(lines[ln - 1])) verdict = 'fallthrough';
101
+ break;
102
+ }
103
+ if (da.has(ln)) { verdict = da.get(ln) > 0; break; }
104
+ }
105
+ result.push({ id: a.parent ? `${a.parent} > ${a.label}` : a.label, line: a.line, verdict });
106
+ }
107
+ // A fallthrough label takes the verdict of the arm it shares.
108
+ for (let i = 0; i < result.length; i++) {
109
+ if (result[i].verdict === 'fallthrough') {
110
+ let j = i + 1;
111
+ while (j < result.length && result[j].verdict === 'fallthrough') j++;
112
+ result[i].verdict = j < result.length ? result[j].verdict : null;
113
+ }
114
+ }
115
+ return result;
116
+ }
117
+
118
+ function validatePolicy(policy) {
119
+ const errors = [];
120
+ for (const a of policy.arms_allow || []) {
121
+ if (!a || typeof a.arm !== 'string') errors.push(`arms_allow entry without an arm: ${JSON.stringify(a)}`);
122
+ else if (!a.reason || !String(a.reason).trim()) errors.push(`arms_allow entry "${a.arm}" has no reason`);
123
+ }
124
+ return errors;
125
+ }
126
+
127
+ /**
128
+ * Evaluate parsed lcov against the policy and the dispatcher source.
129
+ * Pure. Returns { ok, violations[], groups, overall, arms, never_called, policy_errors }.
130
+ */
131
+ function evaluateCoverage(lcovFiles, { dispatcherSrc, policy = DEFAULT_POLICY, root = ROOT } = {}) {
132
+ const violations = [];
133
+ const policyErrors = validatePolicy(policy);
134
+ violations.push(...policyErrors.map((e) => `policy: ${e}`));
135
+ if (!lcovFiles.length) violations.push('lcov parsed no files — malformed or empty coverage output (failing closed)');
136
+
137
+ const groups = {};
138
+ const overall = { lf: 0, lh: 0, ff: 0, fh: 0 };
139
+ const neverCalled = [];
140
+ let dispatcherFile = null;
141
+ for (const f of lcovFiles) {
142
+ const rel = relPath(f.path, root);
143
+ if (rel.endsWith(DISPATCHER_REL) || rel === DISPATCHER_REL) dispatcherFile = f;
144
+ const g = groupOf(rel);
145
+ const fns = [...f.fn.keys()];
146
+ const fh = fns.filter((n) => (f.fnda.get(n) || 0) > 0).length;
147
+ const acc = groups[g] = groups[g] || { files: 0, lf: 0, lh: 0, ff: 0, fh: 0 };
148
+ acc.files++; acc.lf += f.lf; acc.lh += f.lh; acc.ff += fns.length; acc.fh += fh;
149
+ overall.lf += f.lf; overall.lh += f.lh; overall.ff += fns.length; overall.fh += fh;
150
+ for (const n of fns) if (!((f.fnda.get(n) || 0) > 0)) neverCalled.push(`${rel}:${f.fn.get(n)} ${n || '(anonymous)'}`);
151
+ }
152
+ const pct = (h, t) => (t ? Math.round((h / t) * 1000) / 10 : 100);
153
+ const overallPct = { lines: pct(overall.lh, overall.lf), functions: pct(overall.fh, overall.ff) };
154
+ const floors = policy.floors || DEFAULT_POLICY.floors;
155
+ if (lcovFiles.length) {
156
+ if (overallPct.lines < floors.overall_lines) violations.push(`overall line coverage ${overallPct.lines}% is below the floor ${floors.overall_lines}%`);
157
+ if (overallPct.functions < floors.overall_functions) violations.push(`overall function coverage ${overallPct.functions}% is below the floor ${floors.overall_functions}%`);
158
+ for (const [g, floor] of Object.entries(floors.groups || {})) {
159
+ const acc = groups[g];
160
+ if (!acc) { violations.push(`group "${g}" has no files under coverage — include globs or layout changed`); continue; }
161
+ const p = pct(acc.lh, acc.lf);
162
+ if (p < floor) violations.push(`${g} line coverage ${p}% is below the floor ${floor}%`);
163
+ }
164
+ }
165
+
166
+ let arms = [];
167
+ if (dispatcherSrc) {
168
+ arms = armCoverage(dispatcherSrc, dispatcherFile);
169
+ const allow = new Map((policy.arms_allow || []).map((a) => [a.arm, a.reason]));
170
+ if (!dispatcherFile && lcovFiles.length) violations.push('the dispatcher was not in the coverage output — no test ran pan-tools.cjs?');
171
+ for (const a of arms) {
172
+ if (a.verdict === true) continue;
173
+ if (allow.has(a.id)) { a.allowlisted = allow.get(a.id); continue; }
174
+ violations.push(`dispatcher arm never executed: ${a.id} (line ${a.line}) — add a test that dispatches it, or allowlist it in ${POLICY_REL} with a reason`);
175
+ }
176
+ for (const [arm] of allow) if (!arms.some((a) => a.id === arm)) violations.push(`policy: arms_allow names an arm that no longer exists: ${arm}`);
177
+ for (const [arm, reason] of allow) { const a = arms.find((x) => x.id === arm); if (a && a.verdict === true) violations.push(`policy: arm "${arm}" is executed now — remove its allowlist entry (${reason})`); }
178
+ }
179
+
180
+ const groupTable = Object.fromEntries(Object.entries(groups).map(([g, a]) => [g, { files: a.files, lines: pct(a.lh, a.lf), functions: pct(a.fh, a.ff) }]));
181
+ return { ok: violations.length === 0, violations, overall: overallPct, groups: groupTable, arms, never_called: neverCalled.sort(), policy_errors: policyErrors };
182
+ }
183
+
184
+ // ─── Running the suite ──────────────────────────────────────────────────────
185
+
186
+ function expandTestFiles(root = ROOT, dirs = TEST_DIRS) {
187
+ const files = [];
188
+ for (const dir of dirs) {
189
+ const abs = path.join(root, dir);
190
+ let entries = [];
191
+ try { entries = fs.readdirSync(abs); } catch { continue; }
192
+ for (const f of entries) if (f.endsWith('.test.cjs')) files.push(path.join(abs, f));
193
+ }
194
+ return files.sort();
195
+ }
196
+
197
+ function runSuiteWithCoverage(root = ROOT, lcovPath) {
198
+ const specLog = lcovPath + '.spec.log';
199
+ const args = ['--test', '--experimental-test-coverage'];
200
+ for (const g of INCLUDE) args.push(`--test-coverage-include=${g}`);
201
+ for (const g of EXCLUDE) args.push(`--test-coverage-exclude=${g}`);
202
+ args.push('--test-reporter=lcov', `--test-reporter-destination=${lcovPath}`, '--test-reporter=spec', `--test-reporter-destination=${specLog}`);
203
+ args.push(...expandTestFiles(root));
204
+ const r = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
205
+ return { status: r.status, specLog, stderr: r.stderr || '' };
206
+ }
207
+
208
+ function loadPolicy(root = ROOT) {
209
+ try { return JSON.parse(fs.readFileSync(path.join(root, POLICY_REL), 'utf8')); } catch { return DEFAULT_POLICY; }
210
+ }
211
+
212
+ function render(result) {
213
+ const lines = [];
214
+ lines.push(`coverage gate — ${result.ok ? 'OK' : 'FAIL'}`);
215
+ lines.push(` overall: lines ${result.overall.lines}% · functions ${result.overall.functions}%`);
216
+ for (const [g, a] of Object.entries(result.groups).sort()) lines.push(` ${g.padEnd(18)} files ${String(a.files).padStart(3)} lines ${String(a.lines).padStart(5)}% functions ${String(a.functions).padStart(5)}%`);
217
+ const executed = result.arms.filter((a) => a.verdict === true).length;
218
+ const allowed = result.arms.filter((a) => a.allowlisted).length;
219
+ lines.push(` dispatcher arms: ${executed}/${result.arms.length} executed${allowed ? `, ${allowed} allowlisted` : ''}`);
220
+ if (result.never_called.length) {
221
+ lines.push(` never-called functions: ${result.never_called.length} (first 12)`);
222
+ for (const n of result.never_called.slice(0, 12)) lines.push(` ${n}`);
223
+ }
224
+ for (const v of result.violations) lines.push(` ✖ ${v}`);
225
+ return lines.join('\n');
226
+ }
227
+
228
+ function main(argv) {
229
+ const major = Number(process.versions.node.split('.')[0]);
230
+ const lcovArg = argv.includes('--lcov') ? argv[argv.indexOf('--lcov') + 1] : null;
231
+ if (!lcovArg && major < MIN_NODE_MAJOR) {
232
+ console.log(`coverage gate — skipped on Node ${process.versions.node} (needs ${MIN_NODE_MAJOR}+ for coverage include/exclude flags)`);
233
+ return 0;
234
+ }
235
+ let lcovPath = lcovArg;
236
+ let tmp = null;
237
+ if (!lcovPath) {
238
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-coverage-'));
239
+ lcovPath = path.join(tmp, 'coverage.lcov');
240
+ const r = runSuiteWithCoverage(ROOT, lcovPath);
241
+ if (r.status !== 0) {
242
+ console.error(`coverage gate — the suite itself failed (exit ${r.status}); see ${r.specLog}`);
243
+ return 1;
244
+ }
245
+ }
246
+ let text = '';
247
+ try { text = fs.readFileSync(lcovPath, 'utf8'); } catch (e) { console.error(`coverage gate — cannot read ${lcovPath}: ${e.message}`); return 1; }
248
+ const result = evaluateCoverage(parseLcov(text), { dispatcherSrc: fs.readFileSync(path.join(ROOT, DISPATCHER_REL), 'utf8'), policy: loadPolicy(ROOT), root: ROOT });
249
+ if (argv.includes('--json')) console.log(JSON.stringify(result, null, 2));
250
+ else console.log(render(result));
251
+ if (tmp && !argv.includes('--keep')) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } }
252
+ return result.ok ? 0 : 1;
253
+ }
254
+
255
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
256
+
257
+ module.exports = { parseLcov, groupOf, armCoverage, evaluateCoverage, validatePolicy, expandTestFiles, render, DEFAULT_POLICY, POLICY_REL, INCLUDE, EXCLUDE, MIN_NODE_MAJOR };
@@ -48,7 +48,7 @@ GROUP_ORDER = [
48
48
  # Dev skill categorization (filename -> category)
49
49
  DEV_CATEGORIES = {
50
50
  "Development Workflow": [
51
- "pandev", "execplan", "superplan", "featureAI", "review",
51
+ "pandev", "execplan", "superplan", "featureAI", "review", "reality-check",
52
52
  ],
53
53
  "Testing & Verification": [
54
54
  "test", "quick", "pantest", "check", "check-platform", "auditai",
@@ -52,6 +52,11 @@ try {
52
52
 
53
53
  // 3. Confirm the hook file is executable on Unix. On Windows the bit doesn't
54
54
  // matter — Git Bash treats `.sh` and shebanged scripts as executable.
55
+ // The file is TRACKED as 100755, so this is a safety net rather than the source
56
+ // of truth: it used to be tracked 100644, and since npm ci runs this script
57
+ // through `prepare`, every Linux and macOS checkout was left with a one-bit dirty
58
+ // tree that nothing looked at until CI began asserting the tree is unchanged
59
+ // (2026-09-17).
55
60
  const hookFile = path.join(REPO_ROOT, HOOKS_DIR, 'pre-commit');
56
61
  if (process.platform !== 'win32') {
57
62
  try {