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
package/bin/install-lib.cjs
CHANGED
|
@@ -469,6 +469,13 @@ User interaction (runtimes without a native question tool):
|
|
|
469
469
|
}
|
|
470
470
|
|
|
471
471
|
/** Claude command → runtime-neutral SKILL.md (ADR-0028 Phase 1) */
|
|
472
|
+
/**
|
|
473
|
+
* `compatibility` value for emitted unified skills — the spec's optional field
|
|
474
|
+
* for environment requirements (max 500 chars). Kept short and factual: these
|
|
475
|
+
* are the two things a host cannot infer and that every PAN skill depends on.
|
|
476
|
+
*/
|
|
477
|
+
const SKILL_COMPATIBILITY = 'Requires Node.js (skills invoke the bundled pan-tools CLI) and a project with a .planning/ directory, created by /pan-new-project or /pan-map-codebase.';
|
|
478
|
+
|
|
472
479
|
function convertClaudeCommandToUnifiedSkill(content, skillName) {
|
|
473
480
|
// Normalize command mentions to the readable /pan-<name> form; the adapter
|
|
474
481
|
// header tells each runtime to map that onto its own invocation syntax.
|
|
@@ -483,7 +490,14 @@ function convertClaudeCommandToUnifiedSkill(content, skillName) {
|
|
|
483
490
|
description = toSingleLine(description);
|
|
484
491
|
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
|
485
492
|
const adapter = getUnifiedSkillAdapterHeader(skillName);
|
|
486
|
-
|
|
493
|
+
// `compatibility` is the spec's optional field for stating environment
|
|
494
|
+
// requirements, and PAN has real ones: the skill bodies shell out to
|
|
495
|
+
// `pan-tools` (Node) and every workflow reads/writes `.planning/`. Declaring
|
|
496
|
+
// them beats the alternative, which is a host discovering it mid-run.
|
|
497
|
+
// Deliberately NOT emitting `allowed-tools`: it is marked experimental in the
|
|
498
|
+
// spec, and ADR-0028's frontmatter rule is that anything unverified stays out
|
|
499
|
+
// until a live per-runtime check confirms no parser rejects it.
|
|
500
|
+
return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\ncompatibility: ${yamlQuote(SKILL_COMPATIBILITY)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
|
487
501
|
}
|
|
488
502
|
|
|
489
503
|
/** Generate Copilot CLI skill adapter header */
|
|
@@ -869,6 +883,146 @@ const HOOK_EVENT_MAP = Object.freeze({
|
|
|
869
883
|
opencode: null,
|
|
870
884
|
});
|
|
871
885
|
|
|
886
|
+
// ─── MCP server registration (2026-08) ──────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Cross-runtime MCP registration map. Every path and shape below was verified
|
|
890
|
+
* against primary docs on 2026-08-12 — do NOT edit from memory, and re-verify
|
|
891
|
+
* before trusting: this table's ancestor (`HOOK_EVENT_MAP`) exists because PAN
|
|
892
|
+
* shipped two DEAD config paths that had been written from secondary sources.
|
|
893
|
+
*
|
|
894
|
+
* `register: false` means PAN deliberately does not write the file and prints a
|
|
895
|
+
* copy-pasteable snippet instead. That is a risk decision, not an omission:
|
|
896
|
+
* - **codex** — MCP lives in `config.toml`, which PAN does not touch anywhere
|
|
897
|
+
* else and cannot merge non-destructively without a TOML parser (PAN is
|
|
898
|
+
* zero-dep and only ever *generates* TOML). Hand-merging a user's config
|
|
899
|
+
* risks their settings for no gain over a printed snippet.
|
|
900
|
+
* - **claude global** — user scope is `~/.claude.json`, a large file keyed by
|
|
901
|
+
* every project path the user has opened. PAN writes the project-scoped
|
|
902
|
+
* `.mcp.json` instead, which is the documented shareable surface.
|
|
903
|
+
*
|
|
904
|
+
* Shape notes that differ per runtime and are easy to get wrong:
|
|
905
|
+
* - claude/copilot/gemini use `mcpServers`; **opencode uses `mcp`**.
|
|
906
|
+
* - claude/copilot/gemini take `command` + `args[]`; **opencode takes a single
|
|
907
|
+
* `command` ARRAY** ([cmd, ...args]) and names its env block `environment`.
|
|
908
|
+
* - copilot/opencode want `type: "local"`; claude/gemini infer stdio from
|
|
909
|
+
* `command` and are not given a `type` here.
|
|
910
|
+
*/
|
|
911
|
+
const MCP_REGISTRATION = Object.freeze({
|
|
912
|
+
claude: Object.freeze({
|
|
913
|
+
register: true, key: 'mcpServers', localPath: '.mcp.json', globalPath: null,
|
|
914
|
+
why: 'Project-scoped .mcp.json at the repo root (code.claude.com/docs/en/mcp). Needs one interactive approval; workspace trust gates it.',
|
|
915
|
+
}),
|
|
916
|
+
copilot: Object.freeze({
|
|
917
|
+
// PATHS HERE ARE RELATIVE TO THE RUNTIME'S CONFIG DIR, not the repo root.
|
|
918
|
+
// Copilot's config dir already *is* `.github/`, so this is `mcp.json` — it
|
|
919
|
+
// resolves to `.github/mcp.json` on disk. Writing `.github/mcp.json` here
|
|
920
|
+
// produced `.github/.github/mcp.json`, a file Copilot never reads; the
|
|
921
|
+
// doubling is pinned by a test because it installs and verifies "cleanly".
|
|
922
|
+
register: true, key: 'mcpServers', localPath: 'mcp.json', globalPath: 'mcp-config.json',
|
|
923
|
+
why: 'docs.github.com add-mcp-servers: project-level .github/mcp.json (also .mcp.json up-tree), user-level ~/.copilot/mcp-config.json.',
|
|
924
|
+
}),
|
|
925
|
+
gemini: Object.freeze({
|
|
926
|
+
register: true, key: 'mcpServers', localPath: 'settings.json', globalPath: 'settings.json',
|
|
927
|
+
why: 'Gemini reads mcpServers from .gemini/settings.json (workspace) or ~/.gemini/settings.json (user) — the same file PAN already writes hooks into.',
|
|
928
|
+
}),
|
|
929
|
+
opencode: Object.freeze({
|
|
930
|
+
register: true, key: 'mcp', localPath: 'opencode.json', globalPath: 'opencode.json',
|
|
931
|
+
why: 'opencode.ai/docs: opencode.json `mcp` block, type "local", command as one array, env block named `environment`. PAN already writes this file.',
|
|
932
|
+
}),
|
|
933
|
+
codex: Object.freeze({
|
|
934
|
+
// Config-dir-relative like the others (resolves to `.codex/config.toml`).
|
|
935
|
+
// These are documentation-only while register is false — but they follow the
|
|
936
|
+
// convention anyway, so that flipping register:true later cannot inherit the
|
|
937
|
+
// path-doubling bug Copilot's row shipped with.
|
|
938
|
+
register: false, key: 'mcp_servers', localPath: 'config.toml', globalPath: 'config.toml',
|
|
939
|
+
why: 'TOML-only surface ([mcp_servers.NAME] in config.toml, verified learn.chatgpt.com). PAN has no TOML merge and is zero-dep; a printed snippet is safer than hand-editing a user config.',
|
|
940
|
+
}),
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Build one PAN MCP server entry in the shape a given runtime expects.
|
|
945
|
+
*
|
|
946
|
+
* @param {string} runtime - claude | copilot | gemini | opencode | codex
|
|
947
|
+
* @param {string} serverPath - absolute path to pan-wizard-core/mcp/server.cjs
|
|
948
|
+
* @param {string} panToolsPath - absolute path to pan-wizard-core/bin/pan-tools.cjs
|
|
949
|
+
* @param {string} [projectRoot] - value for PAN_PROJECT_ROOT; omitted when falsy
|
|
950
|
+
* @returns {object} the entry (NOT wrapped in its container key)
|
|
951
|
+
*/
|
|
952
|
+
function buildMcpServerEntry(runtime, serverPath, panToolsPath, projectRoot) {
|
|
953
|
+
const env = { PAN_TOOLS_PATH: panToolsPath };
|
|
954
|
+
if (projectRoot) env.PAN_PROJECT_ROOT = projectRoot;
|
|
955
|
+
|
|
956
|
+
if (runtime === 'opencode') {
|
|
957
|
+
// Single command ARRAY + `environment` — opencode's shape is the outlier.
|
|
958
|
+
return { type: 'local', command: ['node', serverPath], enabled: true, environment: env };
|
|
959
|
+
}
|
|
960
|
+
const entry = { command: 'node', args: [serverPath], env };
|
|
961
|
+
// Copilot's documented example carries an explicit local type; Claude and
|
|
962
|
+
// Gemini infer stdio from `command`, and Claude's `type` vocabulary does not
|
|
963
|
+
// include "local", so it must NOT be added there.
|
|
964
|
+
if (runtime === 'copilot') entry.type = 'local';
|
|
965
|
+
return entry;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Merge PAN's MCP server into an existing config object, non-destructively.
|
|
970
|
+
* Foreign servers are preserved; the PAN entry is replaced wholesale so a
|
|
971
|
+
* reinstall is idempotent and a path change takes effect.
|
|
972
|
+
*
|
|
973
|
+
* @param {object|null} existing - parsed config, or null when absent/unusable
|
|
974
|
+
* @param {string} runtime
|
|
975
|
+
* @param {object} entry - from buildMcpServerEntry
|
|
976
|
+
* @param {string} [serverName='pan'] - registration key
|
|
977
|
+
* @returns {object} merged config to serialize
|
|
978
|
+
*/
|
|
979
|
+
function mergeMcpRegistration(existing, runtime, entry, serverName = 'pan') {
|
|
980
|
+
const spec = MCP_REGISTRATION[runtime];
|
|
981
|
+
if (!spec) throw new Error(`mergeMcpRegistration: unknown runtime "${runtime}"`);
|
|
982
|
+
const config = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
|
|
983
|
+
const key = spec.key;
|
|
984
|
+
if (!config[key] || typeof config[key] !== 'object' || Array.isArray(config[key])) config[key] = {};
|
|
985
|
+
config[key][serverName] = entry;
|
|
986
|
+
return config;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Remove PAN's MCP server from a config object, preserving foreign entries.
|
|
991
|
+
* Empties the container key when PAN was its only member, so uninstall does not
|
|
992
|
+
* leave `{"mcpServers":{}}` behind.
|
|
993
|
+
*
|
|
994
|
+
* @returns {{config: object, removed: boolean}}
|
|
995
|
+
*/
|
|
996
|
+
function stripMcpRegistration(existing, runtime, serverName = 'pan') {
|
|
997
|
+
const spec = MCP_REGISTRATION[runtime];
|
|
998
|
+
if (!spec) throw new Error(`stripMcpRegistration: unknown runtime "${runtime}"`);
|
|
999
|
+
const config = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
|
|
1000
|
+
const bag = config[spec.key];
|
|
1001
|
+
if (!bag || typeof bag !== 'object' || !(serverName in bag)) return { config, removed: false };
|
|
1002
|
+
delete bag[serverName];
|
|
1003
|
+
if (Object.keys(bag).length === 0) delete config[spec.key];
|
|
1004
|
+
return { config, removed: true };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* The copy-pasteable TOML a user adds by hand for Codex (register: false).
|
|
1009
|
+
* Emitting a snippet rather than merging is the deliberate choice recorded in
|
|
1010
|
+
* MCP_REGISTRATION — keep this in the shape verified at learn.chatgpt.com
|
|
1011
|
+
* (`[mcp_servers.NAME]` with a nested `[mcp_servers.NAME.env]` table).
|
|
1012
|
+
*/
|
|
1013
|
+
function buildCodexMcpSnippet(serverPath, panToolsPath, projectRoot, serverName = 'pan') {
|
|
1014
|
+
const lines = [
|
|
1015
|
+
`[mcp_servers.${serverName}]`,
|
|
1016
|
+
'command = "node"',
|
|
1017
|
+
`args = [${JSON.stringify(serverPath)}]`,
|
|
1018
|
+
'',
|
|
1019
|
+
`[mcp_servers.${serverName}.env]`,
|
|
1020
|
+
`PAN_TOOLS_PATH = ${JSON.stringify(panToolsPath)}`,
|
|
1021
|
+
];
|
|
1022
|
+
if (projectRoot) lines.push(`PAN_PROJECT_ROOT = ${JSON.stringify(projectRoot)}`);
|
|
1023
|
+
return lines.join('\n');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
872
1026
|
/**
|
|
873
1027
|
* Merge PAN hook registrations into a `.codex/hooks.json` config.
|
|
874
1028
|
*
|
|
@@ -1301,6 +1455,127 @@ function buildPluginHooksConfig() {
|
|
|
1301
1455
|
};
|
|
1302
1456
|
}
|
|
1303
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* Build the plugin-only self-test command that answers PAN's one gated question:
|
|
1460
|
+
* does `${CLAUDE_PLUGIN_ROOT}` expand inside plugin COMMAND MARKDOWN? It is
|
|
1461
|
+
* documented as substituted in hook and MCP configs; content is unverified, and
|
|
1462
|
+
* that is what has kept `dist/pan-wizard-plugin/` from being published.
|
|
1463
|
+
*
|
|
1464
|
+
* Emitted ONLY into the plugin build, never into `commands/pan/`, so the shipped
|
|
1465
|
+
* command set is unchanged and no install gains a diagnostic.
|
|
1466
|
+
*
|
|
1467
|
+
* THE PROBE MUST SEPARATE TWO THINGS that a naive test conflates. If the body
|
|
1468
|
+
* simply ran `node "${CLAUDE_PLUGIN_ROOT}/…"` and it worked, that proves nothing
|
|
1469
|
+
* about markdown: the shell would expand `${CLAUDE_PLUGIN_ROOT}` on its own if the
|
|
1470
|
+
* variable happens to be exported into the tool environment. So probe 1 asks for
|
|
1471
|
+
* the RAW CHARACTERS with no shell involved, probe 2 checks the environment
|
|
1472
|
+
* separately, and the verdict table maps the pair onto what PAN may rely on.
|
|
1473
|
+
*
|
|
1474
|
+
* @param {string} placeholder - the literal PAN rewrites content to, injected
|
|
1475
|
+
* rather than hardcoded so this file stays the single source of that string.
|
|
1476
|
+
* @returns {string} markdown for `commands/pan-plugin-selftest.md` in the plugin
|
|
1477
|
+
*/
|
|
1478
|
+
function buildPluginSelfTestCommand(placeholder = '${CLAUDE_PLUGIN_ROOT}') {
|
|
1479
|
+
// Sentinels the agent quotes between. Deliberately ugly so they cannot occur
|
|
1480
|
+
// naturally in surrounding prose or be mistaken for instructions.
|
|
1481
|
+
const OPEN = 'PAN_PROBE_BEGIN>>>';
|
|
1482
|
+
const CLOSE = '<<<PAN_PROBE_END';
|
|
1483
|
+
return `---
|
|
1484
|
+
description: Diagnose whether the plugin-root placeholder expands in plugin command markdown
|
|
1485
|
+
---
|
|
1486
|
+
|
|
1487
|
+
# PAN plugin self-test
|
|
1488
|
+
|
|
1489
|
+
Answer three questions and print the verdict table. **Do not fix anything.** This
|
|
1490
|
+
command is a measurement; a "fail" here is the result, not a problem to repair.
|
|
1491
|
+
|
|
1492
|
+
## Probe 1 — textual substitution in markdown (the question that matters)
|
|
1493
|
+
|
|
1494
|
+
Between the sentinels below sits one token. Report **the exact characters you see
|
|
1495
|
+
there, verbatim**. Do not run a shell. Do not resolve, expand, guess at, or tidy
|
|
1496
|
+
the value — if it looks like a placeholder, say so and quote it literally; if it
|
|
1497
|
+
looks like an absolute path, quote that path.
|
|
1498
|
+
|
|
1499
|
+
${OPEN}${placeholder}${CLOSE}
|
|
1500
|
+
|
|
1501
|
+
Record it as \`probe1\`.
|
|
1502
|
+
|
|
1503
|
+
## Probe 2 — the environment variable, measured separately
|
|
1504
|
+
|
|
1505
|
+
Run exactly this and record stdout as \`probe2\` (empty output is a valid, expected result):
|
|
1506
|
+
|
|
1507
|
+
\`\`\`bash
|
|
1508
|
+
node -e "process.stdout.write(process.env.CLAUDE_PLUGIN_ROOT || '')"
|
|
1509
|
+
\`\`\`
|
|
1510
|
+
|
|
1511
|
+
## Probe 3 — does the engine actually resolve through the placeholder path
|
|
1512
|
+
|
|
1513
|
+
Run this and record whether it prints JSON or errors, as \`probe3\`:
|
|
1514
|
+
|
|
1515
|
+
\`\`\`bash
|
|
1516
|
+
node "${placeholder}/pan-wizard-core/bin/pan-tools.cjs" --help
|
|
1517
|
+
\`\`\`
|
|
1518
|
+
|
|
1519
|
+
## Verdict
|
|
1520
|
+
|
|
1521
|
+
Print this table, filled in:
|
|
1522
|
+
|
|
1523
|
+
| probe | result |
|
|
1524
|
+
|---|---|
|
|
1525
|
+
| 1 — markdown substitution | \`probe1\` verbatim |
|
|
1526
|
+
| 2 — env var | \`probe2\` or "(empty)" |
|
|
1527
|
+
| 3 — engine through placeholder | ok / failed, with the error's first line |
|
|
1528
|
+
|
|
1529
|
+
Then state which case holds:
|
|
1530
|
+
|
|
1531
|
+
- **case A — markdown IS substituted** (probe 1 returned an absolute path). Plugin
|
|
1532
|
+
content may reference the plugin root directly, and PAN's existing content
|
|
1533
|
+
rewrite is correct as it stands. This unblocks marketplace publishing.
|
|
1534
|
+
- **case B — markdown is NOT substituted, but the env var is set** (probe 1
|
|
1535
|
+
returned the literal token, probe 2 non-empty). Content must not rely on textual
|
|
1536
|
+
substitution; a *shell* command inside content would still work, because the
|
|
1537
|
+
shell expands the variable. Anything read as a path by something other than a
|
|
1538
|
+
shell — an \`@\` file import, for instance — would break.
|
|
1539
|
+
**Note:** on the one environment measured so far (Claude Code 2.1.233, Windows)
|
|
1540
|
+
probe 2 came back EMPTY, so this case did not occur and its shell-expansion
|
|
1541
|
+
premise is unverified. If you land here, confirm the variable really is visible
|
|
1542
|
+
to the Bash tool before relying on it — otherwise you are actually in case C.
|
|
1543
|
+
- **case C — neither** (probe 1 literal, probe 2 empty). Plugin content cannot
|
|
1544
|
+
address the plugin root at all. PAN would need content that resolves paths at
|
|
1545
|
+
runtime instead, and marketplace publishing stays gated.
|
|
1546
|
+
|
|
1547
|
+
Finish with the case letter on its own line, exactly like \`VERDICT: case A\`,
|
|
1548
|
+
so the result is greppable out of the transcript.
|
|
1549
|
+
`;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Build the plugin's MCP registration (`.mcp.json` at the plugin root).
|
|
1554
|
+
*
|
|
1555
|
+
* Plugins may declare MCP servers in a plugin-root `.mcp.json`, and unlike
|
|
1556
|
+
* command markdown — where `${CLAUDE_PLUGIN_ROOT}` expansion is unverified and
|
|
1557
|
+
* is why marketplace publishing is still gated — hook and MCP *configs* are the
|
|
1558
|
+
* documented place the variable is substituted. So the same form
|
|
1559
|
+
* `buildPluginHooksConfig()` relies on is correct here.
|
|
1560
|
+
*
|
|
1561
|
+
* No `env` block: a plugin serves whatever project the session is in, so pinning
|
|
1562
|
+
* PAN_PROJECT_ROOT would be wrong, and the server resolves its engine from its
|
|
1563
|
+
* own location (see `defaultPanToolsPath`) with `cwd` falling back to the
|
|
1564
|
+
* process cwd. Nothing to configure per install.
|
|
1565
|
+
*
|
|
1566
|
+
* @returns {Object} a `.mcp.json` object for the plugin root
|
|
1567
|
+
*/
|
|
1568
|
+
function buildPluginMcpConfig() {
|
|
1569
|
+
return {
|
|
1570
|
+
mcpServers: {
|
|
1571
|
+
pan: {
|
|
1572
|
+
command: 'node',
|
|
1573
|
+
args: ['${CLAUDE_PLUGIN_ROOT}/pan-wizard-core/mcp/server.cjs'],
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1304
1579
|
// ─── Native Claude Code workflows (2026-06) ─────────────────────────────────
|
|
1305
1580
|
//
|
|
1306
1581
|
// Claude Code discovers deterministic orchestration scripts in
|
|
@@ -1510,10 +1785,17 @@ module.exports = {
|
|
|
1510
1785
|
buildCopilotHooksConfig,
|
|
1511
1786
|
HOOK_EVENT_MAP,
|
|
1512
1787
|
mergeCodexHooksConfig,
|
|
1788
|
+
MCP_REGISTRATION,
|
|
1789
|
+
buildMcpServerEntry,
|
|
1790
|
+
mergeMcpRegistration,
|
|
1791
|
+
stripMcpRegistration,
|
|
1792
|
+
buildCodexMcpSnippet,
|
|
1513
1793
|
removeCodexPanHooks,
|
|
1514
1794
|
buildNativeWorkflowScripts,
|
|
1515
1795
|
buildPluginManifest,
|
|
1516
1796
|
buildPluginHooksConfig,
|
|
1797
|
+
buildPluginMcpConfig,
|
|
1798
|
+
buildPluginSelfTestCommand,
|
|
1517
1799
|
// Install verification (v3.7.10)
|
|
1518
1800
|
verifyInstall,
|
|
1519
1801
|
// AGENTS.md universal rules layer (ADR-0028 Phase 3)
|
package/bin/install.js
CHANGED
|
@@ -1611,6 +1611,39 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1611
1611
|
}
|
|
1612
1612
|
}
|
|
1613
1613
|
|
|
1614
|
+
// 7b. Strip the MCP registration. Foreign servers and every other key in the
|
|
1615
|
+
// file are preserved; a file PAN created and that holds nothing else is
|
|
1616
|
+
// removed rather than left as `{}`. An unparseable config is left ALONE —
|
|
1617
|
+
// never rewrite JSON we could not read (the readSettings null-vs-{} contract).
|
|
1618
|
+
{
|
|
1619
|
+
const mcpPath = mcpConfigPathFor(runtime, isGlobal, targetDir);
|
|
1620
|
+
if (mcpPath) {
|
|
1621
|
+
const existing = readSettings(mcpPath);
|
|
1622
|
+
if (existing === null) {
|
|
1623
|
+
try {
|
|
1624
|
+
if (fs.existsSync(mcpPath)) {
|
|
1625
|
+
console.log(` ${yellow}✗${reset} ${displayPath(mcpPath)} is not valid JSON — left untouched (remove the "pan" server by hand)`);
|
|
1626
|
+
}
|
|
1627
|
+
} catch { /* stat failed — nothing to report */ }
|
|
1628
|
+
} else {
|
|
1629
|
+
try {
|
|
1630
|
+
const { config, removed } = lib.stripMcpRegistration(existing, runtime);
|
|
1631
|
+
if (removed) {
|
|
1632
|
+
if (Object.keys(config).length === 0) {
|
|
1633
|
+
fs.unlinkSync(mcpPath);
|
|
1634
|
+
console.log(` ${green}✓${reset} Removed ${displayPath(mcpPath)} (only the PAN server remained)`);
|
|
1635
|
+
} else {
|
|
1636
|
+
fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
|
|
1637
|
+
console.log(` ${green}✓${reset} Removed PAN MCP server from ${displayPath(mcpPath)}`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
console.error(` ${yellow}✗${reset} Failed to strip MCP registration from ${displayPath(mcpPath)}: ${e.message}`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1614
1647
|
// 8. Clean up empty PAN directories
|
|
1615
1648
|
const dirsToClean = [
|
|
1616
1649
|
path.join(targetDir, 'agents'),
|
|
@@ -2017,6 +2050,96 @@ function reportLocalPatches(configDir, runtime = 'claude') {
|
|
|
2017
2050
|
return meta.files || [];
|
|
2018
2051
|
}
|
|
2019
2052
|
|
|
2053
|
+
/**
|
|
2054
|
+
* Resolve where a runtime's MCP registration file lives, per MCP_REGISTRATION.
|
|
2055
|
+
*
|
|
2056
|
+
* Returns null when this runtime/scope combination is deliberately not written
|
|
2057
|
+
* (see the table's `register` flag and the claude-global note). `targetDir` is
|
|
2058
|
+
* the runtime's config dir (.claude/, .github/, .gemini/, .opencode/ …).
|
|
2059
|
+
*
|
|
2060
|
+
* The important asymmetry: Claude's project surface is `.mcp.json` at the REPO
|
|
2061
|
+
* ROOT, not inside `.claude/`. Every other runtime keeps its MCP config inside
|
|
2062
|
+
* its own config dir, so only Claude escapes targetDir.
|
|
2063
|
+
*/
|
|
2064
|
+
function mcpConfigPathFor(runtime, isGlobal, targetDir) {
|
|
2065
|
+
const spec = lib.MCP_REGISTRATION[runtime];
|
|
2066
|
+
if (!spec || !spec.register) return null;
|
|
2067
|
+
if (runtime === 'claude') {
|
|
2068
|
+
// No global path by design — ~/.claude.json is a per-project-keyed user file.
|
|
2069
|
+
return isGlobal ? null : path.join(process.cwd(), spec.localPath);
|
|
2070
|
+
}
|
|
2071
|
+
const rel = isGlobal ? spec.globalPath : spec.localPath;
|
|
2072
|
+
if (!rel) return null;
|
|
2073
|
+
return path.join(targetDir, rel);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* Register PAN's MCP bridge for one runtime, non-destructively.
|
|
2078
|
+
*
|
|
2079
|
+
* Reuses readSettings() so an existing-but-unusable config is distinguished from
|
|
2080
|
+
* an absent one (null vs {}) — the distinction that exists because an earlier
|
|
2081
|
+
* installer destroyed unparseable settings files. An unusable file is left
|
|
2082
|
+
* ALONE and reported; PAN never rewrites JSON it could not parse.
|
|
2083
|
+
*
|
|
2084
|
+
* Codex gets a printed TOML snippet rather than a write (MCP_REGISTRATION.codex
|
|
2085
|
+
* records why). Gemini/OpenCode share a settings file PAN already writes, so the
|
|
2086
|
+
* merge preserves everything else in it.
|
|
2087
|
+
*/
|
|
2088
|
+
function registerMcpServer(runtime, isGlobal, targetDir) {
|
|
2089
|
+
const spec = lib.MCP_REGISTRATION[runtime];
|
|
2090
|
+
if (!spec) return;
|
|
2091
|
+
|
|
2092
|
+
// Absolute paths into the core copy this install just wrote.
|
|
2093
|
+
const coreDir = path.join(targetDir, 'pan-wizard-core');
|
|
2094
|
+
const serverPath = path.join(coreDir, 'mcp', 'server.cjs');
|
|
2095
|
+
const enginePath = path.join(coreDir, 'bin', 'pan-tools.cjs');
|
|
2096
|
+
// A global install serves many projects, so it must NOT pin PAN_PROJECT_ROOT;
|
|
2097
|
+
// the server falls back to process.cwd() per project. Local installs pin it.
|
|
2098
|
+
const projectRoot = isGlobal ? null : process.cwd();
|
|
2099
|
+
|
|
2100
|
+
if (!fs.existsSync(serverPath)) {
|
|
2101
|
+
pushInstallWarning('mcpRegister', serverPath, new Error('MCP server missing; skipped registration'));
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
if (!spec.register) {
|
|
2106
|
+
const snippet = lib.buildCodexMcpSnippet(serverPath, enginePath, projectRoot);
|
|
2107
|
+
const where = isGlobal ? '~/.codex/config.toml' : '.codex/config.toml';
|
|
2108
|
+
console.log(` ${cyan}i${reset} MCP: add PAN to ${where} by hand (TOML is not safely mergeable):`);
|
|
2109
|
+
console.log(snippet.split('\n').map((l) => ` ${dim}${l}${reset}`).join('\n'));
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
const configPath = mcpConfigPathFor(runtime, isGlobal, targetDir);
|
|
2114
|
+
if (!configPath) {
|
|
2115
|
+
if (runtime === 'claude' && isGlobal) {
|
|
2116
|
+
console.log(` ${cyan}i${reset} MCP: run ${cyan}claude mcp add pan --scope user -- node "${serverPath}"${reset} to register globally`);
|
|
2117
|
+
}
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const existing = readSettings(configPath);
|
|
2122
|
+
if (existing === null) {
|
|
2123
|
+
// Exists but unparseable — do not touch it.
|
|
2124
|
+
pushInstallWarning('mcpRegister', configPath, new Error('config exists but is not valid JSON; left untouched'));
|
|
2125
|
+
console.log(` ${yellow}✗${reset} MCP: ${displayPath(configPath)} is not valid JSON — left untouched, register PAN by hand`);
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
try {
|
|
2130
|
+
const entry = lib.buildMcpServerEntry(runtime, serverPath, enginePath, projectRoot);
|
|
2131
|
+
const merged = lib.mergeMcpRegistration(existing, runtime, entry);
|
|
2132
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
2133
|
+
fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n');
|
|
2134
|
+
console.log(` ${green}✓${reset} Registered MCP server (${displayPath(configPath)})`);
|
|
2135
|
+
if (runtime === 'claude') {
|
|
2136
|
+
console.log(` ${dim}Claude prompts once to approve a project-scoped server.${reset}`);
|
|
2137
|
+
}
|
|
2138
|
+
} catch (e) {
|
|
2139
|
+
pushInstallWarning('mcpRegister', configPath, e);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2020
2143
|
function install(isGlobal, runtime = 'claude') {
|
|
2021
2144
|
const isOpencode = runtime === 'opencode';
|
|
2022
2145
|
const isGemini = runtime === 'gemini';
|
|
@@ -2286,6 +2409,10 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2286
2409
|
failures.push('pan-wizard-core');
|
|
2287
2410
|
}
|
|
2288
2411
|
|
|
2412
|
+
// Register the MCP bridge now that pan-wizard-core (which contains mcp/) has
|
|
2413
|
+
// landed — the registration points at files inside it, so it must come after.
|
|
2414
|
+
registerMcpServer(runtime, isGlobal, targetDir);
|
|
2415
|
+
|
|
2289
2416
|
// Copy agents to agents directory
|
|
2290
2417
|
try {
|
|
2291
2418
|
const agentsSrc = path.join(src, 'agents');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Command a bot army for your codebase:
|
|
3
|
+
"version": "3.26.0",
|
|
4
|
+
"description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
7
7
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"bin",
|
|
10
10
|
"commands",
|
|
11
11
|
"pan-wizard-core",
|
|
12
|
+
"!pan-wizard-core/learnings/internal",
|
|
12
13
|
"agents",
|
|
13
14
|
"hooks/dist",
|
|
14
15
|
"scripts",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command suggestions for unknown invocations — "did you mean …".
|
|
5
|
+
*
|
|
6
|
+
* ─── WHY ────────────────────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* An external harness ledger (2026-08-15) recorded `pan-tools trace …` **18
|
|
9
|
+
* times** across three projects — the single most-repeated agent behaviour it
|
|
10
|
+
* had seen. PAN refused correctly every time:
|
|
11
|
+
*
|
|
12
|
+
* Error: Unknown command: trace. Run pan-tools --help to see available commands.
|
|
13
|
+
*
|
|
14
|
+
* The docs were investigated and cleared: `tests/doc-command-surface.test.cjs`
|
|
15
|
+
* passes, and no shipped surface teaches the bare form. So no prose change could
|
|
16
|
+
* explain those 18 sightings, and none would prevent the 19th.
|
|
17
|
+
*
|
|
18
|
+
* What PAN *can* fix is the RECOVERY. `trace` is not a nonsense token — it is a
|
|
19
|
+
* real subcommand sitting one namespace away, under `optimize`. Answering
|
|
20
|
+
* "unknown, go read the list of sixty commands" throws that away and costs a
|
|
21
|
+
* round trip. Naming the correct form converts a dead end into a self-correction,
|
|
22
|
+
* and it does so whatever made the caller type it — which matters precisely
|
|
23
|
+
* because the cause could not be established.
|
|
24
|
+
*
|
|
25
|
+
* ─── HOW IT STAYS TRUE ──────────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* The group→subcommand index is NOT hand-maintained. It is parsed from the
|
|
28
|
+
* dispatcher's own `Unknown <group> subcommand. Available: …` error strings,
|
|
29
|
+
* which are load-bearing — they are what a user sees — so they cannot rot
|
|
30
|
+
* quietly. This is the same trick `tests/doc-command-surface.test.cjs` uses, kept
|
|
31
|
+
* here rather than duplicated as a second list that would drift from the first.
|
|
32
|
+
*
|
|
33
|
+
* The parse happens ONLY on the error path, so a healthy invocation pays nothing,
|
|
34
|
+
* and every step fails open: if the source cannot be read or nothing matches, the
|
|
35
|
+
* caller falls back to the plain message it would have printed anyway.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract `group → [subcommands]` from dispatcher source text.
|
|
40
|
+
*
|
|
41
|
+
* Pure, so it can be tested against both the real dispatcher and fixtures.
|
|
42
|
+
* Tolerates trailing usage hints in the list (e.g. `clean [--apply] …`) by
|
|
43
|
+
* keeping only the leading bare token of each entry.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} sourceText
|
|
46
|
+
* @returns {Object<string, string[]>}
|
|
47
|
+
*/
|
|
48
|
+
function buildSubcommandIndex(sourceText) {
|
|
49
|
+
const index = {};
|
|
50
|
+
if (typeof sourceText !== 'string') return index;
|
|
51
|
+
const re = /Unknown ([a-z][a-z-]*) subcommand\. Available: ([^'"`\n]+)/g;
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = re.exec(sourceText)) !== null) {
|
|
54
|
+
const group = m[1];
|
|
55
|
+
const subs = m[2]
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim().split(/\s+/)[0]) // drop " [--apply]"-style hints
|
|
58
|
+
.filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
|
|
59
|
+
if (subs.length) index[group] = subs;
|
|
60
|
+
}
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Levenshtein distance, iterative and allocation-light. */
|
|
65
|
+
function editDistance(a, b) {
|
|
66
|
+
if (a === b) return 0;
|
|
67
|
+
if (!a.length) return b.length;
|
|
68
|
+
if (!b.length) return a.length;
|
|
69
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
70
|
+
for (let i = 1; i <= a.length; i++) {
|
|
71
|
+
const cur = [i];
|
|
72
|
+
for (let j = 1; j <= b.length; j++) {
|
|
73
|
+
cur[j] = Math.min(
|
|
74
|
+
prev[j] + 1,
|
|
75
|
+
cur[j - 1] + 1,
|
|
76
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
prev = cur;
|
|
80
|
+
}
|
|
81
|
+
return prev[b.length];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Suggest corrections for an unknown top-level command.
|
|
86
|
+
*
|
|
87
|
+
* Two kinds, most useful first:
|
|
88
|
+
* 1. **Namespace miss** — the token IS a real subcommand of one or more groups.
|
|
89
|
+
* This is the `trace` case, and the reason this module exists.
|
|
90
|
+
* 2. **Typo** — close to a real top-level command by edit distance.
|
|
91
|
+
*
|
|
92
|
+
* The threshold scales with length so short commands do not attract noise
|
|
93
|
+
* (`cost` vs `hud` should not match) while longer ones tolerate one slip.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} name the unknown token
|
|
96
|
+
* @param {Object} index from buildSubcommandIndex
|
|
97
|
+
* @param {string[]} topLevel known top-level command names
|
|
98
|
+
* @returns {string[]} suggestion lines, empty when nothing is close enough
|
|
99
|
+
*/
|
|
100
|
+
function suggestCommand(name, index, topLevel) {
|
|
101
|
+
if (!name || typeof name !== 'string') return [];
|
|
102
|
+
const token = name.trim().toLowerCase();
|
|
103
|
+
if (!token) return [];
|
|
104
|
+
const out = [];
|
|
105
|
+
|
|
106
|
+
// 1. Namespace misses. Deterministically ordered so the message is stable.
|
|
107
|
+
const owners = Object.keys(index || {})
|
|
108
|
+
.filter((group) => (index[group] || []).includes(token))
|
|
109
|
+
.sort();
|
|
110
|
+
for (const group of owners) out.push(`pan-tools ${group} ${token}`);
|
|
111
|
+
|
|
112
|
+
// 2. Typos against top-level commands — only when the token is not already a
|
|
113
|
+
// known subcommand, so a correct-but-misplaced token is never muddied by
|
|
114
|
+
// spelling guesses.
|
|
115
|
+
if (out.length === 0 && Array.isArray(topLevel)) {
|
|
116
|
+
const max = token.length <= 4 ? 1 : 2;
|
|
117
|
+
const near = topLevel
|
|
118
|
+
.filter((c) => typeof c === 'string' && c !== token)
|
|
119
|
+
.map((c) => ({ c, d: editDistance(token, c) }))
|
|
120
|
+
.filter((x) => x.d <= max)
|
|
121
|
+
.sort((a, b) => a.d - b.d || a.c.localeCompare(b.c))
|
|
122
|
+
.slice(0, 3)
|
|
123
|
+
.map((x) => `pan-tools ${x.c}`);
|
|
124
|
+
out.push(...near);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render the suggestions as the tail of an error message.
|
|
131
|
+
* Returns '' when there is nothing to add, so callers can concatenate blindly.
|
|
132
|
+
*/
|
|
133
|
+
function formatSuggestions(suggestions) {
|
|
134
|
+
if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
|
|
135
|
+
// Trailing period matters: the caller appends more prose, and without it the
|
|
136
|
+
// message ran together as "…optimize trace Run pan-tools --help".
|
|
137
|
+
if (suggestions.length === 1) return ` Did you mean: ${suggestions[0]}.`;
|
|
138
|
+
return ` Did you mean one of: ${suggestions.join(' | ')}.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { buildSubcommandIndex, suggestCommand, formatSuggestions, editDistance };
|