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.
- package/README.md +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/cost.md +14 -2
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +8 -2
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +39 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +174 -18
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/git.cjs +5 -1
- package/pan-wizard-core/bin/lib/hud.cjs +5 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +52 -24
- package/pan-wizard-core/bin/lib/init.cjs +8 -0
- package/pan-wizard-core/bin/lib/memory.cjs +14 -8
- package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
- package/pan-wizard-core/bin/lib/utils.cjs +22 -0
- package/pan-wizard-core/bin/lib/verify.cjs +46 -12
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +80 -13
- package/scripts/test-quality-lint.cjs +240 -0
- 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();
|
package/scripts/build-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 {
|