pan-wizard 3.21.1 → 3.24.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 (136) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
  68. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
  69. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  70. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  71. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  72. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  73. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  74. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  76. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  77. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  78. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  79. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  80. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  81. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  82. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  83. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  84. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  85. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  86. package/pan-wizard-core/learnings/index.json +7 -7
  87. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  88. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  89. package/pan-wizard-core/references/design-methodology.md +94 -0
  90. package/pan-wizard-core/references/git-integration.md +9 -9
  91. package/pan-wizard-core/references/guardrails.md +4 -1
  92. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  93. package/pan-wizard-core/references/model-profiles.md +44 -34
  94. package/pan-wizard-core/references/planning-config.md +5 -5
  95. package/pan-wizard-core/references/verification-patterns.md +67 -0
  96. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  97. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  98. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  99. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  100. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  101. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  102. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  103. package/pan-wizard-core/templates/design.md +146 -0
  104. package/pan-wizard-core/templates/discovery.md +3 -3
  105. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  106. package/pan-wizard-core/templates/playbook.md +1 -1
  107. package/pan-wizard-core/templates/uat.md +1 -1
  108. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  109. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  110. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  111. package/pan-wizard-core/workflows/health.md +24 -1
  112. package/pan-wizard-core/workflows/help.md +5 -4
  113. package/pan-wizard-core/workflows/learn.md +5 -5
  114. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  116. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  117. package/pan-wizard-core/workflows/new-project.md +47 -32
  118. package/pan-wizard-core/workflows/optimize.md +8 -8
  119. package/pan-wizard-core/workflows/pause.md +1 -1
  120. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  121. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  122. package/pan-wizard-core/workflows/profile.md +13 -10
  123. package/pan-wizard-core/workflows/research-phase.md +1 -1
  124. package/pan-wizard-core/workflows/resume-project.md +1 -1
  125. package/pan-wizard-core/workflows/settings.md +9 -9
  126. package/pan-wizard-core/workflows/transition.md +24 -8
  127. package/pan-wizard-core/workflows/update.md +23 -20
  128. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  129. package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
  130. package/pan-zcode/README.md +14 -2
  131. package/pan-zcode/bin/install-zcode.js +1 -1
  132. package/pan-zcode/mcp/native-tools.cjs +1 -1
  133. package/pan-zcode/mcp/server.cjs +76 -14
  134. package/scripts/build-hooks.js +2 -1
  135. package/scripts/generate-skills-docs.py +0 -4
  136. package/scripts/run-tests.cjs +11 -4
package/bin/install.js CHANGED
@@ -38,11 +38,54 @@ const reset = '\x1b[0m';
38
38
  // Get version from package.json
39
39
  const pkg = require('../package.json');
40
40
 
41
+ // Concrete model ids the E-9 capability advisory (see finishInstall) names to
42
+ // the user. Kept here so the next lineup move is an edit to this object rather
43
+ // than a hunt through printed strings — the printed recommendation has already
44
+ // had to be re-pointed as the lineup moved (docs/ECOSYSTEM-REVIEW-2026-06.md,
45
+ // "Stale capability detection"; B4.1, audit 2026-08). The advisory itself is
46
+ // phrased by capability, not by name; these are the "switch to this" examples
47
+ // that keep the advice actionable. Nothing in PAN gates on these values.
48
+ const RECOMMENDED_MODELS = {
49
+ flagship: 'claude-fable-5',
50
+ reasoningTier: 'claude-opus-5 / claude-opus-4-8',
51
+ };
52
+
41
53
  // Source repo root — prevent installing PAN into its own source directory
42
54
  const PAN_SOURCE_ROOT = path.resolve(__dirname, '..');
43
55
  // Windows paths are case-insensitive; normalize for comparison
44
56
  const normPath = p => process.platform === 'win32' ? p.toLowerCase() : p;
45
57
 
58
+ /**
59
+ * Render a path for console output: relative to cwd when it is inside the project,
60
+ * absolute otherwise, always with forward slashes.
61
+ *
62
+ * Messages used to hardcode literals like '.codex/hooks.json'. That was only true
63
+ * for a local install — a --global or --config-dir install writes elsewhere, so the
64
+ * installer told users to inspect a file that did not exist while the real one sat
65
+ * somewhere they were never shown.
66
+ */
67
+ const displayPath = (p) => {
68
+ const rel = path.relative(process.cwd(), p);
69
+ const use = (rel && !rel.startsWith('..')) ? rel : p;
70
+ return use.split(path.sep).join('/');
71
+ };
72
+
73
+ /**
74
+ * True when `cwd` is the PAN source repo root OR any subdirectory of it — a
75
+ * containment check, not an exact match, so `cd docs && node ../bin/install.js`
76
+ * is also refused (a subdir install plants un-ignored .claude/AGENTS.md/etc.
77
+ * because .gitignore's self-install patterns are root-anchored). Uses
78
+ * fs.realpathSync on both sides so a symlink/junction into the repo can't bypass
79
+ * the guard (path.resolve alone does not canonicalize). Mirrors the
80
+ * memory-rebuild module's isInsideSourceRepo() helper.
81
+ */
82
+ function isInsideSourceRepo(cwd) {
83
+ const realOr = p => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
84
+ const abs = normPath(realOr(cwd));
85
+ const src = normPath(realOr(PAN_SOURCE_ROOT));
86
+ return abs === src || abs.startsWith(src + path.sep) || abs.startsWith(src + '/');
87
+ }
88
+
46
89
  // IMPROVEMENT-TODO P0 (v3.7.10): warning collector for non-fatal install
47
90
  // failures. Replaces silent `catch {}` blocks in copy paths. Surfaced at end
48
91
  // of install if non-empty. Required failures still throw / exit non-zero.
@@ -219,19 +262,65 @@ console.log(banner);
219
262
 
220
263
  // Show help if requested
221
264
  if (hasHelp) {
222
- console.log(` ${yellow}Usage:${reset} npx pan-wizard [options]\n\n ${yellow}Options:${reset}\n ${cyan}-l, --local${reset} Install locally to current directory (default)\n ${cyan}-g, --global${reset} Install globally to config directory\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall PAN (remove all PAN files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--unified-skills${reset} Install commands as one shared .agents/skills/ tree (ADR-0028 alpha)\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime; installs project-level)${reset}\n npx pan-wizard\n\n ${dim}# Install for Claude Code in current project (default, --local implied)${reset}\n npx pan-wizard --claude\n\n ${dim}# Install for all runtimes in current project${reset}\n npx pan-wizard --all --local\n\n ${dim}# Install globally (available in all projects)${reset}\n npx pan-wizard --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx pan-wizard --gemini --global\n\n ${dim}# Install to custom config directory${reset}\n npx pan-wizard --codex --global --config-dir ~/.codex-work\n\n ${dim}# Uninstall PAN from Codex globally${reset}\n npx pan-wizard --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n By default, PAN installs into the current project directory only.\n Use --global to install system-wide (writes to ~/.claude, ~/.gemini, etc.).\n The --config-dir option takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME.\n`);
265
+ console.log(` ${yellow}Usage:${reset} npx pan-wizard [options]\n\n ${yellow}Options:${reset}\n ${cyan}-l, --local${reset} Install locally to current directory (default)\n ${cyan}-g, --global${reset} Install globally to config directory\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for GitHub Copilot CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall PAN (remove all PAN files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--unified-skills${reset} Install commands as one shared .agents/skills/ tree (ADR-0028 alpha)\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime; installs project-level)${reset}\n npx pan-wizard\n\n ${dim}# Install for Claude Code in current project (default, --local implied)${reset}\n npx pan-wizard --claude\n\n ${dim}# Install for all runtimes in current project${reset}\n npx pan-wizard --all --local\n\n ${dim}# Install globally (available in all projects)${reset}\n npx pan-wizard --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx pan-wizard --gemini --global\n\n ${dim}# Install to custom config directory${reset}\n npx pan-wizard --codex --global --config-dir ~/.codex-work\n\n ${dim}# Uninstall PAN from Codex globally${reset}\n npx pan-wizard --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n By default, PAN installs into the current project directory only.\n Use --global to install system-wide (writes to ~/.claude, ~/.gemini, etc.).\n The --config-dir option takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME.\n`);
223
266
  process.exit(0);
224
267
  }
225
268
 
226
269
  /**
227
- * Read and parse settings.json, returning empty object if it doesn't exist
270
+ * Read and parse settings.json.
271
+ *
272
+ * Returns `{}` when there is nothing to preserve — the file is absent or empty.
273
+ * Returns `null` when the file EXISTS but cannot be used: unreadable, not valid
274
+ * JSON, or valid JSON of the wrong shape (an array, a string, `null`). Callers
275
+ * MUST treat `null` as "leave this file alone" — see settingsUnusable().
276
+ *
277
+ * Why the distinction is load-bearing: every caller merges PAN's keys into the
278
+ * object this returns and writes the result back. While parse failure also
279
+ * returned `{}`, an unparseable settings.json — a `//` comment is the common
280
+ * case, since people write them even though the format is strict JSON — came
281
+ * back as empty and was overwritten with PAN's keys alone. The user's model
282
+ * choice, permissions and auth settings were destroyed with no warning, no
283
+ * backup, and exit 0. Returning `null` makes that outcome unreachable: a caller
284
+ * that forgets to check throws instead of silently discarding user data.
285
+ *
286
+ * Comments are deliberately NOT tolerated via parseJsonc here. Parsing JSONC
287
+ * and writing strict JSON back would drop the comments — a quieter version of
288
+ * the same data loss. Warn and skip instead, exactly as configureOpencodePermissions
289
+ * already does for opencode.json.
228
290
  */
229
291
  function readSettings(settingsPath) {
292
+ if (!fs.existsSync(settingsPath)) return {};
293
+ let content;
230
294
  try {
231
- return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
295
+ content = fs.readFileSync(settingsPath, 'utf8');
232
296
  } catch {
233
- return {};
297
+ return null; // exists but unreadable (locked, permissions) — do not touch
234
298
  }
299
+ if (content.trim() === '') return {}; // empty file: nothing to preserve
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(content);
303
+ } catch {
304
+ return null;
305
+ }
306
+ // Valid JSON of the wrong shape is still unusable: merging PAN's keys into an
307
+ // array or a primitive and writing it back would corrupt the file.
308
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
309
+ return parsed;
310
+ }
311
+
312
+ /**
313
+ * Report an unusable settings file and tell the user we left it alone.
314
+ * Returns true when the caller must skip (i.e. readSettings returned null),
315
+ * so call sites read: `if (settingsUnusable(settings, p, 'hook config')) return;`
316
+ */
317
+ function settingsUnusable(settings, settingsPath, whatWasSkipped) {
318
+ if (settings !== null) return false;
319
+ const name = path.basename(settingsPath);
320
+ console.log(` ${yellow}⚠${reset} Could not parse ${name} - skipping ${whatWasSkipped}`);
321
+ console.log(` ${dim}${settingsPath}${reset}`);
322
+ console.log(` ${dim}Your file was NOT modified. Fix the syntax (strict JSON — no comments or trailing commas) and re-run.${reset}`);
323
+ return true;
235
324
  }
236
325
 
237
326
  /**
@@ -262,11 +351,11 @@ function getCommitAttribution(runtime) {
262
351
  let result;
263
352
 
264
353
  if (runtime === 'opencode') {
265
- const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
354
+ const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json')) || {}; // unusable file = no info (read-only probe)
266
355
  result = config.disable_ai_attribution === true ? null : undefined;
267
356
  } else if (runtime === 'gemini') {
268
357
  // Gemini: check gemini settings.json for attribution config
269
- const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
358
+ const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json')) || {}; // unusable file = no info (read-only probe)
270
359
  if (!settings.attribution || settings.attribution.commit === undefined) {
271
360
  result = undefined;
272
361
  } else if (settings.attribution.commit === '') {
@@ -276,7 +365,7 @@ function getCommitAttribution(runtime) {
276
365
  }
277
366
  } else if (runtime === 'claude') {
278
367
  // Claude Code
279
- const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
368
+ const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json')) || {}; // unusable file = no info (read-only probe)
280
369
  if (!settings.attribution || settings.attribution.commit === undefined) {
281
370
  result = undefined;
282
371
  } else if (settings.attribution.commit === '') {
@@ -288,9 +377,9 @@ function getCommitAttribution(runtime) {
288
377
  // Copilot CLI: user-editable settings live in settings.json; config.json is
289
378
  // legacy (auto-migrated by the CLI, now internal state) — fall back for old installs
290
379
  const copilotDir = getGlobalDir('copilot', explicitConfigDir);
291
- let config = readSettings(path.join(copilotDir, 'settings.json'));
380
+ let config = readSettings(path.join(copilotDir, 'settings.json')) || {}; // unusable file = no info (read-only probe)
292
381
  if (!config.attribution) {
293
- config = readSettings(path.join(copilotDir, 'config.json'));
382
+ config = readSettings(path.join(copilotDir, 'config.json')) || {}; // unusable file = no info (read-only probe)
294
383
  }
295
384
  if (!config.attribution || config.attribution.commit === undefined) {
296
385
  result = undefined;
@@ -612,6 +701,51 @@ function copySharedCore(srcDir, destDir, corePrefix, runtimePathPrefix, runtime)
612
701
  } catch (err) {
613
702
  if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/internal', err);
614
703
  }
704
+ stripInternalFromLearningsIndex(path.join(destDir, 'learnings', 'index.json'));
705
+ }
706
+
707
+ /**
708
+ * Drop internal-scoped topics from an installed learnings/index.json and recompute
709
+ * its totals.
710
+ *
711
+ * Deleting learnings/internal/ from disk is only half the strip: the index still
712
+ * listed those topics, so every install shipped file paths that do not exist —
713
+ * dangling references for any consumer that resolves them — along with the internal
714
+ * topic names, their pattern ids, and totals counting content the package
715
+ * deliberately withholds. Each topic entry carries its own size_bytes and
716
+ * size_tokens_est, so the totals are recomputed exactly rather than estimated.
717
+ *
718
+ * Best-effort by design: a malformed or absent index is not worth failing an install
719
+ * over, and the strip of the files themselves has already happened.
720
+ *
721
+ * @param {string} indexPath - Path to the installed learnings/index.json
722
+ */
723
+ function stripInternalFromLearningsIndex(indexPath) {
724
+ let parsed;
725
+ try {
726
+ parsed = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
727
+ } catch (err) {
728
+ if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
729
+ return;
730
+ }
731
+ if (!parsed || !Array.isArray(parsed.topics)) return;
732
+
733
+ const kept = parsed.topics.filter(t => t && t.scope !== 'internal');
734
+ if (kept.length === parsed.topics.length) return; // nothing internal to drop
735
+
736
+ parsed.topics = kept;
737
+ if (parsed.totals && typeof parsed.totals === 'object') {
738
+ parsed.totals.topics = kept.length;
739
+ parsed.totals.patterns = kept.reduce((n, t) => n + (Array.isArray(t.patterns) ? t.patterns.length : 0), 0);
740
+ parsed.totals.size_bytes = kept.reduce((n, t) => n + (t.size_bytes || 0), 0);
741
+ parsed.totals.size_tokens_est = kept.reduce((n, t) => n + (t.size_tokens_est || 0), 0);
742
+ }
743
+
744
+ try {
745
+ fs.writeFileSync(indexPath, JSON.stringify(parsed, null, 2) + '\n');
746
+ } catch (err) {
747
+ pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
748
+ }
615
749
  }
616
750
 
617
751
  /**
@@ -767,8 +901,14 @@ function cleanupOrphanedFiles(configDir) {
767
901
  for (const relPath of orphanedFiles) {
768
902
  const fullPath = path.join(configDir, relPath);
769
903
  if (fs.existsSync(fullPath)) {
770
- fs.unlinkSync(fullPath);
771
- console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
904
+ // A locked/permission-denied legacy file must not abort the whole install
905
+ // with an unhandled exception — surface it as a warning (M3, ADR audit 2026-08).
906
+ try {
907
+ fs.unlinkSync(fullPath);
908
+ console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
909
+ } catch (err) {
910
+ pushInstallWarning('staleCleanup', relPath, err);
911
+ }
772
912
  }
773
913
  }
774
914
  }
@@ -815,14 +955,22 @@ function cleanupOrphanedHooks(settings) {
815
955
  console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
816
956
  }
817
957
 
818
- // Fix #330: Update statusLine if it points to old statusline.js path
958
+ // Fix #330: migrate PAN's OWN legacy hooks/statusline.js path to
959
+ // pan-statusline.js. Anchor on the `hooks/` PREFIX plus the file basename —
960
+ // PAN's legacy hook was only ever registered under a hooks/ directory — so a
961
+ // user's custom command like `my-custom-statusline.js` or a script literally
962
+ // named `statusline.js` (e.g. `node ./statusline.js`) is NOT rewritten. The
963
+ // old looser matches corrupted such names to nonexistent pan-statusline.js
964
+ // paths and then made the M6/N4 statusline guard misclassify them as
965
+ // PAN-owned and stand down (M6 residual). Both slash directions are kept:
966
+ // Windows settings.json may carry escaped backslashes.
967
+ const legacyStatusline = /(^|[\\/])hooks([\\/])statusline\.js\b/;
819
968
  if (settings.statusLine && settings.statusLine.command &&
820
- settings.statusLine.command.includes('statusline.js') &&
969
+ legacyStatusline.test(settings.statusLine.command) &&
821
970
  !settings.statusLine.command.includes('pan-statusline.js')) {
822
- // Replace old path with new path
823
971
  settings.statusLine.command = settings.statusLine.command.replace(
824
- /statusline\.js/,
825
- 'pan-statusline.js'
972
+ legacyStatusline,
973
+ '$1hooks$2pan-statusline.js'
826
974
  );
827
975
  console.log(` ${green}✓${reset} Updated statusline path (statusline.js → pan-statusline.js)`);
828
976
  }
@@ -857,8 +1005,8 @@ function uninstall(isGlobal, runtime = 'claude') {
857
1005
  if (runtime === 'codex') runtimeLabel = 'Codex';
858
1006
  if (runtime === 'copilot') runtimeLabel = 'GitHub Copilot CLI';
859
1007
 
860
- // Guard: never uninstall from the PAN source repository itself
861
- if (normPath(path.resolve(process.cwd())) === normPath(PAN_SOURCE_ROOT)) {
1008
+ // Guard: never uninstall from the PAN source repository itself (or any subdir)
1009
+ if (isInsideSourceRepo(process.cwd())) {
862
1010
  console.error(`\n ${red}✗${reset} Refusing to uninstall from PAN's own source repository.`);
863
1011
  console.error(` Run from your target project directory instead.\n`);
864
1012
  process.exit(1);
@@ -1046,7 +1194,7 @@ function uninstall(isGlobal, runtime = 'claude') {
1046
1194
  // 4. Remove PAN hooks (scripts + Copilot CLI hooks config file)
1047
1195
  const hooksDir = path.join(targetDir, 'hooks');
1048
1196
  if (fs.existsSync(hooksDir)) {
1049
- const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan.json'];
1197
+ const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan.json'];
1050
1198
  let hookCount = 0;
1051
1199
  for (const hook of panHooks) {
1052
1200
  const hookPath = path.join(hooksDir, hook);
@@ -1212,8 +1360,16 @@ function uninstall(isGlobal, runtime = 'claude') {
1212
1360
 
1213
1361
  // 6b. Clean up settings.json (remove PAN hooks and statusline)
1214
1362
  const settingsPath = path.join(targetDir, 'settings.json');
1215
- if (fs.existsSync(settingsPath)) {
1216
- let settings = readSettings(settingsPath);
1363
+ // Unparseable on the way OUT too: stripping PAN's keys means writing the file
1364
+ // back, which would replace the user's content. Skip just this step — NOT the
1365
+ // whole uninstall, which still has the opencode permission cleanup and the
1366
+ // manifest removal to do. The raw-bytes guard further down still deletes a
1367
+ // settings.json that is literally PAN's own empty `{}`.
1368
+ const existingSettings = fs.existsSync(settingsPath) ? readSettings(settingsPath) : null;
1369
+ const skipSettingsCleanup = fs.existsSync(settingsPath)
1370
+ && settingsUnusable(existingSettings, settingsPath, 'settings.json cleanup');
1371
+ if (fs.existsSync(settingsPath) && !skipSettingsCleanup) {
1372
+ let settings = existingSettings;
1217
1373
  let settingsModified = false;
1218
1374
 
1219
1375
  // Remove PAN statusline if it references our hook
@@ -1289,6 +1445,27 @@ function uninstall(isGlobal, runtime = 'claude') {
1289
1445
  }
1290
1446
  }
1291
1447
 
1448
+ // Remove PAN stop guard from Stop (P-1809, v3.23+)
1449
+ if (settings.hooks && settings.hooks.Stop) {
1450
+ const before = settings.hooks.Stop.length;
1451
+ settings.hooks.Stop = settings.hooks.Stop.filter(entry => {
1452
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1453
+ const hasPanHook = entry.hooks.some(h =>
1454
+ h.command && h.command.includes('pan-stop-guard')
1455
+ );
1456
+ return !hasPanHook;
1457
+ }
1458
+ return true;
1459
+ });
1460
+ if (settings.hooks.Stop.length < before) {
1461
+ settingsModified = true;
1462
+ console.log(` ${green}✓${reset} Removed auto-advance stop guard hook from settings`);
1463
+ }
1464
+ if (settings.hooks.Stop.length === 0) {
1465
+ delete settings.hooks.Stop;
1466
+ }
1467
+ }
1468
+
1292
1469
  // Clean up empty hooks object
1293
1470
  if (settings.hooks && Object.keys(settings.hooks).length === 0) {
1294
1471
  delete settings.hooks;
@@ -1313,18 +1490,41 @@ function uninstall(isGlobal, runtime = 'claude') {
1313
1490
  writeSettings(settingsPath, settings);
1314
1491
  }
1315
1492
  removedCount++;
1493
+ } else if (Object.keys(settings).length === 0) {
1494
+ // No PAN entries and the parsed object is empty — but readSettings()
1495
+ // returns {} on ANY parse failure (BOM, comments, trailing comma), so an
1496
+ // empty object does NOT prove the file holds no user data. Only remove it
1497
+ // when the RAW bytes are literally an empty object; otherwise leave the
1498
+ // user's (unparseable) file untouched (N1 regression fix, ADR audit 2026-08).
1499
+ let rawIsEmptyObject = false;
1500
+ try {
1501
+ rawIsEmptyObject = fs.readFileSync(settingsPath, 'utf8').replace(/^/, '').trim() === '{}';
1502
+ } catch { /* unreadable — never delete */ }
1503
+ if (rawIsEmptyObject) {
1504
+ try {
1505
+ fs.unlinkSync(settingsPath);
1506
+ console.log(` ${green}✓${reset} Removed empty settings.json`);
1507
+ removedCount++;
1508
+ } catch (err) { pushInstallWarning('staleCleanup', settingsPath, err); }
1509
+ }
1316
1510
  }
1317
1511
  }
1318
1512
 
1319
1513
  // 6. For OpenCode, clean up permissions from opencode.json
1320
1514
  if (isOpencode) {
1321
1515
  // For local uninstalls, clean up ./.opencode/opencode.json
1322
- // For global uninstalls, clean up ~/.config/opencode/opencode.json
1516
+ // For global uninstalls, clean up the resolved global dir — via getGlobalDir so
1517
+ // --config-dir is honoured here as well. Otherwise `--opencode --global
1518
+ // --config-dir <path> --uninstall` looked in ~/.config/opencode and left the
1519
+ // real config untouched, which is half of how this became residue.
1323
1520
  const opencodeConfigDir = isGlobal
1324
- ? getOpencodeGlobalDir()
1521
+ ? getGlobalDir('opencode', explicitConfigDir)
1325
1522
  : path.join(process.cwd(), '.opencode');
1326
1523
  const configPath = path.join(opencodeConfigDir, 'opencode.json');
1327
- if (fs.existsSync(configPath)) {
1524
+ {
1525
+ // Read straight through instead of existsSync-then-read: absence and a corrupt
1526
+ // file both mean "nothing of ours to strip", and the check-then-use gap was a
1527
+ // CodeQL js/file-system-race. Same idiom as the hooks' metrics read.
1328
1528
  try {
1329
1529
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1330
1530
  let modified = false;
@@ -1335,7 +1535,13 @@ function uninstall(isGlobal, runtime = 'claude') {
1335
1535
  if (config.permission[permType]) {
1336
1536
  const keys = Object.keys(config.permission[permType]);
1337
1537
  for (const key of keys) {
1338
- if (key.includes('pan-wizard-core')) {
1538
+ // Match `pan-wizard` rather than `pan-wizard-core` so the malformed
1539
+ // key earlier versions wrote (`~/.config/opencode/pan-wizard/*`) is
1540
+ // cleaned too. Without this, machines that ran any global OpenCode
1541
+ // install before the glob was fixed keep that entry forever — the
1542
+ // uninstaller could never match it, which is what made it residue
1543
+ // rather than a cosmetic typo.
1544
+ if (key.includes('pan-wizard')) {
1339
1545
  delete config.permission[permType][key];
1340
1546
  modified = true;
1341
1547
  }
@@ -1352,12 +1558,20 @@ function uninstall(isGlobal, runtime = 'claude') {
1352
1558
  }
1353
1559
 
1354
1560
  if (modified) {
1355
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1561
+ // If stripping PAN permissions emptied the config, remove the file
1562
+ // rather than leaving a spurious {} behind — PAN created it via
1563
+ // configureOpencodePermissions, so an empty result is not a user file.
1564
+ if (Object.keys(config).length === 0) {
1565
+ fs.unlinkSync(configPath);
1566
+ console.log(` ${green}✓${reset} Removed empty opencode.json`);
1567
+ } else {
1568
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1569
+ console.log(` ${green}✓${reset} Removed PAN permissions from opencode.json`);
1570
+ }
1356
1571
  removedCount++;
1357
- console.log(` ${green}✓${reset} Removed PAN permissions from opencode.json`);
1358
1572
  }
1359
1573
  } catch (e) {
1360
- // Ignore JSON parse errors
1574
+ // Missing file (ENOENT) or unparseable JSON — nothing for us to remove.
1361
1575
  }
1362
1576
  }
1363
1577
  }
@@ -1436,9 +1650,13 @@ function uninstall(isGlobal, runtime = 'claude') {
1436
1650
  */
1437
1651
  function configureOpencodePermissions(isGlobal = true) {
1438
1652
  // For local installs, use ./.opencode/opencode.json
1439
- // For global installs, use ~/.config/opencode/opencode.json
1653
+ // For global installs, use the resolved global dir — via getGlobalDir so that
1654
+ // --config-dir is honoured. Calling getOpencodeGlobalDir() directly ignored the
1655
+ // flag, so `--opencode --global --config-dir <path>` installed the core into
1656
+ // <path> but wrote its permissions into ~/.config/opencode: the real install had
1657
+ // no permission config at all, and an unrelated directory got a stray one.
1440
1658
  const opencodeConfigDir = isGlobal
1441
- ? getOpencodeGlobalDir()
1659
+ ? getGlobalDir('opencode', explicitConfigDir)
1442
1660
  : path.join(process.cwd(), '.opencode');
1443
1661
  const configPath = path.join(opencodeConfigDir, 'opencode.json');
1444
1662
 
@@ -1467,9 +1685,17 @@ function configureOpencodePermissions(isGlobal = true) {
1467
1685
 
1468
1686
  // Build the PAN path using the actual config directory
1469
1687
  // Use ~ shorthand if it's in the default location, otherwise use full path
1688
+ // The directory PAN's core actually lands in is `pan-wizard-core`. The
1689
+ // default-location branch used to emit `~/.config/opencode/pan-wizard/*`, which
1690
+ // matches nothing — so every default global OpenCode install granted read access
1691
+ // to a path that does not exist while PAN's real core stayed un-allow-listed. It
1692
+ // also became permanent residue: the uninstaller only removed keys containing
1693
+ // `pan-wizard-core`, so the wrong key could never be matched or cleaned.
1694
+ // Keep the ~ form for the default location (portable, and what a user expects to
1695
+ // see in their own config) and an absolute path otherwise.
1470
1696
  const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1471
1697
  const panPath = opencodeConfigDir === defaultConfigDir
1472
- ? '~/.config/opencode/pan-wizard/*'
1698
+ ? '~/.config/opencode/pan-wizard-core/*'
1473
1699
  : `${opencodeConfigDir.replace(/\\/g, '/')}/pan-wizard-core/*`;
1474
1700
 
1475
1701
  let modified = false;
@@ -1548,6 +1774,10 @@ function verifyFileInstalled(filePath, description) {
1548
1774
  // ──────────────────────────────────────────────────────
1549
1775
 
1550
1776
  const PATCHES_DIR_NAME = 'pan-local-patches';
1777
+ // Backups of manifest entries that live OUTSIDE the runtime's config dir (Codex's
1778
+ // skills at ../.agents/skills/) land here, with the traversal segments stripped, so
1779
+ // the patches tree stays self-contained.
1780
+ const EXTERNAL_PATCHES_SUBDIR = '_external';
1551
1781
  const MANIFEST_NAME = 'pan-file-manifest.json';
1552
1782
 
1553
1783
  /**
@@ -1631,6 +1861,22 @@ function writeManifest(configDir, runtime = 'claude', isGlobal = false) {
1631
1861
  }
1632
1862
  }
1633
1863
  }
1864
+ // Claude native skill shims: flat skills/pan-*.md files (E-5). Only the Claude
1865
+ // runtime's nested-commands install writes these; under --unified-skills the
1866
+ // shared .agents/skills tree above already covers skills. Tracking them lets
1867
+ // verifyInstall catch silent shim-write failures and saveLocalPatches back up
1868
+ // user edits — the codex/copilot SKILL.md branch above matches directories, not
1869
+ // these flat files.
1870
+ if (runtime === 'claude' && !unifiedSkills) {
1871
+ const claudeSkillsDir = path.join(configDir, 'skills');
1872
+ if (fs.existsSync(claudeSkillsDir)) {
1873
+ for (const file of fs.readdirSync(claudeSkillsDir)) {
1874
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
1875
+ manifest.files['skills/' + file] = fileHash(path.join(claudeSkillsDir, file));
1876
+ }
1877
+ }
1878
+ }
1879
+ }
1634
1880
  if (fs.existsSync(agentsDir)) {
1635
1881
  for (const file of fs.readdirSync(agentsDir)) {
1636
1882
  if (file.startsWith('pan-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
@@ -1678,21 +1924,44 @@ function saveLocalPatches(configDir) {
1678
1924
 
1679
1925
  const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1680
1926
  const modified = [];
1927
+ const externalBackups = {};
1681
1928
 
1682
1929
  for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
1683
- // Keys reaching outside configDir (Codex skills in ../.agents/skills/)
1684
- // can't be backed up under patchesDir — path.join would collapse the
1685
- // `..` and write outside the patches tree. Skip them; they're still
1686
- // overwritten cleanly on reinstall.
1687
- if (relPath.split('/').includes('..')) continue;
1930
+ // Keys reaching outside configDir — Codex's skills live in ../.agents/skills/ —
1931
+ // used to be SKIPPED here, because joining a `..` path under patchesDir would
1932
+ // collapse the `..` and write outside the patches tree.
1933
+ //
1934
+ // Skipping was the wrong half of that trade: those keys are Codex's ONLY command
1935
+ // surface, so a Codex user who tuned a skill silently lost the edit on the next
1936
+ // upgrade, with no mention in the output and no backup to recover from — while
1937
+ // docs/USER-GUIDE.md and docs/AGENTS.md both promise, unqualified, that the
1938
+ // installer backs up locally modified files.
1939
+ //
1940
+ // Back them up under `_external/` with the traversal segments stripped instead.
1941
+ // That keeps the containment the guard existed for (the final path cannot escape
1942
+ // patchesDir — asserted below) while honouring the promise.
1943
+ const segments = relPath.split(/[\\/]/).filter(Boolean);
1944
+ const escapes = segments.some(s => s === '..');
1945
+ const safeRel = escapes
1946
+ ? path.join(EXTERNAL_PATCHES_SUBDIR, ...segments.filter(s => s !== '..' && s !== '.'))
1947
+ : relPath;
1948
+
1688
1949
  const fullPath = path.join(configDir, relPath);
1689
1950
  if (!fs.existsSync(fullPath)) continue;
1690
1951
  const currentHash = fileHash(fullPath);
1691
1952
  if (currentHash !== originalHash) {
1692
- const backupPath = path.join(patchesDir, relPath);
1953
+ const backupPath = path.join(patchesDir, safeRel);
1954
+ // Defence in depth: never write outside patchesDir, whatever the manifest says.
1955
+ const resolvedPatches = path.resolve(patchesDir);
1956
+ const resolvedBackup = path.resolve(backupPath);
1957
+ if (resolvedBackup !== resolvedPatches && !resolvedBackup.startsWith(resolvedPatches + path.sep)) {
1958
+ pushInstallWarning('saveLocalPatches', relPath, new Error('backup path escaped the patches directory — skipped'));
1959
+ continue;
1960
+ }
1693
1961
  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1694
1962
  fs.copyFileSync(fullPath, backupPath);
1695
1963
  modified.push(relPath);
1964
+ if (escapes) externalBackups[relPath] = safeRel.split(path.sep).join("/");
1696
1965
  }
1697
1966
  }
1698
1967
 
@@ -1700,7 +1969,11 @@ function saveLocalPatches(configDir) {
1700
1969
  const meta = {
1701
1970
  backed_up_at: new Date().toISOString(),
1702
1971
  from_version: manifest.version,
1703
- files: modified
1972
+ files: modified,
1973
+ // Files whose manifest key points outside the config dir are stored under
1974
+ // _external/ with the traversal stripped, so `files` alone would not locate
1975
+ // them. Map logical key -> path within pan-local-patches/ for the restore flow.
1976
+ ...(Object.keys(externalBackups).length > 0 ? { external_files: externalBackups } : {}),
1704
1977
  };
1705
1978
  fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
1706
1979
  console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified PAN file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
@@ -1723,7 +1996,9 @@ function reportLocalPatches(configDir, runtime = 'claude') {
1723
1996
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
1724
1997
 
1725
1998
  if (meta.files && meta.files.length > 0) {
1726
- const reapplyCommand = runtime === 'opencode'
1999
+ // Copilot uses the same flat /pan-<name> form as OpenCode; it previously fell
2000
+ // through to Claude's '/pan:patches', a command form that does not exist there.
2001
+ const reapplyCommand = (runtime === 'opencode' || runtime === 'copilot')
1727
2002
  ? '/pan-patches'
1728
2003
  : runtime === 'codex'
1729
2004
  ? '$pan-patches'
@@ -1772,8 +2047,8 @@ function install(isGlobal, runtime = 'claude') {
1772
2047
  if (isCodex) runtimeLabel = 'Codex';
1773
2048
  if (isCopilot) runtimeLabel = 'GitHub Copilot CLI';
1774
2049
 
1775
- // Guard: never install into the PAN source repository itself
1776
- if (normPath(path.resolve(process.cwd())) === normPath(PAN_SOURCE_ROOT)) {
2050
+ // Guard: never install into the PAN source repository itself (or any subdir)
2051
+ if (isInsideSourceRepo(process.cwd())) {
1777
2052
  console.error(`\n ${red}✗${reset} Refusing to install PAN into its own source repository.`);
1778
2053
  console.error(` Run the installer from your target project directory instead.\n`);
1779
2054
  console.error(` Example: cd /path/to/my-project && node ${path.resolve(__dirname, 'install.js')} --claude --local\n`);
@@ -1938,6 +2213,22 @@ function install(isGlobal, runtime = 'claude') {
1938
2213
  try {
1939
2214
  const skillsDir = path.join(targetDir, 'skills');
1940
2215
  fs.mkdirSync(skillsDir, { recursive: true });
2216
+ // Upgrade path: sweep stale pan-* shims before regenerating, mirroring
2217
+ // the stale-cleanup copyFlattenedCommands does for command trees.
2218
+ // A rename/removal in commands/pan would otherwise leave orphan shims.
2219
+ for (const file of fs.readdirSync(skillsDir)) {
2220
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
2221
+ try { fs.unlinkSync(path.join(skillsDir, file)); } catch (err) { pushInstallWarning('staleCleanup', file, err); }
2222
+ }
2223
+ }
2224
+ const workflowsDir = path.join(targetDir, 'workflows');
2225
+ if (fs.existsSync(workflowsDir)) {
2226
+ for (const file of fs.readdirSync(workflowsDir)) {
2227
+ if (file.startsWith('pan-') && file.endsWith('.js')) {
2228
+ try { fs.unlinkSync(path.join(workflowsDir, file)); } catch (err) { pushInstallWarning('staleCleanup', file, err); }
2229
+ }
2230
+ }
2231
+ }
1941
2232
  let shimCount = 0;
1942
2233
  for (const file of fs.readdirSync(panDest)) {
1943
2234
  if (!file.endsWith('.md')) continue;
@@ -1981,6 +2272,9 @@ function install(isGlobal, runtime = 'claude') {
1981
2272
  if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/internal', err);
1982
2273
  }
1983
2274
  }
2275
+ // The index lists those topics too — strip it here as well, or the metadata
2276
+ // ships even though the files did not.
2277
+ stripInternalFromLearningsIndex(path.join(skillDest, 'learnings', 'index.json'));
1984
2278
 
1985
2279
  if (verifyInstalled(skillDest, 'pan-wizard-core')) {
1986
2280
  console.log(` ${green}✓${reset} Installed pan-wizard-core`);
@@ -2022,7 +2316,13 @@ function install(isGlobal, runtime = 'claude') {
2022
2316
  // stripThinkingFrontmatter so `effort:` survives to be mapped to
2023
2317
  // Codex's native model_reasoning_effort field.
2024
2318
  if (isCodex) {
2025
- const toml = convertClaudeAgentToCodexToml(stripSubTags(content));
2319
+ // Rewrite /pan:command mentions to Codex's $pan-command syntax before
2320
+ // TOML conversion — the command/skill path already does this, but the
2321
+ // agent path previously shipped invalid /pan: invocations in
2322
+ // developer_instructions (audit L3).
2323
+ const toml = convertClaudeAgentToCodexToml(
2324
+ convertSlashCommandsToCodexSkillMentions(stripSubTags(content))
2325
+ );
2026
2326
  if (toml) {
2027
2327
  const tomlName = entry.name.replace(/\.md$/, '.toml');
2028
2328
  fs.writeFileSync(path.join(agentsDest, tomlName), toml);
@@ -2098,10 +2398,13 @@ function install(isGlobal, runtime = 'claude') {
2098
2398
  failures.push('VERSION');
2099
2399
  }
2100
2400
 
2101
- if (!isCodex) {
2102
- // Write package.json to force CommonJS mode for PAN scripts
2103
- // Prevents "require is not defined" errors when project has "type": "module"
2104
- // Node.js walks up looking for package.json - this stops inheritance from project
2401
+ {
2402
+ // Write package.json to force CommonJS mode for PAN scripts — for ALL
2403
+ // runtimes, INCLUDING Codex. The shipped hooks use require(); without this
2404
+ // marker Node walks up to the project's package.json and, in a
2405
+ // "type":"module" project, crashes every hook with "require is not defined"
2406
+ // (H1, ADR audit 2026-08). The uninstall step (see "Remove PAN package.json")
2407
+ // removes this marker for any runtime, so writing it for Codex is symmetric.
2105
2408
  try {
2106
2409
  const pkgJsonDest = path.join(targetDir, 'package.json');
2107
2410
  fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
@@ -2126,16 +2429,36 @@ function install(isGlobal, runtime = 'claude') {
2126
2429
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
2127
2430
  for (const entry of hookEntries) {
2128
2431
  const srcFile = path.join(hooksSrc, entry);
2129
- if (fs.statSync(srcFile).isFile()) {
2130
- const destFile = path.join(hooksDest, entry);
2131
- // Template .js files to replace '.claude' with runtime-specific config dir
2432
+ const destFile = path.join(hooksDest, entry);
2433
+ // Read straight through rather than statSync-then-read. hooks/dist is flat,
2434
+ // so "not a regular file" is a skip, and letting the read itself report that
2435
+ // closes the check-then-use gap CodeQL flagged (js/file-system-race).
2436
+ try {
2437
+ // Template .js files to replace '.claude' with runtime-specific config dir.
2438
+ // '.claude' plays two roles in the hooks: home-anchored (cache dir,
2439
+ // global VERSION → machine-global config dir) and project-anchored
2440
+ // (project VERSION → per-project config dir). Templating both with a
2441
+ // single token planted a stray ~/.github (Copilot --local) and a dead
2442
+ // project VERSION check (Copilot --global), since Copilot's global dir
2443
+ // is .copilot but its project dir is .github (audit L37). Resolve each
2444
+ // role independently and context-anchored so it's correct in both modes.
2132
2445
  if (entry.endsWith('.js')) {
2133
2446
  let content = fs.readFileSync(srcFile, 'utf8');
2447
+ const homeDirToken = getConfigDirFromHome(runtime, true); // machine-global config dir
2448
+ const projectDirToken = `'${getDirName(runtime)}'`; // per-project config dir
2449
+ content = content.replace(/(join\(\s*homeDir\s*,\s*)'\.claude'/g, `$1${homeDirToken}`);
2450
+ content = content.replace(/(join\(\s*cwd\s*,\s*)'\.claude'/g, `$1${projectDirToken}`);
2451
+ // Fallback for any unanchored '.claude' occurrences.
2134
2452
  content = content.replace(/'\.claude'/g, configDirReplacement);
2135
2453
  fs.writeFileSync(destFile, content);
2136
2454
  } else {
2137
2455
  fs.copyFileSync(srcFile, destFile);
2138
2456
  }
2457
+ } catch (e) {
2458
+ // A directory (EISDIR, or EPERM for a copy on Windows) or an entry that
2459
+ // vanished between readdir and read (ENOENT) is simply not a hook — skip
2460
+ // it. Anything else is a real failure and belongs to the outer handler.
2461
+ if (e.code !== 'EISDIR' && e.code !== 'ENOENT' && e.code !== 'EPERM') throw e;
2139
2462
  }
2140
2463
  }
2141
2464
  if (verifyInstalled(hooksDest, 'hooks')) {
@@ -2261,6 +2584,9 @@ function install(isGlobal, runtime = 'claude') {
2261
2584
  const traceLoggerCommand = isGlobal
2262
2585
  ? buildHookCommand(targetDir, 'pan-trace-logger.js')
2263
2586
  : 'node ' + dirName + '/hooks/pan-trace-logger.js';
2587
+ const stopGuardCommand = isGlobal
2588
+ ? buildHookCommand(targetDir, 'pan-stop-guard.js')
2589
+ : 'node ' + dirName + '/hooks/pan-stop-guard.js';
2264
2590
 
2265
2591
  if (isCodex) {
2266
2592
  // Codex hooks (2026-06): Claude-compatible PascalCase events in the shared
@@ -2275,7 +2601,10 @@ function install(isGlobal, runtime = 'claude') {
2275
2601
  updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand,
2276
2602
  });
2277
2603
  fs.writeFileSync(hooksJsonPath, JSON.stringify(merged, null, 2) + '\n');
2278
- console.log(` ${green}✓${reset} Configured hooks (.codex/hooks.json: update check, context monitor, cost + trace loggers)`);
2604
+ // Print the path we actually wrote. The hardcoded '.codex/hooks.json' was
2605
+ // wrong for --global and for --config-dir, telling users to inspect a file
2606
+ // that does not exist while the real one sat elsewhere.
2607
+ console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksJsonPath)}: update check, context monitor, cost + trace loggers)`);
2279
2608
  } catch (e) {
2280
2609
  pushInstallWarning('codexHooks', 'hooks.json', e);
2281
2610
  }
@@ -2291,7 +2620,8 @@ function install(isGlobal, runtime = 'claude') {
2291
2620
  try {
2292
2621
  fs.mkdirSync(path.dirname(hooksConfigPath), { recursive: true });
2293
2622
  fs.writeFileSync(hooksConfigPath, JSON.stringify(hooksConfig, null, 2) + '\n');
2294
- console.log(` ${green}✓${reset} Configured hooks (.github/hooks/pan.json: update check, context monitor, cost + trace loggers)`);
2623
+ // Same as the Codex case: a global Copilot install writes to ~/.copilot/, not .github/.
2624
+ console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksConfigPath)}: update check, context monitor, cost + trace loggers)`);
2295
2625
  } catch (e) {
2296
2626
  console.error(` ${yellow}✗${reset} Failed to write Copilot hooks config: ${e.message}`);
2297
2627
  }
@@ -2302,6 +2632,10 @@ function install(isGlobal, runtime = 'claude') {
2302
2632
  // .github/copilot/ repo-level); config.json is internal CLI state.
2303
2633
  const configPath = path.join(targetDir, 'config.json');
2304
2634
  const config = readSettings(configPath);
2635
+ // Unusable legacy config: skip the migration rather than rewrite the file.
2636
+ // Silent here (no warn) — config.json is internal CLI state the user did not
2637
+ // author, and settings.json is the surface that gets the warning.
2638
+ if (config === null) return;
2305
2639
  let legacyModified = false;
2306
2640
  if (config.hooks) {
2307
2641
  for (const evt of ['sessionStart', 'postToolUse']) {
@@ -2330,6 +2664,9 @@ function install(isGlobal, runtime = 'claude') {
2330
2664
  ? path.join(targetDir, 'settings.json')
2331
2665
  : path.join(targetDir, 'copilot', 'settings.json');
2332
2666
  const copilotSettings = readSettings(copilotSettingsPath);
2667
+ if (settingsUnusable(copilotSettings, copilotSettingsPath, 'statusline configuration')) {
2668
+ return { settingsPath: copilotSettingsPath, settings: null, statuslineCommand, runtime };
2669
+ }
2333
2670
 
2334
2671
  return { settingsPath: copilotSettingsPath, settings: copilotSettings, statuslineCommand, runtime };
2335
2672
  }
@@ -2337,7 +2674,13 @@ function install(isGlobal, runtime = 'claude') {
2337
2674
  // Configure statusline and hooks in settings.json
2338
2675
  // Claude Code, Gemini, OpenCode use settings.json
2339
2676
  const settingsPath = path.join(targetDir, 'settings.json');
2340
- const settings = cleanupOrphanedHooks(readSettings(settingsPath));
2677
+ const rawSettings = readSettings(settingsPath);
2678
+ if (settingsUnusable(rawSettings, settingsPath, 'statusline and hook configuration')) {
2679
+ // Returning the path with settings:null tells finishInstall to skip every
2680
+ // write. Overwriting would destroy whatever the user has in there.
2681
+ return { settingsPath, settings: null, statuslineCommand, runtime };
2682
+ }
2683
+ const settings = cleanupOrphanedHooks(rawSettings);
2341
2684
 
2342
2685
  // Enable experimental agents for Gemini CLI (required for custom sub-agents)
2343
2686
  if (isGemini) {
@@ -2433,6 +2776,30 @@ function install(isGlobal, runtime = 'claude') {
2433
2776
  });
2434
2777
  console.log(` ${green}✓${reset} Configured trace logger hook`);
2435
2778
  }
2779
+
2780
+ // v3.23+ (P-1809): Stop hook guarding the auto-advance phase boundary.
2781
+ // Blocks a session stop ONCE when workflow.auto_advance is armed, state.md
2782
+ // says "ready to plan", and the roadmap has unbuilt phases — the exact
2783
+ // boundary-drop fingerprint from the 2026-08 field runs. Fail-open and
2784
+ // inert outside PAN projects; hosts that never fire Stop never trigger it
2785
+ // (same convention as the SubagentStop registrations above).
2786
+ if (!settings.hooks.Stop) {
2787
+ settings.hooks.Stop = [];
2788
+ }
2789
+ const hasStopGuardHook = settings.hooks.Stop.some(entry =>
2790
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-stop-guard'))
2791
+ );
2792
+ if (!hasStopGuardHook) {
2793
+ settings.hooks.Stop.push({
2794
+ hooks: [
2795
+ {
2796
+ type: 'command',
2797
+ command: stopGuardCommand
2798
+ }
2799
+ ]
2800
+ });
2801
+ console.log(` ${green}✓${reset} Configured auto-advance stop guard hook`);
2802
+ }
2436
2803
  }
2437
2804
 
2438
2805
  return { settingsPath, settings, statuslineCommand, runtime };
@@ -2441,28 +2808,48 @@ function install(isGlobal, runtime = 'claude') {
2441
2808
  /**
2442
2809
  * Apply statusline config, then print completion message
2443
2810
  */
2444
- function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
2811
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, isPrimaryStatusline = true) {
2812
+ // settings === null means the existing file could not be parsed and the caller
2813
+ // already warned. Every branch below merges into `settings` and writes it back,
2814
+ // so proceeding would replace the user's file with PAN's keys alone. Skip, and
2815
+ // do NOT print the "Configured …" ticks — an install that silently reports
2816
+ // success while configuring nothing was its own finding in the 2026-08 test.
2817
+ if (settings === null) return;
2445
2818
  const isOpencode = runtime === 'opencode';
2446
2819
  const isCodex = runtime === 'codex';
2447
2820
  const isCopilot = runtime === 'copilot';
2448
2821
 
2449
2822
  if (shouldInstallStatusline && !isOpencode && !isCodex) {
2450
- // Same schema everywhere — Copilot CLI also uses {type: "command", command}
2451
- // in settings.json (statusline is experimental there as of 2026-05).
2452
- settings.statusLine = {
2453
- type: 'command',
2454
- command: statuslineCommand
2455
- };
2456
- console.log(` ${green}✓${reset} Configured statusline`);
2457
- if (isCopilot) {
2458
- console.log(` ${dim}ℹ Copilot CLI statusline is experimental — if it doesn't render, start with 'copilot --experimental'${reset}`);
2823
+ // Preserve a user's EXISTING custom statusline in THIS runtime's settings.
2824
+ // finishInstall runs per runtime, so this is the per-runtime check the old
2825
+ // primary-only guard skipped — it clobbered custom Gemini/Copilot
2826
+ // statuslines (M6, ADR audit 2026-08). PAN's own statusline is replaceable.
2827
+ const existingCmd = settings.statusLine && settings.statusLine.command;
2828
+ const isPanStatusline = typeof existingCmd === 'string' && /pan-statusline/.test(existingCmd);
2829
+ // Preserve an existing custom statusline only for SECONDARY runtimes (not
2830
+ // prompted). The primary runtime's shouldInstallStatusline already carries
2831
+ // the user's interactive answer, so don't second-guess it (N4 fix).
2832
+ if (settings.statusLine && !isPanStatusline && !isPrimaryStatusline && !args.includes('--force-statusline')) {
2833
+ console.log(` ${dim}ℹ Kept your existing statusline (pass --force-statusline to replace it)${reset}`);
2834
+ } else {
2835
+ // Same schema everywhere — Copilot CLI also uses {type: "command", command}
2836
+ // in settings.json (statusline is experimental there as of 2026-05).
2837
+ settings.statusLine = {
2838
+ type: 'command',
2839
+ command: statuslineCommand
2840
+ };
2841
+ console.log(` ${green}✓${reset} Configured statusline`);
2842
+ if (isCopilot) {
2843
+ console.log(` ${dim}ℹ Copilot CLI statusline is experimental — if it doesn't render, start with 'copilot --experimental'${reset}`);
2844
+ }
2459
2845
  }
2460
2846
  }
2461
2847
 
2462
- // Write settings/config when runtime supports it. For Copilot, skip the
2463
- // write when there is nothing to persist (avoids creating an empty
2464
- // .github/copilot/settings.json).
2465
- if (!isCodex && !(isCopilot && Object.keys(settings).length === 0)) {
2848
+ // Write settings/config when runtime supports it. Skip the write entirely when
2849
+ // there is nothing to persist — avoids creating a spurious empty settings.json
2850
+ // (Copilot's .github/copilot/settings.json and, notably, OpenCode's .opencode/
2851
+ // settings.json, which OpenCode doesn't even use — its config is opencode.json).
2852
+ if (!isCodex && Object.keys(settings).length > 0) {
2466
2853
  if (isCopilot) {
2467
2854
  try { fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); } catch { /* surfaced by writeSettings */ }
2468
2855
  }
@@ -2474,13 +2861,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
2474
2861
  configureOpencodePermissions(isGlobal);
2475
2862
  }
2476
2863
 
2477
- // E-9: Opus 4.7 capability detection — warn if user's default model lacks
2478
- // features Spec A relies on (1M ctx, extended thinking, prompt caching).
2864
+ // E-9 Model-capability integration (the name docs/ARCHITECTURE.md uses for
2865
+ // this mechanism) — warn if user's default model lacks features Spec A relies
2866
+ // on (1M ctx, extended thinking, prompt caching). Advisory only — there is no
2867
+ // runtime capability gate, and as of this writing this notice is the whole
2868
+ // reason detectModelCapabilities exists in the shipped path.
2479
2869
  if (!args.includes('--skip-warnings')) {
2480
2870
  try {
2481
- const settingsPath = path.join(targetDir, 'settings.json');
2482
- const settingsRaw = fs.readFileSync(settingsPath, 'utf-8');
2483
- const settings = JSON.parse(settingsRaw);
2871
+ // Use the `settings` param already resolved for this install. The prior
2872
+ // re-derivation read a nonexistent `targetDir`, throwing a ReferenceError
2873
+ // that the bare catch swallowed — so this warning could never fire (M5,
2874
+ // ADR audit 2026-08).
2484
2875
  const modelField = settings && settings.model;
2485
2876
  if (typeof modelField === 'string' && modelField.trim()) {
2486
2877
  const caps = detectModelCapabilities(modelField);
@@ -2491,8 +2882,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
2491
2882
  ].filter(Boolean).join(', ');
2492
2883
  console.log(`
2493
2884
  ${yellow}ℹ${reset} PAN's multi-agent workflows are tuned for frontier reasoning models. Default model "${modelField}" lacks: ${missing}.
2494
- Features degrade gracefully, but for best results select claude-fable-5 (PAN's recommended flagship — deepest
2495
- long-horizon reasoning for the bot army), or an Opus-tier model (claude-opus-5 / claude-opus-4-8) at lower cost.`);
2885
+ Features degrade gracefully, but for best results set your default model to one with a 1M-token context window
2886
+ and extended thinking — ${RECOMMENDED_MODELS.flagship} is PAN's recommended flagship (deepest long-horizon
2887
+ reasoning for the bot army), or an Opus-tier model (${RECOMMENDED_MODELS.reasoningTier}) at lower cost.`);
2496
2888
  }
2497
2889
  }
2498
2890
  } catch {
@@ -2533,6 +2925,13 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
2533
2925
  * Handle statusline configuration with optional prompt
2534
2926
  */
2535
2927
  function handleStatusline(settings, isInteractive, callback) {
2928
+ // settings === null means readSettings could not use the existing file and the
2929
+ // caller already warned. There is nothing to prompt about and nothing we may
2930
+ // write, so decline the statusline without asking.
2931
+ if (settings === null) {
2932
+ callback(false);
2933
+ return;
2934
+ }
2536
2935
  const hasExisting = settings.statusLine != null;
2537
2936
 
2538
2937
  if (!hasExisting) {
@@ -2644,13 +3043,20 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
2644
3043
  const finalize = (shouldInstallStatusline) => {
2645
3044
  for (const result of results) {
2646
3045
  const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
3046
+ // The primary runtime is the one handleStatusline actually prompted about,
3047
+ // so its shouldInstallStatusline reflects explicit user consent — honor it
3048
+ // even over an existing custom statusline. Secondary runtimes were NOT
3049
+ // prompted, so their existing custom statuslines are preserved (N4 fix keeps
3050
+ // the interactive "replace" working; M6 still protects the others).
3051
+ const isPrimaryStatusline = !!primaryStatuslineResult && result.runtime === primaryStatuslineResult.runtime;
2647
3052
  finishInstall(
2648
3053
  result.settingsPath,
2649
3054
  result.settings,
2650
3055
  result.statuslineCommand,
2651
3056
  useStatusline,
2652
3057
  result.runtime,
2653
- isGlobal
3058
+ isGlobal,
3059
+ isPrimaryStatusline
2654
3060
  );
2655
3061
  }
2656
3062
  };