pan-wizard 3.25.0 → 3.27.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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/bin/install-lib.cjs +283 -1
  3. package/bin/install.js +127 -0
  4. package/commands/pan/hygiene.md +14 -8
  5. package/commands/pan/milestone-audit.md +10 -4
  6. package/hooks/dist/pan-cost-logger.js +69 -5
  7. package/hooks/dist/pan-stop-guard.js +32 -1
  8. package/hooks/dist/pan-trace-logger.js +35 -2
  9. package/package.json +3 -2
  10. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  11. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  12. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  13. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  14. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  15. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  16. package/pan-wizard-core/bin/lib/constants.cjs +27 -0
  17. package/pan-wizard-core/bin/lib/context-budget.cjs +28 -0
  18. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  19. package/pan-wizard-core/bin/lib/cost.cjs +0 -1
  20. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  21. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  22. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  23. package/pan-wizard-core/bin/lib/hygiene.cjs +397 -37
  24. package/pan-wizard-core/bin/lib/init.cjs +90 -13
  25. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  26. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  28. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  29. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  30. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  31. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  32. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  33. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  34. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  35. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/suggest.cjs +141 -0
  37. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  39. package/pan-wizard-core/bin/lib/verify-deploy.cjs +113 -2
  40. package/pan-wizard-core/bin/lib/verify.cjs +4 -3
  41. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  42. package/pan-wizard-core/bin/pan-tools.cjs +97 -7
  43. package/pan-wizard-core/mcp/native-tools.cjs +159 -0
  44. package/pan-wizard-core/mcp/orchestrator.cjs +179 -0
  45. package/{pan-zcode → pan-wizard-core}/mcp/server.cjs +35 -6
  46. package/{pan-zcode → pan-wizard-core}/mcp/tool-registry.cjs +60 -3
  47. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  48. package/pan-wizard-core/workflows/verify-phase.md +25 -6
  49. package/pan-zcode/README.md +17 -9
  50. package/pan-zcode/bin/install-zcode.js +4 -1
  51. package/scripts/build-plugin.js +35 -3
  52. package/scripts/deprecate-old-versions.js +225 -0
  53. package/scripts/plugin-path.js +84 -0
  54. package/pan-wizard-core/learnings/internal/.gitkeep +0 -2
  55. package/pan-wizard-core/learnings/internal/experiment-runner.md +0 -81
  56. package/pan-wizard-core/learnings/internal/external-research.md +0 -105
  57. package/pan-wizard-core/learnings/internal/loop-design.md +0 -33
  58. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +0 -181
  59. package/pan-zcode/mcp/native-tools.cjs +0 -63
  60. package/pan-zcode/mcp/orchestrator.cjs +0 -66
  61. /package/{pan-zcode → pan-wizard-core}/mcp/merge-gate.cjs +0 -0
@@ -135,7 +135,13 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
135
135
  }
136
136
  }
137
137
 
138
- const status = missing.length > 0 ? 'broken' : modified.length > 0 ? 'modified' : 'clean';
138
+ const mcp = validateMcpRegistration(cwd, configDir, runtime);
139
+ if (!mcp.ok) settingsIssues.push(...mcp.issues);
140
+
141
+ const status = missing.length > 0 ? 'broken'
142
+ : modified.length > 0 ? 'modified'
143
+ : !mcp.ok ? 'modified'
144
+ : 'clean';
139
145
 
140
146
  return {
141
147
  status,
@@ -144,11 +150,114 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
144
150
  missing,
145
151
  modified,
146
152
  orphaned: [],
147
- settings_ok: settingsOk,
153
+ settings_ok: settingsOk && mcp.ok,
148
154
  settings_issues: settingsIssues,
155
+ mcp,
149
156
  };
150
157
  }
151
158
 
159
+ /**
160
+ * Where each runtime's MCP registration lives, relative to the project (claude)
161
+ * or to the runtime's config dir (everything else), and under which container
162
+ * key. This MIRRORS `MCP_REGISTRATION` in bin/install-lib.cjs — the installer
163
+ * writes, this reads, and the two must agree.
164
+ *
165
+ * Duplicated rather than imported on purpose: install-lib.cjs is installer-side
166
+ * and is NOT shipped into an install, while this module runs from inside one. A
167
+ * scenario test pins the two tables against each other so the copy cannot drift
168
+ * silently.
169
+ *
170
+ * `codex` is absent because registration there is `register: false` — PAN prints
171
+ * a TOML snippet rather than writing config, so there is nothing to verify.
172
+ */
173
+ const MCP_EXPECTED = {
174
+ claude: { rel: '.mcp.json', fromProjectRoot: true, key: 'mcpServers' },
175
+ copilot: { rel: 'mcp.json', fromProjectRoot: false, key: 'mcpServers' },
176
+ gemini: { rel: 'settings.json', fromProjectRoot: false, key: 'mcpServers' },
177
+ opencode: { rel: 'opencode.json', fromProjectRoot: false, key: 'mcp' },
178
+ };
179
+
180
+ /**
181
+ * Verify the MCP registration this install wrote.
182
+ *
183
+ * WHY: `registerMcpServer()` writes up to four config files per install, and this
184
+ * verdict is the only thing most callers check afterwards. Until 2026-08 it had no
185
+ * idea MCP existed, so a fresh install reported `clean` whether registration
186
+ * succeeded, was skipped because the server file was missing, or was refused
187
+ * because the runtime's config was unparseable JSON. The installer recorded those
188
+ * cases as warnings; nothing surfaced them where anyone looks.
189
+ *
190
+ * Every part of this is checkable without launching the bridge: the config is
191
+ * present, it parses, it carries a `pan` entry, and the server path in that entry
192
+ * exists on disk. Whether a RUNTIME then loads it is a separate claim this cannot
193
+ * make, and does not.
194
+ *
195
+ * @returns {{ok:boolean, registered:boolean, path:string|null, issues:string[]}}
196
+ */
197
+ function validateMcpRegistration(cwd, configDir, runtime) {
198
+ const spec = MCP_EXPECTED[runtime];
199
+ // codex (and any future register:false runtime) has nothing to verify.
200
+ if (!spec) return { ok: true, registered: false, path: null, issues: [], skipped: 'no-registration-by-design' };
201
+
202
+ // ONLY expect a registration when this install actually SHIPS the bridge.
203
+ //
204
+ // An install made before the MCP bridge existed has no `.mcp.json` and never
205
+ // should have — flagging it would be a false alarm on every older deployment,
206
+ // and the first version of this check did exactly that, turning four green
207
+ // fixtures red for lacking a file they were never supposed to have. The
208
+ // installed tree is the authority: if `pan-wizard-core/mcp/server.cjs` is
209
+ // present, registration is expected; if it is not, there is nothing to verify.
210
+ const bridge = path.join(cwd, configDir, 'pan-wizard-core', 'mcp', 'server.cjs');
211
+ try {
212
+ fs.accessSync(bridge);
213
+ } catch (_) {
214
+ return { ok: true, registered: false, path: null, issues: [], skipped: 'bridge-not-in-this-install' };
215
+ }
216
+
217
+ const configPath = spec.fromProjectRoot
218
+ ? path.join(cwd, spec.rel)
219
+ : path.join(cwd, configDir, spec.rel);
220
+ const shown = path.relative(cwd, configPath) || spec.rel;
221
+
222
+ let parsed;
223
+ try {
224
+ parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
225
+ } catch (e) {
226
+ // Absent and unparseable are different failures and must read differently:
227
+ // one means registration never happened, the other means PAN deliberately
228
+ // left a file it could not safely rewrite.
229
+ const missingFile = e && e.code === 'ENOENT';
230
+ return {
231
+ ok: false,
232
+ registered: false,
233
+ path: shown,
234
+ issues: [missingFile
235
+ ? `MCP not registered: ${shown} is missing`
236
+ : `MCP config unreadable (left untouched by design): ${shown} — ${e.message}`],
237
+ };
238
+ }
239
+
240
+ const bag = parsed && parsed[spec.key];
241
+ const entry = bag && typeof bag === 'object' ? bag.pan : undefined;
242
+ if (!entry) {
243
+ return { ok: false, registered: false, path: shown, issues: [`MCP not registered: no "pan" entry under "${spec.key}" in ${shown}`] };
244
+ }
245
+
246
+ // The server path is `args[0]` everywhere except opencode, whose `command` is a
247
+ // single array of [cmd, ...args] — the shape difference that has already caused
248
+ // one bug in this feature.
249
+ const serverPath = Array.isArray(entry.command) ? entry.command[1] : (entry.args && entry.args[0]);
250
+ const issues = [];
251
+ if (!serverPath) {
252
+ issues.push(`MCP entry in ${shown} names no server path`);
253
+ } else {
254
+ try { fs.accessSync(serverPath); } catch (_) {
255
+ issues.push(`MCP server path does not exist: ${serverPath} (from ${shown})`);
256
+ }
257
+ }
258
+ return { ok: issues.length === 0, registered: true, path: shown, server: serverPath || null, issues };
259
+ }
260
+
152
261
  /**
153
262
  * CLI command: validate deployment
154
263
  * Validates PAN installations in the current directory.
@@ -190,4 +299,6 @@ module.exports = {
190
299
  detectInstalledRuntimes,
191
300
  validateRuntimeInstall,
192
301
  cmdValidateDeployment,
302
+ validateMcpRegistration,
303
+ MCP_EXPECTED,
193
304
  };
@@ -9,7 +9,7 @@ const { safeReadFile, normalizePhaseName, comparePhaseNum, execGit, findPhaseInt
9
9
  const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
10
10
  const { writeStateMd, readStateSafe } = require('./state.cjs');
11
11
  const {
12
- PLANNING_DIR, PHASES_DIR, STATE_FILE, ROADMAP_FILE, REQUIREMENTS_FILE, CONFIG_FILE, PROJECT_FILE, PATTERNS_FILE,
12
+ PHASES_DIR, STATE_FILE, ROADMAP_FILE, REQUIREMENTS_FILE, CONFIG_FILE, PROJECT_FILE, PATTERNS_FILE,
13
13
  isPlanFile, isSummaryFile, isVerificationFile, PHASE_HEADER_RE, PHASE_DIR_RE, ARCHIVE_DIR_RE, FIELD_VALUE_RE,
14
14
  PLAN_SUFFIX, SUMMARY_SUFFIX, STANDARDS_FILE, STANDARDS_CATALOG, HEALTH_STATUS,
15
15
  BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
@@ -21,6 +21,7 @@ const { runDriftCheck, parseConventionRules, checkFileConventions, calculateDrif
21
21
  const { collectVerificationStats, countRoadmapPhases, groupGapPatterns, cmdRetro } = require('./verify-retro.cjs');
22
22
  const { detectInstalledRuntimes, validateRuntimeInstall, cmdValidateDeployment } = require('./verify-deploy.cjs');
23
23
  const { cmdPreflight, cmdDepsValidate } = require('./verify-preflight.cjs');
24
+ const { planningRootRel } = require('./planning-root.cjs');
24
25
 
25
26
  /**
26
27
  * Spot-check files mentioned in summary content.
@@ -715,7 +716,7 @@ function cmdValidateConsistency(cwd, raw) {
715
716
  */
716
717
  function checkPlanningDirExists(cwd, addIssue) {
717
718
  if (!fileAccessible(planningPath(cwd))) {
718
- addIssue('error', 'E001', PLANNING_DIR + '/ directory not found', 'Run /pan:new-project to initialize');
719
+ addIssue('error', 'E001', planningRootRel() + '/ directory not found', 'Run /pan:new-project to initialize');
719
720
  return false;
720
721
  }
721
722
  return true;
@@ -998,7 +999,7 @@ function repairIssues(cwd, repairs) {
998
999
  const milestone = getMilestoneInfo(cwd);
999
1000
  let stateContent = '# Session State\n\n';
1000
1001
  stateContent += '## Project Reference\n\n';
1001
- stateContent += `See: ${PLANNING_DIR}/${PROJECT_FILE}\n\n`;
1002
+ stateContent += `See: ${planningRootRel()}/${PROJECT_FILE}\n\n`;
1002
1003
  stateContent += '## Position\n\n';
1003
1004
  stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
1004
1005
  stateContent += '**Current phase:** (determining...)\n';
@@ -19,7 +19,6 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const { output, error, safeReadFile, isGitRepo, execGit, toPosix, findPhaseInternal } = require('./core.cjs');
22
- const { PLANNING_DIR } = require('./constants.cjs');
23
22
  const { planningPath } = require('./utils.cjs');
24
23
 
25
24
  const COUNTERFACTUALS_DIR = 'counterfactuals';
@@ -128,7 +128,7 @@
128
128
  * init verify-work <phase> All context for verify-work workflow
129
129
  * init phase-op <phase> Generic phase operation context
130
130
  * init todos [area] All context for todo workflows
131
- * init milestone-op All context for milestone operations
131
+ * init milestone-op [--all-tracks] All context for milestone operations
132
132
  * init map-codebase All context for map-codebase workflow
133
133
  * init progress All context for progress workflow
134
134
  *
@@ -219,6 +219,8 @@ const docLint = require('./lib/doc-lint.cjs');
219
219
  const learnLint = require('./lib/learn-lint.cjs');
220
220
  const learnIndex = require('./lib/learn-index.cjs');
221
221
  const links = require('./lib/links.cjs');
222
+ const { setPlanningRoot, describePlanningRoot } = require('./lib/planning-root.cjs');
223
+ const stateCompact = require('./lib/state-compact.cjs');
222
224
 
223
225
  /**
224
226
  * Get the value following a flag in the args array.
@@ -233,6 +235,30 @@ function getArgValue(args, flag, defaultVal = null) {
233
235
  return args[idx + 1];
234
236
  }
235
237
 
238
+ /**
239
+ * Read a global `--flag value` / `--flag=value` pair and REMOVE it from args,
240
+ * so per-command parsers never see it. Returns null when the flag is absent.
241
+ *
242
+ * @param {string[]} args - CLI arguments (mutated)
243
+ * @param {string} flag - Flag name (e.g. '--track')
244
+ * @returns {string|null} The flag's value, or null
245
+ */
246
+ function takeFlagValue(args, flag) {
247
+ const eqArg = args.find(a => a.startsWith(`${flag}=`));
248
+ if (eqArg) {
249
+ const value = eqArg.slice(flag.length + 1).trim();
250
+ if (!value) error(`Missing value for ${flag}`);
251
+ args.splice(args.indexOf(eqArg), 1);
252
+ return value;
253
+ }
254
+ const idx = args.indexOf(flag);
255
+ if (idx === -1) return null;
256
+ const value = args[idx + 1];
257
+ if (!value || value.startsWith('--')) error(`Missing value for ${flag}`);
258
+ args.splice(idx, 2);
259
+ return value;
260
+ }
261
+
236
262
  /**
237
263
  * Parse JSON string or call error() with a descriptive message.
238
264
  * @param {string} raw - Raw JSON string
@@ -270,6 +296,22 @@ async function main() {
270
296
  error(`Invalid --cwd: ${cwd}`);
271
297
  }
272
298
 
299
+ // Which planning tree do we act on? `--cwd` moves the PROJECT root; these
300
+ // move the PLANNING root inside it, so a repo holding several planning trees
301
+ // can address any of them. Parsed here, before dispatch, because the answer
302
+ // has to be settled once for every path the command will build.
303
+ const planningDirFlag = takeFlagValue(args, '--planning-dir');
304
+ const trackFlag = takeFlagValue(args, '--track');
305
+ const allTracksIndex = args.indexOf('--all-tracks');
306
+ const allTracks = allTracksIndex !== -1;
307
+ if (allTracks) args.splice(allTracksIndex, 1);
308
+
309
+ try {
310
+ setPlanningRoot({ planningDir: planningDirFlag, track: trackFlag });
311
+ } catch (e) {
312
+ error(e.message);
313
+ }
314
+
273
315
  const rawIndex = args.indexOf('--raw');
274
316
  const raw = rawIndex !== -1;
275
317
  if (rawIndex !== -1) args.splice(rawIndex, 1);
@@ -282,7 +324,13 @@ async function main() {
282
324
 
283
325
  const command = args[0];
284
326
 
285
- const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>]\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links';
327
+ const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>] [--track <name> | --planning-dir <path>] [--all-tracks]\n'
328
+ + '\nPlanning root (which .planning tree to act on):\n'
329
+ + ' --track <name> act on .planning/tracks/<name>/ instead of .planning/\n'
330
+ + ' --planning-dir <path> act on an arbitrary project-relative planning tree\n'
331
+ + ' --all-tracks (hygiene) act on the root tree AND every discovered track\n'
332
+ + ' env: PAN_TRACK, PAN_PLANNING_DIR (flags win)\n'
333
+ + '\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links';
286
334
 
287
335
  if (!command) {
288
336
  error(USAGE);
@@ -317,6 +365,11 @@ async function main() {
317
365
  }
318
366
  }
319
367
  state.cmdStatePatch(cwd, patches, raw);
368
+ } else if (subcommand === 'compact') {
369
+ stateCompact.cmdStateCompact(cwd, {
370
+ apply: args.includes('--apply'),
371
+ keepDays: getArgValue(args, '--keep-days'),
372
+ }, raw);
320
373
  } else if (subcommand === 'advance-plan') {
321
374
  state.cmdStateAdvancePlan(cwd, raw);
322
375
  } else if (subcommand === 'record-metric') {
@@ -765,7 +818,7 @@ async function main() {
765
818
  init.cmdInitTodos(cwd, args[2], raw);
766
819
  break;
767
820
  case 'milestone-op':
768
- init.cmdInitMilestoneOp(cwd, raw);
821
+ init.cmdInitMilestoneOp(cwd, raw, { allTracks });
769
822
  break;
770
823
  case 'map-codebase':
771
824
  init.cmdInitMapCodebase(cwd, raw);
@@ -1087,13 +1140,14 @@ async function main() {
1087
1140
  const hygieneOpts = {
1088
1141
  traceAgeDays: getArgValue(args, '--trace-age-days'),
1089
1142
  apply: args.includes('--apply'),
1143
+ allTracks,
1090
1144
  };
1091
1145
  if (subcommand === 'scan') {
1092
1146
  hygiene.cmdHygieneScan(cwd, hygieneOpts, raw);
1093
1147
  } else if (subcommand === 'clean') {
1094
1148
  hygiene.cmdHygieneClean(cwd, hygieneOpts, raw);
1095
1149
  } else {
1096
- error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N]');
1150
+ error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N] [--all-tracks] [--track <name>]');
1097
1151
  }
1098
1152
  break;
1099
1153
  }
@@ -1499,7 +1553,24 @@ async function main() {
1499
1553
  break;
1500
1554
  }
1501
1555
 
1502
- // Default: convenience alias for optimize learn (existing behavior)
1556
+ // BARE `learn` is the documented convenience alias for `optimize learn`
1557
+ // (docs/CLI-REFERENCE.md lists it as `learn (alias)`), so it is kept.
1558
+ //
1559
+ // An UNKNOWN subcommand, however, used to fall through to that same alias
1560
+ // and silently run trace analysis. A ledger flagged `learn` as colliding
1561
+ // with `optimize learn`; the collision turned out to be a superset rather
1562
+ // than two meanings, but this fallthrough was the real defect underneath —
1563
+ // `pan-tools learn promotee` (a typo) ran the analyser and returned a
1564
+ // trace-session error, so the caller concluded `promote` was broken.
1565
+ //
1566
+ // `learn` was also the ONLY group of the ~33 that never published an
1567
+ // "Available:" list, which made its subcommands invisible both to a user
1568
+ // and to the suggestion index that is now parsed from those strings.
1569
+ // Publishing it fixes the error AND feeds the suggester, with no second
1570
+ // list to maintain.
1571
+ if (subcommand) {
1572
+ error('Unknown learn subcommand. Available: promote, unpromote, list-promoted, build-index, topics-for, lint');
1573
+ }
1503
1574
  optimize.cmdOptimizeLearn(cwd, {
1504
1575
  sessionId: getArgValue(args, '--session'),
1505
1576
  }, raw);
@@ -1528,8 +1599,27 @@ async function main() {
1528
1599
  error(`Unknown links subcommand: ${subcommand}. Available: validate`);
1529
1600
  }
1530
1601
 
1531
- default:
1532
- error(`Unknown command: ${command}. Run pan-tools --help to see available commands.`);
1602
+ default: {
1603
+ // A ledger recorded `pan-tools trace` 18 times — the most-repeated agent
1604
+ // behaviour it had seen — and every one got "unknown, go read the list".
1605
+ // But `trace` is a REAL subcommand one namespace away (`optimize trace`),
1606
+ // so naming the right form turns a dead end into a self-correction. The
1607
+ // docs were cleared as the cause, which is exactly why the fix belongs
1608
+ // here: it works whatever led the caller to type it.
1609
+ //
1610
+ // Parsed from this file's own "Available:" strings on the error path only,
1611
+ // so nothing is paid on a healthy call and no second list can drift. Fails
1612
+ // open to the original message.
1613
+ let hint = '';
1614
+ try {
1615
+ const suggest = require('./lib/suggest.cjs');
1616
+ const src = require('fs').readFileSync(__filename, 'utf8');
1617
+ const topLevel = (USAGE.split('Commands: ')[1] || '').split(',').map((s) => s.trim()).filter(Boolean);
1618
+ hint = suggest.formatSuggestions(
1619
+ suggest.suggestCommand(command, suggest.buildSubcommandIndex(src), topLevel));
1620
+ } catch { /* suggestions are a courtesy — never let them mask the error */ }
1621
+ error(`Unknown command: ${command}.${hint} Run pan-tools --help to see available commands.`);
1622
+ }
1533
1623
  }
1534
1624
  }
1535
1625
 
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PAN-Z M2 — native MCP tools whose logic lives in-process (not a pan-tools spawn).
5
+ *
6
+ * These are the deterministic grafts the review demanded: the orchestrator's
7
+ * `next-action` state machine and the two-step merge gate. A native tool declares a
8
+ * `handler({ cwd, input, env, gitImpl }) -> { json | text, isError? }` instead of a
9
+ * `verb`; a thrown Error is surfaced as JSON-RPC -32602 (invalid params) by the server.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const mergeGate = require('./merge-gate.cjs');
15
+ const orchestrator = require('./orchestrator.cjs');
16
+
17
+ /**
18
+ * Fill in the `verified` run fact from disk where the caller did not state it.
19
+ *
20
+ * WHY THIS EXISTS. `nextAction` is pure and takes `verified`/`merged` as run
21
+ * facts, which is what makes the human merge gate reachable at all. But nothing
22
+ * asks the caller to remember them, and a phase that is `complete` without
23
+ * `verified` returns `verify` — so a project whose phases were completed in an
24
+ * earlier session would be told to verify phase 1 forever. That is a live-lock of
25
+ * exactly the kind this whole fix set out to remove, just moved one step along.
26
+ *
27
+ * PAN already records verification on disk: `verification.md` (or
28
+ * `*-verification.md`) inside the phase directory. `classifyPhaseStatus()` ignores
29
+ * it — it counts plans against summaries and nothing else — which is precisely why
30
+ * `verified` could not come from the status vocabulary and had to be a run fact.
31
+ *
32
+ * So: derive it here, in the impure layer that has a cwd, and leave `nextAction`
33
+ * pure. An explicit value from the caller always wins. `merged` is NOT derived —
34
+ * no file records it, and a phase resting at `request_merge` until a human acts is
35
+ * the gate working, not a stall.
36
+ *
37
+ * Fail-open: any fs problem leaves the snapshot exactly as the caller sent it.
38
+ */
39
+ function enrichVerifiedFromDisk(cwd, state) {
40
+ if (!state || !Array.isArray(state.phases)) return state;
41
+ let phaseDirs;
42
+ try {
43
+ const root = path.join(cwd, '.planning', 'phases');
44
+ phaseDirs = fs.readdirSync(root, { withFileTypes: true })
45
+ .filter((e) => e.isDirectory())
46
+ .map((e) => ({ name: e.name, full: path.join(root, e.name) }));
47
+ } catch {
48
+ return state; // no .planning/phases — nothing to derive
49
+ }
50
+
51
+ const hasVerification = (dir) => {
52
+ try {
53
+ return fs.readdirSync(dir).some((f) => f === 'verification.md' || f.endsWith('-verification.md'));
54
+ } catch { return false; }
55
+ };
56
+
57
+ const phases = state.phases.map((p) => {
58
+ if (!p || typeof p !== 'object' || p.verified !== undefined) return p;
59
+ const num = String(p.number == null ? '' : p.number).trim();
60
+ if (!num) return p;
61
+ // Phase dirs are `NN-slug`; match on the leading number with or without
62
+ // zero padding, so both "3" and "03" find `03-foo`.
63
+ const padded = num.padStart(2, '0');
64
+ const match = phaseDirs.find((d) => d.name === num || d.name.startsWith(`${num}-`) || d.name.startsWith(`${padded}-`));
65
+ if (!match) return p;
66
+ return hasVerification(match.full) ? Object.assign({}, p, { verified: true }) : p;
67
+ });
68
+ return Object.assign({}, state, { phases });
69
+ }
70
+
71
+ const NATIVE_TOOLS = [
72
+ {
73
+ name: 'pan_next_action',
74
+ title: 'Next deterministic action',
75
+ // The description is the ONLY thing an LLM caller reads, so it names the
76
+ // correct source explicitly. An audit found callers following the old prose
77
+ // ("assemble from the pan-mcp resources") straight into an infinite plan
78
+ // loop: `pan://progress` reports Title Case and `pan://phases` returns
79
+ // directory names with no status at all. `pan_roadmap_analyze` is the source
80
+ // whose shape actually matches — say so here rather than in a comment.
81
+ description: 'Given a phase snapshot, return the next step the primary agent should take (plan/execute/verify/request_merge/await_approval/stop), enforcing the safety caps, the regression circuit-breaker and the human merge gate. Build `state.phases` from `pan_roadmap_analyze` (its `phases[].disk_status` matches this contract); `pan://phases` carries no status and is NOT a substitute. Set `verified`/`merged` on a phase once you have performed those steps — they are run facts no file records, and without them the merge gate is never reached.',
82
+ readOnly: true, destructive: false,
83
+ inputSchema: {
84
+ type: 'object', additionalProperties: false, required: ['state'],
85
+ properties: {
86
+ state: {
87
+ type: 'object',
88
+ // `phases` is REQUIRED. It previously was not, so `{}` — or any object
89
+ // with a misspelled key — collapsed to an empty list and reported
90
+ // "all_complete / done:true", i.e. a malformed request read as success.
91
+ required: ['phases'],
92
+ properties: {
93
+ phases: {
94
+ type: 'array',
95
+ description: 'Ordered phases. status is the lowercase disk vocabulary (empty|discussed|researched|planned|partial|complete); case is folded, so Title Case from pan://progress is accepted.',
96
+ items: {
97
+ type: 'object',
98
+ required: ['status'],
99
+ properties: {
100
+ number: { type: ['number', 'string'] },
101
+ status: { type: 'string' },
102
+ verified: { type: 'boolean', description: 'Run fact: verification passed for this phase.' },
103
+ merged: { type: 'boolean', description: 'Run fact: this phase has been merged.' },
104
+ },
105
+ },
106
+ },
107
+ cycles: { type: 'number' },
108
+ points_used: { type: 'number' },
109
+ tests_before: { type: 'number' },
110
+ tests_after: { type: 'number' },
111
+ awaiting_approval: { type: 'boolean' },
112
+ aborted: { type: 'boolean' },
113
+ },
114
+ },
115
+ caps: { type: 'object' },
116
+ },
117
+ },
118
+ handler: ({ cwd, input }) => {
119
+ if (!input.state || typeof input.state !== 'object') throw new Error('Invalid "state": an object snapshot is required');
120
+ // Derive `verified` from disk for any phase the caller left unset, so the
121
+ // verify step terminates on a real project instead of looping. Explicit
122
+ // caller values are never overwritten.
123
+ return { json: orchestrator.nextAction(enrichVerifiedFromDisk(cwd, input.state), input.caps) };
124
+ },
125
+ },
126
+ {
127
+ name: 'pan_request_merge',
128
+ title: 'Request a gated merge',
129
+ description: 'Stage a squash-merge request for a branch and mark it awaiting human approval. Records intent only — does NOT merge.',
130
+ readOnly: false, destructive: false,
131
+ inputSchema: {
132
+ type: 'object', additionalProperties: false, required: ['branch'],
133
+ properties: { branch: { type: 'string' }, ci_green: { type: 'boolean' }, verify_pass: { type: 'boolean' } },
134
+ },
135
+ handler: ({ cwd, input }) => ({
136
+ json: mergeGate.requestMerge(cwd, { branch: input.branch, ci_green: input.ci_green, verify_pass: input.verify_pass }),
137
+ }),
138
+ },
139
+ {
140
+ name: 'pan_confirm_merge',
141
+ title: 'Confirm a human-approved merge',
142
+ description: 'Perform a squash-merge ONLY if CI is green, verify passed, and a human-origin approval token (env PAN_MERGE_APPROVAL equal to the request\'s approval_token) is present. Any agent-supplied approval is ignored; never force-pushes or rewrites history.',
143
+ readOnly: false, destructive: true,
144
+ inputSchema: {
145
+ type: 'object', additionalProperties: false, required: ['branch'],
146
+ properties: { branch: { type: 'string' } },
147
+ },
148
+ handler: ({ cwd, input, env, gitImpl }) => {
149
+ const res = mergeGate.confirmMerge(cwd, { branch: input.branch }, env, gitImpl);
150
+ // A refused gate (missing approval / CI / verify) is a normal, non-error result the
151
+ // agent should read; only a real git failure is flagged isError.
152
+ const gitFailed = !res.merged && Array.isArray(res.reasons)
153
+ && res.reasons.some((r) => r === 'git_merge_failed' || r === 'git_commit_failed');
154
+ return { json: res, isError: gitFailed };
155
+ },
156
+ },
157
+ ];
158
+
159
+ module.exports = { NATIVE_TOOLS };