pan-wizard 3.25.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.
- package/bin/install-lib.cjs +283 -1
- package/bin/install.js +127 -0
- package/package.json +3 -2
- package/pan-wizard-core/bin/lib/suggest.cjs +141 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +113 -2
- package/pan-wizard-core/bin/pan-tools.cjs +39 -3
- package/pan-wizard-core/mcp/native-tools.cjs +159 -0
- package/pan-wizard-core/mcp/orchestrator.cjs +179 -0
- package/{pan-zcode → pan-wizard-core}/mcp/server.cjs +35 -6
- package/{pan-zcode → pan-wizard-core}/mcp/tool-registry.cjs +60 -3
- package/pan-wizard-core/workflows/verify-phase.md +25 -6
- package/pan-zcode/README.md +17 -9
- package/pan-zcode/bin/install-zcode.js +4 -1
- package/scripts/build-plugin.js +35 -3
- package/scripts/deprecate-old-versions.js +225 -0
- package/scripts/plugin-path.js +84 -0
- package/pan-wizard-core/learnings/internal/.gitkeep +0 -2
- package/pan-wizard-core/learnings/internal/experiment-runner.md +0 -81
- package/pan-wizard-core/learnings/internal/external-research.md +0 -105
- package/pan-wizard-core/learnings/internal/loop-design.md +0 -33
- package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +0 -181
- package/pan-zcode/mcp/native-tools.cjs +0 -63
- package/pan-zcode/mcp/orchestrator.cjs +0 -66
- /package/{pan-zcode → pan-wizard-core}/mcp/merge-gate.cjs +0 -0
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
|
110
|
-
|
|
|
111
|
-
|
|
|
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
|
```
|
package/pan-zcode/README.md
CHANGED
|
@@ -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-
|
|
20
|
-
│ spawn: node pan-tools.cjs <verb> --
|
|
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
|
|
37
|
-
env token that ignores agent-supplied approval; never force/reset/push) +
|
|
38
|
-
(the deterministic `next-action` state machine with safety
|
|
39
|
-
exposed as native MCP tools via
|
|
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
|
-
|
|
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 });
|
package/scripts/build-plugin.js
CHANGED
|
@@ -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
|
|
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).
|
|
17
|
-
*
|
|
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();
|