pan-wizard 3.27.0 → 3.28.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.
@@ -15,6 +15,7 @@ const {
15
15
  BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
16
16
  } = require('./constants.cjs');
17
17
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
18
+ const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
18
19
  // Drift detection lives in verify-drift.cjs; re-exported below so consumers of
19
20
  // verify.cjs are unaffected by the decomposition.
20
21
  const { runDriftCheck, parseConventionRules, checkFileConventions, calculateDriftScore, getChangedFiles, cmdDriftCheck } = require('./verify-drift.cjs');
@@ -1293,13 +1294,29 @@ function cmdValidateHealth(cwd, options, raw) {
1293
1294
 
1294
1295
  // Check 1: .planning/ exists (fatal if missing -- skip remaining checks)
1295
1296
  if (!checkPlanningDirExists(cwd, addIssue)) {
1297
+ // A verdict payload carries `errors[]`, which is OUTSIDE output()'s error family
1298
+ // (plural collections are detail, not a failure signal — see CLI-REFERENCE "Error
1299
+ // Shape"), so the exit code must be set explicitly here, as `reconcile` does.
1300
+ // `broken` → 1. Reality check RC2 (2026-09-10): this site exited 0 for a missing
1301
+ // .planning/, so an orchestrator gating on the exit code read it as healthy.
1296
1302
  output({
1297
1303
  status: HEALTH_STATUS.BROKEN,
1298
1304
  errors,
1299
1305
  warnings,
1300
1306
  info,
1301
1307
  repairable_count: 0,
1302
- }, raw);
1308
+ }, raw, undefined, 1);
1309
+ return;
1310
+ }
1311
+
1312
+ // Check 1b: the tree exists but belongs to another tool (gsd-core shares the
1313
+ // directory name and PAN's legacy uppercase file names). Report that as its own
1314
+ // error and stop: E002-E005 would describe a foreign layout as a broken PAN one,
1315
+ // and --repair must never write into it. Reality check R15.
1316
+ const foreign = detectForeignPlanningTree(planningPath(cwd));
1317
+ if (foreign) {
1318
+ addIssue('error', 'E006', `planning tree belongs to ${foreign.tool}: ${foreign.evidence.join(', ')}`, 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)');
1319
+ output({ status: HEALTH_STATUS.BROKEN, errors, warnings, info, repairable_count: 0 }, raw, undefined, 1);
1303
1320
  return;
1304
1321
  }
1305
1322
 
@@ -1418,7 +1435,10 @@ function cmdValidateHealth(cwd, options, raw) {
1418
1435
  result.link_graph = linkGraphResult;
1419
1436
  }
1420
1437
 
1421
- output(result, raw);
1438
+ // Explicit verdict exit: `broken` → 1; `degraded` and `healthy` → 0 (warnings are
1439
+ // not failures). Computed AFTER --repair ran, so the code reflects the post-repair
1440
+ // state the JSON reports. See the note at the early-return site above.
1441
+ output(result, raw, undefined, status === HEALTH_STATUS.BROKEN ? 1 : 0);
1422
1442
  }
1423
1443
 
1424
1444
  /**
@@ -50,7 +50,36 @@ const META_SERVER_INFO_KEY = 'io.modelcontextprotocol/serverInfo';
50
50
  // claim to speak a version we don't. Newest first (the `server/discover` order).
51
51
  const SUPPORTED_VERSIONS_LIST = [MODERN_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05'];
52
52
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(SUPPORTED_VERSIONS_LIST);
53
- const SERVER_INFO = { name: 'pan-mcp', version: '0.1.0' };
53
+ /**
54
+ * The version the server reports in `initialize` / `server/discover`. Read from the
55
+ * package.json two levels up: the repository root in the source tree, the runtime
56
+ * directory in an install (the installer writes package.json beside pan-wizard-core/).
57
+ * The plugin bundles carry no package.json there, so fall back to the plugin manifest
58
+ * and finally to a marker that is visibly not a release. Never throws: a missing
59
+ * file must not stop the server from answering. Reality check R9: this was a literal
60
+ * '0.1.0' while the package shipped 3.x.
61
+ */
62
+ function readPackageVersion(baseDir = path.join(__dirname, '..', '..')) {
63
+ // Order: the repo/runtime package.json when it carries a version; the install
64
+ // manifest every runtime writes (the runtime directory's package.json is a bare
65
+ // `{"type":"commonjs"}` marker with no version — measured on a fresh install,
66
+ // 2026-09-10, where the first version of this reader answered 0.0.0-unknown); the
67
+ // Claude plugin manifest; an Agent Plugins manifest.
68
+ const candidates = [
69
+ path.join(baseDir, 'package.json'),
70
+ path.join(baseDir, 'pan-file-manifest.json'),
71
+ path.join(baseDir, '.claude-plugin', 'plugin.json'),
72
+ path.join(baseDir, 'plugin.json'),
73
+ ];
74
+ for (const file of candidates) {
75
+ try {
76
+ const v = JSON.parse(fs.readFileSync(file, 'utf8')).version;
77
+ if (typeof v === 'string' && v.trim()) return v.trim();
78
+ } catch { /* try the next candidate */ }
79
+ }
80
+ return '0.0.0-unknown';
81
+ }
82
+ const SERVER_INFO = { name: 'pan-mcp', version: readPackageVersion() };
54
83
 
55
84
  /**
56
85
  * Default engine location: `bin/` is a sibling of this `mcp/` directory inside
@@ -75,6 +104,25 @@ function defaultPanToolsPath() {
75
104
  return path.join(__dirname, '..', 'bin', 'pan-tools.cjs');
76
105
  }
77
106
 
107
+ /**
108
+ * A verdict payload: a JSON object with no error-family key. The family is `error`
109
+ * and any key ending in `_error` — the same definition core.cjs's reportsFailure()
110
+ * uses for the CLI exit code, mirrored here because the server stays engine-agnostic
111
+ * (it never requires the engine's modules; it spawns them). Plural collections such
112
+ * as `errors[]` are verdict DETAIL, not a failure signal. Returns the parsed object,
113
+ * or null when the text is not such a payload.
114
+ */
115
+ function parseVerdict(text) {
116
+ if (typeof text !== 'string' || !text.trim()) return null;
117
+ let parsed;
118
+ try { parsed = JSON.parse(text); } catch { return null; }
119
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
120
+ for (const k of Object.keys(parsed)) {
121
+ if (k === 'error' || k.endsWith('_error')) { if (parsed[k]) return null; }
122
+ }
123
+ return parsed;
124
+ }
125
+
78
126
  /** Real spawn: shell-less execFile of `node <argv...>`. */
79
127
  function defaultSpawn(nodeArgs) {
80
128
  try {
@@ -154,7 +202,7 @@ function createServer(opts = {}) {
154
202
  const gitImpl = opts.gitImpl || makeDefaultGit(cwd);
155
203
  const env = opts.env || process.env;
156
204
 
157
- function runVerb(verb, extraArgs) {
205
+ function runVerb(verb, extraArgs, verbCwd = cwd) {
158
206
  // Defense in depth: the verb always comes from the registry, but re-check the
159
207
  // forbidden pattern here so no future caller can smuggle a force/reset op past it.
160
208
  if (reg.FORBIDDEN_VERB.test(verb)) {
@@ -163,22 +211,46 @@ function createServer(opts = {}) {
163
211
  // No --raw: pan-tools' default output is structured JSON (which is what the MCP
164
212
  // client wants); --raw would instead emit a bare human scalar. Large results
165
213
  // arrive via the @file: overflow protocol, resolved here.
166
- const r = spawn([panToolsPath, verb, ...extraArgs, '--cwd', cwd]);
214
+ const r = spawn([panToolsPath, verb, ...extraArgs, '--cwd', verbCwd]);
167
215
  if (r && r.ok) r.stdout = resolveOverflow(r.stdout);
168
216
  return r;
169
217
  }
170
218
 
219
+ /**
220
+ * Per-call project root (ADR-0045 D6). Every TOOL accepts an optional `cwd`;
221
+ * it must be an absolute path to an existing directory. Returns
222
+ * { cwd, input } with the field removed from the input handed to the tool, or
223
+ * { error } shaped for a -32602 — a bad root is a bad REQUEST, and nothing is
224
+ * spawned. Resources never come through here: their argv is static.
225
+ */
226
+ function resolveCallCwd(input) {
227
+ const src = input || {};
228
+ if (src.cwd === undefined) return { cwd, input: src };
229
+ let candidate;
230
+ try { candidate = reg.validateProjectCwd(src.cwd); }
231
+ catch (e) { return { error: { code: -32602, message: String((e && e.message) || e) } }; }
232
+ let isDir = false;
233
+ try { isDir = fs.statSync(candidate).isDirectory(); } catch { /* absent → not a directory */ }
234
+ if (!isDir) return { error: { code: -32602, message: `Invalid "cwd": not an existing directory: ${candidate}` } };
235
+ const { cwd: _omit, ...rest } = src;
236
+ return { cwd: path.resolve(candidate), input: rest };
237
+ }
238
+
171
239
  // Returns { error:{code,message} } for JSON-RPC protocol errors (unknown tool /
172
240
  // invalid arguments — a bad *request*), or { result:{content,isError} } where
173
241
  // isError:true signals a genuine tool *execution* failure (the verb ran and failed).
174
242
  function callTool(name, input) {
175
243
  const tool = reg.byToolName[name];
176
244
  if (!tool) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
245
+ const call = resolveCallCwd(input);
246
+ if (call.error) return { error: call.error };
177
247
  // Native, in-process tools (orchestrator / merge gate) run a handler; a thrown
178
248
  // Error means bad params (-32602), matching the spawn-tool validation path.
249
+ // The git executor follows the per-call root unless a test injected one.
179
250
  if (typeof tool.handler === 'function') {
180
251
  try {
181
- const out = tool.handler({ cwd, input: input || {}, env, gitImpl });
252
+ const gitForCall = opts.gitImpl ? gitImpl : (call.cwd === cwd ? gitImpl : makeDefaultGit(call.cwd));
253
+ const out = tool.handler({ cwd: call.cwd, input: call.input, env, gitImpl: gitForCall });
182
254
  const text = (out && out.text != null) ? out.text : JSON.stringify(out && out.json);
183
255
  return { result: { content: [{ type: 'text', text }], isError: !!(out && out.isError) } };
184
256
  } catch (e) {
@@ -186,9 +258,9 @@ function createServer(opts = {}) {
186
258
  }
187
259
  }
188
260
  let extra;
189
- try { extra = tool.args ? tool.args(input || {}) : []; }
261
+ try { extra = tool.args ? tool.args(call.input) : []; }
190
262
  catch (e) { return { error: { code: -32602, message: String((e && e.message) || e) } }; }
191
- const r = runVerb(tool.verb, extra);
263
+ const r = runVerb(tool.verb, extra, call.cwd);
192
264
  return { result: { content: [{ type: 'text', text: r.ok ? r.stdout : (r.stderr || 'error') }], isError: !r.ok } };
193
265
  }
194
266
 
@@ -206,7 +278,19 @@ function createServer(opts = {}) {
206
278
  // non-array into the spawn.
207
279
  const tail = Array.isArray(res.args) ? res.args : [];
208
280
  const r = runVerb(res.verb, tail);
209
- if (!r.ok) return { error: { code: -32603, message: r.stderr || 'resource read failed' } };
281
+ if (!r.ok) {
282
+ // A VERDICT is data, not a failed read. `validate health` (pan://health) exits
283
+ // non-zero when its verdict is `broken` — CLI-REFERENCE: verdict commands set
284
+ // their exit code explicitly, for shell gating — while still printing the full
285
+ // JSON report. Over MCP the report IS the resource, so accept stdout when it is
286
+ // a JSON object carrying no error-family key. Anything else (no JSON, or an
287
+ // `error`/`*_error` key) is a genuine read failure → JSON-RPC error.
288
+ const text = resolveOverflow(r.stdout);
289
+ if (parseVerdict(text) !== null) {
290
+ return { result: { contents: [{ uri, mimeType: 'application/json', text }] } };
291
+ }
292
+ return { error: { code: -32603, message: r.stderr || 'resource read failed' } };
293
+ }
210
294
  return { result: { contents: [{ uri, mimeType: 'application/json', text: r.stdout }] } };
211
295
  }
212
296
 
@@ -315,6 +399,6 @@ function main() {
315
399
  if (require.main === module) main();
316
400
 
317
401
  module.exports = {
318
- createServer, defaultPanToolsPath, defaultSpawn, SERVER_INFO, toMcpTool, toMcpResource,
402
+ createServer, defaultPanToolsPath, defaultSpawn, parseVerdict, readPackageVersion, SERVER_INFO, toMcpTool, toMcpResource,
319
403
  PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, SUPPORTED_VERSIONS_LIST, META_PROTOCOL_VERSION_KEY,
320
404
  };
@@ -39,7 +39,9 @@ const QUERY_RE = /^[\w .,:/&()-]{1,120}$/; // find-phase query fragment
39
39
  *
40
40
  * THE RULE FOR ADDING ONE — a resource must be readable on ANY project, including
41
41
  * a bare directory with no `.planning/`. If "no data yet" is reported as an error
42
- * (non-zero exit / an error-family key), it is a TOOL, not a resource: a client
42
+ * (an error-family key in the JSON it prints, or no JSON at all — a non-zero exit BY
43
+ * ITSELF is a verdict signal for shell gating, and the reader accepts the JSON as data;
44
+ * see server.cjs readResource), it is a TOOL, not a resource: a client
43
45
  * that lists resources and reads them should not collect failures for a young
44
46
  * project. `preview` is the worked example — `preview phases` exits non-zero
45
47
  * without a roadmap, so it is exposed as a tool below rather than as a resource.
@@ -62,7 +64,7 @@ const RESOURCES = [
62
64
  description: 'Phase inventory: the phase directories present, with a count.' },
63
65
  { uri: 'pan://progress', name: 'Progress', verb: 'progress', description: 'Requirement and plan completion progress.' },
64
66
  { uri: 'pan://health', name: 'Project health', verb: 'validate', args: ['health'],
65
- description: 'Health check over .planning/: issue codes with severities. Reports an unhealthy project as DATA (exit 0), so it is readable even on a broken or empty one.' },
67
+ description: 'Health check over .planning/: issue codes with severities. Reports an unhealthy project as DATA: the JSON verdict is the resource even when the CLI exits non-zero for shell gating, so it is readable on a broken or empty project.' },
66
68
  { uri: 'pan://links', name: 'Doc-code links', verb: 'links', args: ['validate'],
67
69
  description: 'Doc↔code link graph verdict: forward links, backlink contracts, and anchor targets, with finding codes.' },
68
70
  { uri: 'pan://cost', name: 'Token cost', verb: 'cost', args: ['report'],
@@ -138,7 +140,51 @@ const FORBIDDEN_VERB = /(^|-)(push|reset|rebase|force)($|-)/;
138
140
  // tools into one advertised list. Required after SPAWN_TOOLS/FORBIDDEN_VERB so the
139
141
  // native module (which imports nothing back from here) composes cleanly — no cycle.
140
142
  const { NATIVE_TOOLS } = require('./native-tools.cjs');
141
- const TOOLS = [...SPAWN_TOOLS, ...NATIVE_TOOLS];
143
+
144
+ // ─── Per-call project root (ADR-0045 D6, 2026-09) ───────────────────────────
145
+ //
146
+ // The server resolves its project as `opts.cwd || PAN_PROJECT_ROOT || process.cwd()`.
147
+ // Under Claude Code the process cwd IS the project. Under an Agent Plugins client
148
+ // the spec makes the PLUGIN ROOT the default working directory of a stdio server,
149
+ // so every verb would read `.planning/` from inside the plugin cache and report an
150
+ // empty project — cleanly, which is the worst kind of failure. So every TOOL takes
151
+ // an optional `cwd`: the absolute path of the project to operate on, honoured for
152
+ // that call only. Applied here, centrally, so a tool added later cannot miss it.
153
+ //
154
+ // Resources deliberately do NOT get it: their argv is a static array and the
155
+ // safety argument of ADR-0041 is that no client input reaches it.
156
+ const PROJECT_CWD_PROPERTY = Object.freeze({
157
+ type: 'string',
158
+ description: 'Absolute path of the PAN project to operate on. Optional: defaults to the directory the server was started in. Pass it when the server was launched from a plugin directory (Agent Plugins clients do this by default), or to address another project.',
159
+ });
160
+
161
+ const PROJECT_CWD_MAX = 1024;
162
+
163
+ /**
164
+ * Shape-validate a per-call project root: a non-empty absolute path with no NUL,
165
+ * within a sane length. Existence is the SERVER's check (it has fs); this module
166
+ * stays pure. Throws a message fit for a -32602 on failure.
167
+ */
168
+ function validateProjectCwd(value) {
169
+ if (typeof value !== 'string' || value.length === 0 || value.length > PROJECT_CWD_MAX) {
170
+ throw new Error(`Invalid "cwd": must be a non-empty string of at most ${PROJECT_CWD_MAX} chars`);
171
+ }
172
+ if (value.includes('\0')) throw new Error('Invalid "cwd": contains a NUL byte');
173
+ // Absolute on either platform family: `/…`, `C:\…`, `C:/…`, or a UNC `\\host\share`.
174
+ if (!/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value)) throw new Error('Invalid "cwd": must be an absolute path');
175
+ return value;
176
+ }
177
+
178
+ /** Return a copy of a tool descriptor whose inputSchema also accepts `cwd`. Never mutates the source. */
179
+ function withProjectCwd(tool) {
180
+ const schema = tool.inputSchema || { type: 'object', additionalProperties: false, properties: {} };
181
+ return {
182
+ ...tool,
183
+ inputSchema: { ...schema, properties: { ...(schema.properties || {}), cwd: PROJECT_CWD_PROPERTY } },
184
+ };
185
+ }
186
+
187
+ const TOOLS = [...SPAWN_TOOLS, ...NATIVE_TOOLS].map(withProjectCwd);
142
188
 
143
189
  const byToolName = Object.create(null);
144
190
  for (const t of TOOLS) byToolName[t.name] = t;
@@ -148,4 +194,5 @@ for (const r of RESOURCES) byResourceUri[r.uri] = r;
148
194
  module.exports = {
149
195
  TOOLS, SPAWN_TOOLS, NATIVE_TOOLS, RESOURCES, byToolName, byResourceUri, FORBIDDEN_VERB,
150
196
  AGENT_RE, PHASE_RE, QUERY_RE, str,
197
+ PROJECT_CWD_PROPERTY, validateProjectCwd, withProjectCwd,
151
198
  };
@@ -11,8 +11,8 @@ PAN uses three abstract tiers instead of hardcoded model names:
11
11
  | Tier | Purpose | Anthropic | OpenAI | Google |
12
12
  |------|---------|-----------|--------|--------|
13
13
  | `reasoning` | Architecture, planning, complex decisions | inherit (your session's top-tier model) | inherit | inherit |
14
- | `mid` | Execution, research, verification | Sonnet | mid | mid |
15
- | `fast` | Read-only extraction, budget tasks | Haiku | fast | fast |
14
+ | `mid` | Execution, research, verification | Sonnet | mid | gemini-2.5-flash |
15
+ | `fast` | Read-only extraction, budget tasks | Haiku | fast | gemini-2.5-flash-lite |
16
16
 
17
17
  **Why `inherit` for reasoning?** Host runtimes map "opus" to a specific model version. PAN returns `inherit` for reasoning-tier agents, so they use whatever top-tier model the user has configured. This avoids version conflicts and silent fallbacks.
18
18
 
@@ -152,6 +152,7 @@ Report final status.
152
152
  | E003 | error | roadmap.md not found | No |
153
153
  | E004 | error | state.md not found | Yes |
154
154
  | E005 | error | config.json parse error | Yes |
155
+ | E006 | error | `.planning/` belongs to another tool (gsd-core markers found); PAN stops before E002-E005 and `--repair` writes nothing | No |
155
156
  | W001 | warning | project.md missing required section | No |
156
157
  | W002 | warning | state.md references invalid phase | Yes |
157
158
  | W003 | warning | config.json not found | Yes |
@@ -13,7 +13,7 @@ ZCode through the one interface it speaks: **MCP**.
13
13
 
14
14
  ## How it fits
15
15
 
16
- ```
16
+ ```text
17
17
  ZCode harness (GLM-5.2) primary Agent drives everything; ported subagents fan out
18
18
  │ MCP · local stdio
19
19
  pan-wizard-core/mcp (SHARED) a thin, zero-dep bridge — verbs → MCP tools/resources
@@ -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
 
@@ -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",