pan-wizard 3.24.0 → 3.26.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.
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * PAN-Z MCP bridge server (M1).
4
+ * PAN MCP bridge server.
5
5
  *
6
- * A dependency-free JSON-RPC 2.0 server over stdio implementing the small MCP
7
- * surface ZCode needs: server/discover / initialize / tools/list / tools/call /
6
+ * Canonical home: `pan-wizard-core/mcp/`, so it ships with the engine to every
7
+ * install and every runtime. It was originally written for the PAN-Z/ZCode
8
+ * preview (`pan-zcode/`), which remains a CONSUMER rather than the owner — the
9
+ * protocol layer is harness-neutral and must not be forked per consumer.
10
+ *
11
+ * A dependency-free JSON-RPC 2.0 server over stdio implementing a small MCP
12
+ * surface: server/discover / initialize / tools/list / tools/call /
8
13
  * resources/list / resources/read / ping. It is a DUAL-ERA server (see the MCP
9
14
  * 2026-07-28 versioning spec): legacy clients open with the `initialize`
10
15
  * handshake; modern clients (2026-07-28+) declare their protocol version in each
@@ -47,9 +52,27 @@ const SUPPORTED_VERSIONS_LIST = [MODERN_PROTOCOL_VERSION, '2025-06-18', '2025-03
47
52
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(SUPPORTED_VERSIONS_LIST);
48
53
  const SERVER_INFO = { name: 'pan-mcp', version: '0.1.0' };
49
54
 
50
- /** Default engine location: pan-wizard-core is a sibling of pan-zcode/. */
55
+ /**
56
+ * Default engine location: `bin/` is a sibling of this `mcp/` directory inside
57
+ * pan-wizard-core. That holds in the source repo AND in every install, because
58
+ * the installer copies pan-wizard-core wholesale, so the two stay siblings
59
+ * wherever the tree lands. Callers can still override via `opts.panToolsPath`
60
+ * (an out-of-tree engine, a test fixture, a pinned version) or PAN_TOOLS_PATH.
61
+ *
62
+ * This replaced `join(__dirname, '..', '..', 'pan-wizard-core', 'bin', …)`,
63
+ * carried over from when the module lived in `pan-zcode/mcp/`. Do NOT record
64
+ * that as a bug the relocation fixed — from this directory the two forms
65
+ * resolve to the identical path (the grandparent of `mcp/` contains
66
+ * `pan-wizard-core/` in both the source tree and an install). The old form is
67
+ * merely over-specified: it requires the grandparent to hold a directory
68
+ * *named* `pan-wizard-core`, so it breaks if the core is vendored or renamed,
69
+ * while the sibling form only requires the layout it actually depends on.
70
+ * Covered by the "engine path resolution" suite in tests/pan-zcode-mcp.test.cjs,
71
+ * which exists because every other test injects a spawn or passes an explicit
72
+ * path — so this function had zero coverage when the module moved.
73
+ */
51
74
  function defaultPanToolsPath() {
52
- return path.join(__dirname, '..', '..', 'pan-wizard-core', 'bin', 'pan-tools.cjs');
75
+ return path.join(__dirname, '..', 'bin', 'pan-tools.cjs');
53
76
  }
54
77
 
55
78
  /** Real spawn: shell-less execFile of `node <argv...>`. */
@@ -176,7 +199,13 @@ function createServer(opts = {}) {
176
199
  function readResource(uri) {
177
200
  const res = reg.byResourceUri[uri];
178
201
  if (!res) return { unknown: true };
179
- const r = runVerb(res.verb, []);
202
+ // A resource's argv tail is a STATIC array on its descriptor (for verbs whose
203
+ // read is a subcommand, e.g. `validate health`). It never derives from the
204
+ // request: resources take no client parameters, so there is no input path into
205
+ // this argv. Guard the type anyway — a descriptor typo must not spread a
206
+ // non-array into the spawn.
207
+ const tail = Array.isArray(res.args) ? res.args : [];
208
+ const r = runVerb(res.verb, tail);
180
209
  if (!r.ok) return { error: { code: -32603, message: r.stderr || 'resource read failed' } };
181
210
  return { result: { contents: [{ uri, mimeType: 'application/json', text: r.stdout }] } };
182
211
  }
@@ -28,12 +28,45 @@ const AGENT_RE = /^[a-z][a-z0-9_-]{1,60}$/; // agent type, e.g. pan-planner
28
28
  const PHASE_RE = /^[0-9]{1,3}$/; // phase number, e.g. 03
29
29
  const QUERY_RE = /^[\w .,:/&()-]{1,120}$/; // find-phase query fragment
30
30
 
31
- /** Read-only aggregators → MCP resources (no side effects). */
31
+ /**
32
+ * Read-only aggregators → MCP resources (no side effects).
33
+ *
34
+ * Optional `args` is a STATIC argv tail for verbs whose read lives in a
35
+ * subcommand (`validate health`, `links validate`, `cost report`). It is
36
+ * deliberately a fixed array and never a function of client input — a resource
37
+ * takes no parameters, and that is precisely what makes the surface safe: there
38
+ * is no path from an LLM tool-call to these argv elements.
39
+ *
40
+ * THE RULE FOR ADDING ONE — a resource must be readable on ANY project, including
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
43
+ * that lists resources and reads them should not collect failures for a young
44
+ * project. `preview` is the worked example — `preview phases` exits non-zero
45
+ * without a roadmap, so it is exposed as a tool below rather than as a resource.
46
+ * Check before adding: run the verb in an empty dir and read `$?`.
47
+ */
32
48
  const RESOURCES = [
33
49
  { uri: 'pan://state', name: 'Project state', verb: 'state', description: 'Current PAN project state snapshot derived from .planning/.' },
34
- { uri: 'pan://roadmap', name: 'Roadmap', verb: 'roadmap', description: 'The project roadmap: phases, goals, success criteria.' },
35
- { uri: 'pan://phases', name: 'Phases', verb: 'phases', description: 'Phase inventory with per-phase status.' },
50
+ // NOTE: there is deliberately no `pan://roadmap`. One existed and was DEAD from
51
+ // M1 until 2026-08 — its descriptor named the bare verb `roadmap`, which requires
52
+ // a subcommand, so every read returned "Unknown roadmap subcommand". Nothing
53
+ // caught it because the protocol tests inject a fake spawn, so no test had ever
54
+ // run a resource against the real engine. It is now `pan_roadmap_analyze` in
55
+ // TOOLS: `roadmap analyze` exits non-zero on a project with no roadmap.md, which
56
+ // fails the resource rule above. Removing the URI breaks no consumer — no
57
+ // consumer can have depended on a read that always errored.
58
+ // `phases` alone is not a verb — the subcommand is `list`. This descriptor named
59
+ // the bare verb and was DEAD from M1 alongside pan://roadmap, for the same reason
60
+ // and found by the same test. Returns {directories, count}.
61
+ { uri: 'pan://phases', name: 'Phases', verb: 'phases', args: ['list'],
62
+ description: 'Phase inventory: the phase directories present, with a count.' },
36
63
  { uri: 'pan://progress', name: 'Progress', verb: 'progress', description: 'Requirement and plan completion progress.' },
64
+ { 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.' },
66
+ { uri: 'pan://links', name: 'Doc-code links', verb: 'links', args: ['validate'],
67
+ description: 'Doc↔code link graph verdict: forward links, backlink contracts, and anchor targets, with finding codes.' },
68
+ { uri: 'pan://cost', name: 'Token cost', verb: 'cost', args: ['report'],
69
+ description: 'Aggregated token spend from the .planning/metrics ledger. Reads as zeros on a project with no recorded calls.' },
37
70
  ];
38
71
 
39
72
  /** Actionable pan-tools verbs → MCP tools (each spawns `node pan-tools.cjs <verb>`). */
@@ -58,6 +91,30 @@ const SPAWN_TOOLS = [
58
91
  },
59
92
  args: (i) => [str('query', i && i.query, QUERY_RE, 120)],
60
93
  },
94
+ {
95
+ name: 'pan_roadmap_analyze', title: 'Analyze the roadmap', verb: 'roadmap',
96
+ description: 'The roadmap read: milestones, phases, goals and success criteria with completion analysis. A tool rather than a resource because it reports a project with no roadmap.md as an error.',
97
+ readOnly: true, destructive: false,
98
+ inputSchema: { type: 'object', additionalProperties: false, properties: {} },
99
+ args: () => ['analyze'],
100
+ },
101
+ {
102
+ name: 'pan_preview_phases', title: 'Preview all phases (dependency graph)', verb: 'preview',
103
+ description: 'Phase dependency graph: a mermaid DAG, the parallel-executable batches, and any hidden dependencies. Errors on a project with no roadmap, which is why this is a tool rather than a resource.',
104
+ readOnly: true, destructive: false,
105
+ inputSchema: { type: 'object', additionalProperties: false, properties: {} },
106
+ args: () => ['phases'],
107
+ },
108
+ {
109
+ name: 'pan_preview_phase', title: 'Preview one phase (blast radius)', verb: 'preview',
110
+ description: 'Blast radius for a single phase: what it touches and what depends on it, before any work starts.',
111
+ readOnly: true, destructive: false,
112
+ inputSchema: {
113
+ type: 'object', additionalProperties: false, required: ['phase'],
114
+ properties: { phase: { type: 'string', description: 'Phase number, e.g. 03' } },
115
+ },
116
+ args: (i) => ['phase', str('phase', i && i.phase, PHASE_RE, 3)],
117
+ },
61
118
  {
62
119
  name: 'pan_report_phase', title: 'Generate a phase HTML report', verb: 'report',
63
120
  description: 'Render the self-contained HTML report for one phase (a build deliverable). Writes only its own file; non-destructive and idempotent.',
@@ -88,11 +88,18 @@ This step provides awareness; the hard gate is enforced by exec-phase.
88
88
 
89
89
  This step catches test regressions that goal-backward analysis cannot detect.
90
90
 
91
- 1. Detect test command:
91
+ 1. Detect whether a test script exists. **This decides `skipped` vs `failed` later**, so
92
+ it must be answered BEFORE running anything — `npm test` on a project with no `test`
93
+ script exits non-zero with "Missing script", which is indistinguishable from a crash
94
+ once you are only looking at the exit code.
95
+
92
96
  ```bash
93
- TEST_CMD=$(node -e "const p=require('./package.json'); console.log(p.scripts && p.scripts.test || 'echo no test')")
97
+ HAS_TEST=$(node -e "try{const p=require('./package.json');process.stdout.write(p.scripts&&p.scripts.test?'yes':'no')}catch(e){process.stdout.write('no')}")
94
98
  ```
95
99
 
100
+ **If `HAS_TEST` is `no`:** record `test_gate_status: skipped` and go to step 4. Do not
101
+ run the suite, and do not report a crash — there is nothing to run.
102
+
96
103
  2. Run test suite and capture results:
97
104
  ```bash
98
105
  TEST_OUTPUT=$(npm test 2>&1)
@@ -102,13 +109,25 @@ TEST_PASS=$(echo "$TEST_OUTPUT" | grep -E "^ℹ pass" | awk '{print $NF}')
102
109
  TEST_FAIL=$(echo "$TEST_OUTPUT" | grep -E "^ℹ fail" | awk '{print $NF}')
103
110
  ```
104
111
 
105
- 3. Evaluate results:
112
+ 3. Evaluate results.
113
+
114
+ **Check `TEST_EXIT` FIRST, before any count.** A suite that did not run emits no
115
+ `ℹ fail` line at all, so `TEST_FAIL` comes back EMPTY — not `0`. Reading an empty
116
+ value as "no failures" scores a crashed suite as a pass, which is the worst
117
+ direction this gate can fail in: a phase ships green on a project whose tests never
118
+ executed. Empty is not zero. Judge the exit code, then the counts.
106
119
 
107
120
  | Condition | Action |
108
121
  |-----------|--------|
109
- | All tests pass (`TEST_FAIL` = 0) | Record counts, continue to must-haves |
110
- | Tests regress (failures exist) | Set `test_gate_status: failed`, include failure details in verification report |
111
- | No test command found | Record as `test_gate_status: skipped`, continue |
122
+ | `TEST_EXIT` = 0 **and** `TEST_FAIL` = 0 (a real number) | Record counts, continue to must-haves |
123
+ | `TEST_EXIT` ≠ 0 **and** failures were reported | Set `test_gate_status: failed`, include failure details |
124
+ | `TEST_EXIT` ≠ 0 **and** `TEST_FAIL` is empty/absent — the suite CRASHED or could not run (syntax error, missing module, bad import, no runner) | Set `test_gate_status: failed`, and record the reason as `suite did not run`. **Never `skipped`, never `passed`.** A suite that cannot execute is stronger evidence of a broken phase than one that runs and fails |
125
+ | `HAS_TEST` = `no` (handled in step 1) | Record as `test_gate_status: skipped`, continue. This is the ONLY legitimate route to `skipped` |
126
+
127
+ **The distinction that matters:** *no test command exists* is a project that never had
128
+ tests — a known, acceptable gap. *A test command exists and did not run* is a broken
129
+ project. They must never share a verdict; the first is `skipped`, the second is
130
+ `failed`.
112
131
 
113
132
  4. Store test gate results for inclusion in verification.md:
114
133
  ```
@@ -16,27 +16,35 @@ ZCode through the one interface it speaks: **MCP**.
16
16
  ```
17
17
  ZCode harness (GLM-5.2) primary Agent drives everything; ported subagents fan out
18
18
  │ MCP · local stdio
19
- pan-zcode/mcp (this subsystem) a thin, zero-dep bridge — verbs → MCP tools/resources
20
- │ spawn: node pan-tools.cjs <verb> --raw --cwd <root>
19
+ pan-wizard-core/mcp (SHARED) a thin, zero-dep bridge — verbs → MCP tools/resources
20
+ │ spawn: node pan-tools.cjs <verb> --cwd <root>
21
21
  pan-wizard-core (reused as-is) the deterministic engine; .planning/ stays the state store
22
22
  ```
23
23
 
24
+ **The bridge is no longer part of this subsystem.** It was written here, but it lives at
25
+ `pan-wizard-core/mcp/` so it ships with the engine to every install and every runtime — PAN-Z
26
+ is now a **consumer** of it, alongside the main installer. `install-zcode.js` emits an MCP
27
+ registration pointing at that shared path. **Never fork a copy back under `pan-zcode/`**: one
28
+ protocol layer, many consumers, or the two drift the way the per-runtime command trees did
29
+ before ADR-0028.
30
+
24
31
  **Scope boundary (by design):** the bridge exposes `pan-tools` verbs as MCP tools/resources and nothing more. It intentionally does **not** carry rich agent *session state* — diffs, streaming, live thread lifecycle — because MCP can't faithfully represent it (the reason OpenAI built the Codex harness as a native Rust core rather than over MCP). Keep the bridge to tool/resource exposure; the CLI's JSON contract is the tool contract. See `KNOWN-BETA-RISKS.md`.
25
32
 
26
33
  ## Status — M1–M5 built (M0 is the human verify spike)
27
34
 
28
- - **M1 — bridge core.** `mcp/tool-registry.cjs` (pure verb→tool/resource map, with a hard
29
- guardrail against exposing a force/reset/rebase/push verb) + `mcp/server.cjs` (a
35
+ - **M1 — bridge core.** `pan-wizard-core/mcp/tool-registry.cjs` (pure verb→tool/resource map, with a hard
36
+ guardrail against exposing a force/reset/rebase/push verb) + `pan-wizard-core/mcp/server.cjs` (a
30
37
  **zero-dependency** JSON-RPC 2.0 stdio MCP server; reads → resources, actions → tools with
31
38
  accurate hints; shell-less `execFile` spawn; `@file:` overflow protocol; strict per-arg
32
39
  validation). **Dual-era** per the MCP 2026-07-28 stateless spec (ADR-0041): legacy clients
33
40
  use the `initialize` handshake; modern clients declare their protocol version in each
34
41
  request's `_meta`, probe `server/discover`, and get `UnsupportedProtocolVersionError`
35
42
  (`-32022`) on a version mismatch.
36
- - **M2 — determinism grafts.** `mcp/merge-gate.cjs` (two-step, model-proof merge: a human-origin
37
- env token that ignores agent-supplied approval; never force/reset/push) + `mcp/orchestrator.cjs`
38
- (the deterministic `next-action` state machine with safety caps + regression circuit-breaker),
39
- exposed as native MCP tools via `mcp/native-tools.cjs`.
43
+ - **M2 — determinism grafts.** `pan-wizard-core/mcp/merge-gate.cjs` (two-step, model-proof merge: a
44
+ human-origin env token that ignores agent-supplied approval; never force/reset/push) +
45
+ `pan-wizard-core/mcp/orchestrator.cjs` (the deterministic `next-action` state machine with safety
46
+ caps + regression circuit-breaker), exposed as native MCP tools via
47
+ `pan-wizard-core/mcp/native-tools.cjs`.
40
48
  - **M3 — content port.** `lib/convert-agent.cjs` — Claude agents → ZCode subagents (reusing the
41
49
  installer's frontmatter helpers): drops `Task` (no nesting), maps PAN tiers → `inherit`,
42
50
  preserves the body; plus a command → skill wrapper.
@@ -59,7 +67,7 @@ A third M0 checkpoint (added 2026-08): **which protocol era does the real ZCode
59
67
  The bridge is now dual-era (ADR-0041), so it answers both a legacy `initialize` handshake and a
60
68
  modern `server/discover` probe. Confirm on a real install which path ZCode takes and that the
61
69
  version it declares is in our supported list; if ZCode ever declares a revision newer than
62
- `2026-07-28`, add it to `SUPPORTED_VERSIONS_LIST` in `mcp/server.cjs` once its method shapes are
70
+ `2026-07-28`, add it to `SUPPORTED_VERSIONS_LIST` in `pan-wizard-core/mcp/server.cjs` once its method shapes are
63
71
  implemented.
64
72
 
65
73
  ## Zero dependencies
@@ -98,7 +98,10 @@ function buildBundle(o) {
98
98
  assertNotInSourceRepo(destDir, repoRoot);
99
99
 
100
100
  const agentsSrc = path.join(repoRoot, 'agents');
101
- const serverPath = path.join(repoRoot, 'pan-zcode', 'mcp', 'server.cjs');
101
+ // The MCP protocol layer lives in pan-wizard-core/mcp/ (it ships with the
102
+ // engine to every install and every runtime). PAN-Z is a CONSUMER of it, not
103
+ // its owner — never fork a copy back under pan-zcode/, or the two drift.
104
+ const serverPath = path.join(repoRoot, 'pan-wizard-core', 'mcp', 'server.cjs');
102
105
  const panToolsPath = path.join(repoRoot, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
103
106
 
104
107
  fs.mkdirSync(destDir, { recursive: true });
@@ -11,10 +11,23 @@
11
11
  * pan-wizard-core/ dispatcher + modules + workflows + templates
12
12
  *
13
13
  * Distribution status: built ALONGSIDE the loose-file installer. Marketplace
14
- * publishing is gated on one live verification — whether ${CLAUDE_PLUGIN_ROOT}
14
+ * publishing WAS gated on one live verification — whether ${CLAUDE_PLUGIN_ROOT}
15
15
  * expands inside command markdown content (documented for hook/MCP configs
16
- * only). Until then, content references core paths relative to the plugin
17
- * root, which matches the documented plugin working layout.
16
+ * only).
17
+ *
18
+ * ANSWERED 2026-08-14, Claude Code 2.1.233 on Windows, by installing this plugin
19
+ * from the `command`-source marketplace in `marketplace/` and running
20
+ * `/pan-plugin-selftest`: **it does expand.** The command body reached the model
21
+ * with a real absolute path — no placeholder text survived — and invoking
22
+ * pan-tools through that path worked. So the CONTENT_PREFIX rewrite below is
23
+ * correct as it stands, and the gate is lifted.
24
+ *
25
+ * One measurement from the same run that constrains how far to take this: the
26
+ * `CLAUDE_PLUGIN_ROOT` environment variable is NOT exported into the Bash tool's
27
+ * environment (it read as empty). Textual substitution and shell expansion are
28
+ * therefore NOT interchangeable — generated content must keep using the
29
+ * substituted form, because `$CLAUDE_PLUGIN_ROOT` evaluated by a shell at runtime
30
+ * expands to nothing. Re-measure before relying on the shell form anywhere.
18
31
  *
19
32
  * Usage: node scripts/build-plugin.js (or npm run build:plugin)
20
33
  */
@@ -88,6 +101,25 @@ function main() {
88
101
  }
89
102
  }
90
103
 
104
+ // 2b. Plugin-only self-test command. NOT copied from commands/pan/ — it is
105
+ // generated here so the shipped command set stays unchanged and no ordinary
106
+ // install gains a diagnostic. It answers the one question gating publication:
107
+ // whether CONTENT_PREFIX expands inside command markdown. The placeholder must
108
+ // reach the plugin UNEXPANDED or the probe measures nothing, so this write
109
+ // deliberately bypasses rewriteContent().
110
+ fs.writeFileSync(
111
+ path.join(OUT, 'commands', 'pan-plugin-selftest.md'),
112
+ lib.buildPluginSelfTestCommand(CONTENT_PREFIX.replace(/\/$/, ''))
113
+ );
114
+
115
+ // 4b. MCP registration. The server itself rides along inside pan-wizard-core
116
+ // (step 5 copies it wholesale), but shipping it is not the same as declaring
117
+ // it — without this file the plugin carried the bridge and never registered it.
118
+ fs.writeFileSync(
119
+ path.join(OUT, '.mcp.json'),
120
+ JSON.stringify(lib.buildPluginMcpConfig(), null, 2) + '\n'
121
+ );
122
+
91
123
  // 5. Core (strip source-only internal learnings, same policy as the installer)
92
124
  copyTree(path.join(ROOT, 'pan-wizard-core'), path.join(OUT, 'pan-wizard-core'), rewriteContent);
93
125
  fs.rmSync(path.join(OUT, 'pan-wizard-core', 'learnings', 'internal'), { recursive: true, force: true });
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deprecate published versions that have fallen far enough behind.
4
+ *
5
+ * WHY. Old releases stay installable forever, and `npm install pan-wizard@3.20.0`
6
+ * silently gives someone a build from several cycles ago with none of the fixes
7
+ * since. Deprecation is the honest signal: the version keeps working, keeps
8
+ * resolving, and anyone installing it sees a warning telling them what to move to.
9
+ *
10
+ * WHY DEPRECATE AND NEVER UNPUBLISH. Unpublishing removes a tarball other people
11
+ * may depend on and is refused by npm outside a 72-hour window anyway. Deprecation
12
+ * is additive, reversible (`npm deprecate <pkg>@<ver> ""` clears it), and breaks
13
+ * nobody. **This script must never gain an unpublish path.**
14
+ *
15
+ * THE RULE. Keep the newest N stable releases (default 3 — the one just published
16
+ * plus the two behind it) and deprecate every stable release older than those.
17
+ * Prereleases are never counted as "kept": once a stable release exists that
18
+ * supersedes them they are deprecated too, since an rc is not something anyone
19
+ * should be installing after the real release shipped.
20
+ *
21
+ * SAFETY, in the order it matters:
22
+ * - DRY RUN BY DEFAULT. `--apply` is required to change anything.
23
+ * - The version being released is never deprecated, even if the arithmetic
24
+ * somehow selects it — an explicit guard, not a consequence.
25
+ * - Already-deprecated versions are skipped, so re-running is a no-op.
26
+ * - A failure to deprecate NEVER fails the build. By the time this runs the
27
+ * publish has already succeeded; turning a housekeeping failure into a red
28
+ * release would be strictly worse than leaving an old version undeprecated.
29
+ *
30
+ * Usage:
31
+ * node scripts/deprecate-old-versions.js # dry run, keep 3
32
+ * node scripts/deprecate-old-versions.js --apply # actually deprecate
33
+ * node scripts/deprecate-old-versions.js --keep 5 --apply
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ const { execFileSync } = require('child_process');
39
+
40
+ const PKG = 'pan-wizard';
41
+ const DEFAULT_KEEP = 3;
42
+
43
+ /**
44
+ * Parse a semver string into comparable parts. Returns null for anything that is
45
+ * not `major.minor.patch[-prerelease]`, so junk sorts out rather than throwing.
46
+ */
47
+ function parse(v) {
48
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v || '').trim());
49
+ if (!m) return null;
50
+ return {
51
+ version: v,
52
+ nums: [Number(m[1]), Number(m[2]), Number(m[3])],
53
+ pre: m[4] || null,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Compare two parsed versions, ascending. Prerelease-aware: 3.26.0-rc.1 sorts
59
+ * BELOW 3.26.0, which the update-check hook's comparator deliberately does not do
60
+ * (it ignores the suffix). Getting this backwards would deprecate a real release
61
+ * in favour of its own release candidate, so it is implemented here rather than
62
+ * reused.
63
+ */
64
+ function compare(a, b) {
65
+ for (let i = 0; i < 3; i++) {
66
+ if (a.nums[i] !== b.nums[i]) return a.nums[i] - b.nums[i];
67
+ }
68
+ if (a.pre === b.pre) return 0;
69
+ if (a.pre === null) return 1; // release > prerelease
70
+ if (b.pre === null) return -1;
71
+ return a.pre < b.pre ? -1 : 1; // lexical is good enough for rc.1 < rc.2
72
+ }
73
+
74
+ /**
75
+ * Decide what to deprecate. PURE — no network, no process — so the rule is
76
+ * testable without touching a registry.
77
+ *
78
+ * @param {string[]} published every version on the registry
79
+ * @param {string} current the version just released (never deprecated)
80
+ * @param {number} keep how many newest STABLE releases to leave alone
81
+ * @param {string[]} [already] versions already carrying a deprecation message
82
+ * @returns {{deprecate:string[], keep:string[], reason:Object<string,string>}}
83
+ */
84
+ function selectVersionsToDeprecate(published, current, keep = DEFAULT_KEEP, already = []) {
85
+ const parsed = (published || []).map(parse).filter(Boolean).sort(compare);
86
+ const skip = new Set(already || []);
87
+ const stable = parsed.filter((p) => !p.pre);
88
+ // The newest `keep` stable releases are protected.
89
+ const kept = new Set(stable.slice(-keep).map((p) => p.version));
90
+ // The current release is protected regardless of where the arithmetic lands it.
91
+ kept.add(current);
92
+
93
+ const reason = {};
94
+ const deprecate = [];
95
+ for (const p of parsed) {
96
+ if (kept.has(p.version)) continue;
97
+ if (skip.has(p.version)) continue;
98
+ reason[p.version] = p.pre
99
+ ? `prerelease superseded by ${current}`
100
+ : `more than ${keep - 1} releases behind ${current}`;
101
+ deprecate.push(p.version);
102
+ }
103
+ return { deprecate, keep: [...kept].sort(), reason };
104
+ }
105
+
106
+ /** Message a deprecated version carries. Points at what to install instead. */
107
+ function buildMessage(current) {
108
+ return `No longer maintained — install pan-wizard@${current} or later (npm i pan-wizard@latest).`;
109
+ }
110
+
111
+ // ─── IO layer ───────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Run npm.
115
+ *
116
+ * WINDOWS: `npm` is a `.cmd` shim, so `execFileSync('npm', …)` fails ENOENT and
117
+ * `'npm.cmd'` fails EINVAL — the shim can only be launched through a shell. This
118
+ * is the same scar `runner.cjs` carries as its `shell: 'win32'` opt-in, and the
119
+ * first version of this script reproduced the bug: a local dry run reported
120
+ * "could not read the registry" and returned, so the fail-open path made a real
121
+ * platform bug look like a benign skip.
122
+ *
123
+ * QUOTING: with `shell: true` node CONCATENATES arguments rather than escaping
124
+ * them, so anything containing a space must be quoted or it arrives as several
125
+ * arguments — which matters here because the deprecation message is a sentence.
126
+ * Every argument is program-controlled (package name, versions read from the
127
+ * registry, our own message), so this is a correctness problem rather than an
128
+ * injection one, but it still has to be right. `assertQuotable` refuses a value
129
+ * carrying a double quote instead of emitting a broken command line.
130
+ */
131
+ function assertQuotable(a) {
132
+ if (String(a).includes('"')) {
133
+ throw new Error(`refusing to shell-quote an argument containing a double quote: ${a}`);
134
+ }
135
+ return a;
136
+ }
137
+
138
+ function runNpm(args, opts = {}) {
139
+ const win = process.platform === 'win32';
140
+ const argv = win ? args.map((a) => (/\s/.test(a) ? `"${assertQuotable(a)}"` : a)) : args;
141
+ return execFileSync('npm', argv, {
142
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: win, ...opts,
143
+ });
144
+ }
145
+
146
+ /**
147
+ * `npm view <pkg> <field> --json` prints NOTHING when the field is unset across
148
+ * every version — which is exactly the healthy starting state for `deprecated`
149
+ * (nothing deprecated yet). `JSON.parse('')` throws, and the fail-open handler
150
+ * then reported "could not read the registry", turning the normal case into an
151
+ * apparent failure. Empty means absent, not broken.
152
+ */
153
+ function npmJson(args, fallback = null) {
154
+ const out = runNpm(args);
155
+ if (!out || !out.trim()) return fallback;
156
+ return JSON.parse(out);
157
+ }
158
+
159
+ function main() {
160
+ const args = process.argv.slice(2);
161
+ const apply = args.includes('--apply');
162
+ const keepIdx = args.indexOf('--keep');
163
+ const keep = keepIdx > -1 ? Number(args[keepIdx + 1]) : DEFAULT_KEEP;
164
+ const current = require('../package.json').version;
165
+
166
+ if (!Number.isInteger(keep) || keep < 1) {
167
+ console.error(`deprecate: --keep must be a positive integer, got ${keep}`);
168
+ process.exit(1);
169
+ }
170
+
171
+ // A prerelease must never trigger a deprecation sweep: it is not the thing
172
+ // users are being pointed at, and treating it as "the new release" would
173
+ // deprecate the current stable one.
174
+ if (parse(current) && parse(current).pre) {
175
+ console.log(`deprecate: ${current} is a prerelease — skipping (sweeps run for stable releases only).`);
176
+ return;
177
+ }
178
+
179
+ let published = [];
180
+ let deprecatedAlready = [];
181
+ try {
182
+ published = npmJson(['view', PKG, 'versions', '--json'], []);
183
+ if (!Array.isArray(published)) published = [published];
184
+ const map = npmJson(['view', PKG, 'deprecated', '--json'], {});
185
+ // npm returns a bare string for a single version, or {version: message}.
186
+ deprecatedAlready = (map && typeof map === 'object') ? Object.keys(map) : [];
187
+ } catch (e) {
188
+ console.error(`deprecate: could not read the registry (${String(e.message).split('\n')[0]}). Nothing changed.`);
189
+ return; // never fail the build over housekeeping
190
+ }
191
+
192
+ const { deprecate, keep: kept, reason } = selectVersionsToDeprecate(published, current, keep, deprecatedAlready);
193
+ const message = buildMessage(current);
194
+
195
+ console.log(`deprecate: current=${current} keep=${keep}`);
196
+ console.log(` protected: ${kept.join(', ')}`);
197
+ if (deprecate.length === 0) {
198
+ console.log(' nothing to deprecate.');
199
+ return;
200
+ }
201
+ console.log(` ${apply ? 'deprecating' : 'WOULD deprecate (dry run — pass --apply)'}: ${deprecate.length}`);
202
+ for (const v of deprecate) console.log(` ${v} — ${reason[v]}`);
203
+
204
+ if (!apply) return;
205
+
206
+ let failed = 0;
207
+ for (const v of deprecate) {
208
+ try {
209
+ runNpm(['deprecate', `${PKG}@${v}`, message]);
210
+ console.log(` ✓ ${v}`);
211
+ } catch (e) {
212
+ failed++;
213
+ console.error(` ✗ ${v}: ${String(e.stderr || e.message).split('\n')[0]}`);
214
+ }
215
+ }
216
+ if (failed) {
217
+ // Reported, not fatal. The publish already succeeded; a red build here would
218
+ // imply the release failed, which is false and worse than the omission.
219
+ console.error(`deprecate: ${failed} of ${deprecate.length} failed — release is unaffected.`);
220
+ }
221
+ }
222
+
223
+ if (require.main === module) main();
224
+
225
+ module.exports = { selectVersionsToDeprecate, buildMessage, parse, compare, assertQuotable };
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print the absolute path of the built PAN plugin directory — the contract a
4
+ * Claude Code plugin-marketplace `command` source requires (v2.1.229+).
5
+ *
6
+ * WHY THIS EXISTS. PAN builds a plugin (`npm run build:plugin`) and has shipped
7
+ * it nowhere, because marketplace publishing is gated on one unverified
8
+ * question: does `${CLAUDE_PLUGIN_ROOT}` expand inside *command markdown*? It is
9
+ * documented as substituted in hook and MCP configs, not in content. A `command`
10
+ * source needs no hosting, so it turns that question into a local experiment —
11
+ * install the plugin from this script's output and run `/pan-plugin-selftest`.
12
+ *
13
+ * THE CONTRACT, verbatim from code.claude.com/docs/en/plugin-marketplaces:
14
+ * - Claude Code runs the command "through the platform shell, `sh` on macOS and
15
+ * Linux or `cmd.exe` on Windows, from the user's home directory". So NOTHING
16
+ * here may depend on the working directory; every path is derived from
17
+ * __dirname.
18
+ * - "The command must print exactly one line on stdout and exit with code 0."
19
+ * The plugin build is chatty, so its stdout is relayed to STDERR and only the
20
+ * path reaches stdout. A stray console.log here breaks the install.
21
+ * - The printed directory must hold plugin content at its top level, must not
22
+ * be the directory Claude Code started in or one of its parents, and on
23
+ * Windows must not be a UNC path.
24
+ *
25
+ * Rebuilding on every run is deliberate: Claude Code re-runs the command once per
26
+ * session in the background, so a source edit is picked up without reinstalling.
27
+ * In `copy` mode the version is a hash of the directory contents, so an unchanged
28
+ * build counts as up to date.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const path = require('path');
34
+ const fs = require('fs');
35
+ const { execFileSync } = require('child_process');
36
+
37
+ const ROOT = path.join(__dirname, '..');
38
+ const PLUGIN_DIR = path.join(ROOT, 'dist', 'pan-wizard-plugin');
39
+
40
+ // Top-level markers Claude Code accepts as proof of plugin content.
41
+ const PLUGIN_MARKERS = ['.claude-plugin', 'skills', 'commands', 'agents', 'hooks'];
42
+
43
+ function fail(message) {
44
+ // stderr only — stdout is reserved for the single path line.
45
+ process.stderr.write(`plugin-path: ${message}\n`);
46
+ process.exit(1);
47
+ }
48
+
49
+ function build() {
50
+ try {
51
+ // Relay the builder's stdout to stderr so stdout stays single-line.
52
+ const out = execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'build-plugin.js')], {
53
+ cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
54
+ });
55
+ if (out) process.stderr.write(out);
56
+ } catch (err) {
57
+ const detail = (err.stderr || err.stdout || err.message || '').toString().trim();
58
+ fail(`plugin build failed: ${detail.split('\n').slice(-3).join(' | ')}`);
59
+ }
60
+ }
61
+
62
+ function main() {
63
+ build();
64
+
65
+ if (!fs.existsSync(PLUGIN_DIR)) fail(`build produced no directory at ${PLUGIN_DIR}`);
66
+
67
+ const top = fs.readdirSync(PLUGIN_DIR);
68
+ if (!PLUGIN_MARKERS.some((m) => top.includes(m))) {
69
+ fail(`no plugin content at the top level of ${PLUGIN_DIR} (need one of ${PLUGIN_MARKERS.join(', ')})`);
70
+ }
71
+
72
+ const resolved = path.resolve(PLUGIN_DIR);
73
+
74
+ // Windows UNC paths are refused by Claude Code; catch it here with a clear
75
+ // message rather than letting the install fail opaquely.
76
+ if (process.platform === 'win32' && /^\\\\/.test(resolved)) {
77
+ fail(`refusing a UNC path (Claude Code rejects it): ${resolved}`);
78
+ }
79
+
80
+ // Exactly one line, nothing else.
81
+ process.stdout.write(`${resolved}\n`);
82
+ }
83
+
84
+ main();
@@ -1,2 +0,0 @@
1
- # Placeholder so git tracks the empty directory.
2
- # This tier is NOT shipped to user installs — see ../README.md.