pan-wizard 3.24.0 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/pan-conductor.md +1 -1
- package/bin/install-lib.cjs +283 -1
- package/bin/install.js +127 -0
- package/commands/pan/army.md +14 -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/lib/worktree.cjs +98 -0
- package/pan-wizard-core/bin/pan-tools.cjs +42 -4
- 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/agents/pan-conductor.md
CHANGED
|
@@ -192,7 +192,7 @@ This agent file ships to all runtimes (keeps the installer uniform), but only ge
|
|
|
192
192
|
When invoked by `/pan:army` (ADR-0033), you are **Mission Control** for a whole-project campaign, not a single phase — same harness, wider scope. The differences:
|
|
193
193
|
|
|
194
194
|
- **You delegate to squads, not bare agents.** Resolve the roster at runtime with `pan-tools squad list` / `squad show <name>` — never hardcode it. Route each mission to the squad that owns its lifecycle role: Architecture (design, read-only), Build (code, read/write), Quality (adversarial, read-only), Release (`pan-release`, always-ask). Workers (document_code, distiller) take narrow, high-volume jobs; they are the agents the `budget` profile drops to the `fast` tier, and run at the inherited reasoning tier otherwise.
|
|
195
|
-
- **Build parallelizes by worktree.** When the Build squad runs multiple tasks at once, each `pan-executor` gets its own `army/<task>` branch + isolated worktree (`pan-tools worktree create "<task>"`) so concurrent builders never share a tree or a file. The spawn cap and budget ceiling still bound the fan-out.
|
|
195
|
+
- **Build parallelizes by worktree — and you tear the worktrees down.** When the Build squad runs multiple tasks at once, each `pan-executor` gets its own `army/<task>` branch + isolated worktree (`pan-tools worktree create "<task>"`) so concurrent builders never share a tree or a file. The spawn cap and budget ceiling still bound the fan-out. Those worktrees are scaffolding: after each task's squash-merge lands, `pan-tools worktree remove <path> --branch army/<task>`; at campaign end or on any abort, `pan-tools worktree cleanup` sweeps the strays (`--force` to also discard dirty or unintegrated ones). Leaving `pan-army-*` sibling directories behind is a campaign defect, not residue (P-1815).
|
|
196
196
|
- **Integration is human-gated.** You never merge to a protected branch. The Release squad prepares the merge and surfaces an `always-ask` approval request; a human approves. Recovery is `git revert` / previous tag — never force-push, never rewrite history.
|
|
197
197
|
- **The loop carries learnings.** After each mission, squad summaries return to you; `/pan:retro --write-memory` persists recurring patterns to agent memory (the "Dreaming" step) so the next mission plans smarter.
|
|
198
198
|
|
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/commands/pan/army.md
CHANGED
|
@@ -133,6 +133,8 @@ Spawn the Quality squad on the built tree (parallel, read-only). Merge findings
|
|
|
133
133
|
### Phase 5 — Integrate (Release, human-gated)
|
|
134
134
|
Spawn `pan-release`. It prepares the squash-merge, runs the configured `verification`, and surfaces an **always-ask** approval request. A human approves the merge to the protected branch; release then tags and records the rollback target. `--push` pushes the approved result.
|
|
135
135
|
|
|
136
|
+
**Teardown after the merge lands (P-1815):** worktrees are scaffolding, not deliverables — the squash-merge is the permanent artifact. Once the approval lands, remove each integrated task's worktree **and** its branch in one call: `pan-tools worktree remove ../pan-army-<slug> --branch army/<slug>`. The `--branch` half is not optional: a squash-merged branch never looks merged to git, so nothing else will ever clean it. At campaign end — and on **any** abort path — sweep whatever remains: `pan-tools worktree cleanup` (`--force` also discards dirty worktrees and unintegrated branches; without it the sweep keeps anything that might hold the only copy of work, and says exactly why and how to delete it). A campaign that ends with `pan-tools worktree list` non-empty has skipped this step.
|
|
137
|
+
|
|
136
138
|
**Phase report (opt-in build deliverable):** when `workflow.phase_reports.enabled` is `true`, generate the mission's self-contained per-phase HTML report **in the built tree, before staging the squash-merge** — `pan-tools report phase <N>` — so the report rides along in the merge as a phase deliverable. **Never run `report index` inside a squad worktree:** the timeline index is a single shared file that aggregates *all* phases, so a worktree would see only its own phase and concurrent squads would race on it. The index is a single-writer, post-merge concern (Phase 6). Never opens a browser.
|
|
137
139
|
|
|
138
140
|
### Phase 6 — Learn (Dreaming)
|
|
@@ -142,6 +144,18 @@ Squad summaries return to Mission Control. Run `/pan:retro --write-memory` (and
|
|
|
142
144
|
|
|
143
145
|
---
|
|
144
146
|
|
|
147
|
+
<output_paths>
|
|
148
|
+
|
|
149
|
+
- the project tree + `.planning/orchestration/` — mission state, reports, loop-state (permanent)
|
|
150
|
+
- `<parent-of-project>/pan-army-<task-slug>/` — one worktree per Build task, a **sibling of the project directory** (temporary — removed per task by the Phase 5 teardown; strays swept by `pan-tools worktree cleanup`)
|
|
151
|
+
- branches `army/<task-slug>` — the worktrees' branches (temporary — deleted with their worktree once the squash-merge lands; squash-merged branches never look merged to git and must be deleted explicitly)
|
|
152
|
+
|
|
153
|
+
Nothing under `pan-army-*` or on an `army/*` branch is a deliverable: the squash-merge into the protected branch is the permanent artifact, same doctrine as what-if's disposable worktrees.
|
|
154
|
+
|
|
155
|
+
</output_paths>
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
145
159
|
## Scheduled, self-resuming campaigns (ADR-0034)
|
|
146
160
|
|
|
147
161
|
PAN is not a daemon — it cannot wake itself while the session is closed. `--schedule` arms a campaign and lets an external trigger drive it; the human merge gate is never relaxed.
|
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 };
|