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.
@@ -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
- return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
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.25.0",
4
- "description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
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 };