pan-wizard 3.33.0 → 3.35.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 (214) hide show
  1. package/README.md +29 -26
  2. package/agents/pan-conductor.md +18 -17
  3. package/agents/pan-counterfactual.md +2 -2
  4. package/agents/pan-debugger.md +30 -9
  5. package/agents/pan-design-checker.md +2 -1
  6. package/agents/pan-designer.md +2 -2
  7. package/agents/pan-distiller.md +3 -3
  8. package/agents/pan-document_code.md +14 -14
  9. package/agents/pan-executor.md +30 -9
  10. package/agents/pan-experiment-runner.md +9 -8
  11. package/agents/pan-hardener.md +1 -1
  12. package/agents/pan-integration-checker.md +2 -2
  13. package/agents/pan-knowledge.md +1 -1
  14. package/agents/pan-meta-reviewer.md +4 -4
  15. package/agents/pan-optimizer.md +39 -43
  16. package/agents/pan-phase-researcher.md +10 -10
  17. package/agents/pan-plan-checker.md +37 -34
  18. package/agents/pan-planner.md +57 -44
  19. package/agents/pan-previewer.md +1 -1
  20. package/agents/pan-project-researcher.md +12 -12
  21. package/agents/pan-release.md +1 -1
  22. package/agents/pan-research-synthesizer.md +3 -3
  23. package/agents/pan-reviewer.md +26 -3
  24. package/agents/pan-roadmapper.md +15 -14
  25. package/agents/pan-verifier.md +33 -25
  26. package/bin/install-lib.cjs +176 -38
  27. package/bin/install.js +81 -63
  28. package/commands/pan/army.md +15 -11
  29. package/commands/pan/assumptions.md +2 -2
  30. package/commands/pan/audit-deployment.md +11 -12
  31. package/commands/pan/cost.md +8 -8
  32. package/commands/pan/dashboard.md +0 -3
  33. package/commands/pan/debug.md +10 -7
  34. package/commands/pan/design-phase.md +5 -5
  35. package/commands/pan/discuss-phase.md +2 -0
  36. package/commands/pan/exec-phase.md +21 -37
  37. package/commands/pan/experiment.md +9 -8
  38. package/commands/pan/focus-auto.md +22 -20
  39. package/commands/pan/focus-design.md +7 -6
  40. package/commands/pan/focus-doc-audit.md +4 -3
  41. package/commands/pan/focus-drift-walking.md +4 -3
  42. package/commands/pan/focus-exec.md +6 -6
  43. package/commands/pan/focus-plan.md +12 -11
  44. package/commands/pan/focus-scan.md +5 -3
  45. package/commands/pan/focus-sync.md +3 -2
  46. package/commands/pan/git.md +8 -8
  47. package/commands/pan/health.md +2 -2
  48. package/commands/pan/hud.md +4 -7
  49. package/commands/pan/hygiene.md +14 -7
  50. package/commands/pan/knowledge.md +9 -10
  51. package/commands/pan/learn.md +10 -7
  52. package/commands/pan/links.md +4 -1
  53. package/commands/pan/map-codebase.md +1 -1
  54. package/commands/pan/mcp-bridge.md +19 -21
  55. package/commands/pan/milestone-done.md +6 -3
  56. package/commands/pan/milestone-new.md +1 -1
  57. package/commands/pan/new-project.md +10 -10
  58. package/commands/pan/optimize.md +18 -14
  59. package/commands/pan/patches.md +2 -6
  60. package/commands/pan/pause.md +1 -1
  61. package/commands/pan/phase-tests.md +3 -2
  62. package/commands/pan/plan-phase.md +9 -19
  63. package/commands/pan/preview.md +2 -3
  64. package/commands/pan/profile.md +2 -2
  65. package/commands/pan/quick.md +1 -1
  66. package/commands/pan/remove-phase.md +1 -1
  67. package/commands/pan/report.md +3 -7
  68. package/commands/pan/research-phase.md +16 -15
  69. package/commands/pan/resume.md +9 -11
  70. package/commands/pan/retro.md +5 -3
  71. package/commands/pan/review-deep.md +7 -9
  72. package/commands/pan/settings.md +1 -1
  73. package/commands/pan/verify-phase.md +0 -11
  74. package/commands/pan/what-if.md +3 -4
  75. package/hooks/dist/pan-context-monitor.js +352 -44
  76. package/hooks/dist/pan-cost-logger.js +30 -0
  77. package/hooks/dist/pan-guard.js +244 -0
  78. package/hooks/dist/pan-statusline.js +73 -24
  79. package/hooks/dist/pan-stop-guard.js +7 -2
  80. package/package.json +1 -1
  81. package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
  82. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  83. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  84. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  85. package/pan-wizard-core/bin/lib/commands.cjs +57 -6
  86. package/pan-wizard-core/bin/lib/config.cjs +7 -1
  87. package/pan-wizard-core/bin/lib/constants.cjs +18 -6
  88. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  89. package/pan-wizard-core/bin/lib/core.cjs +53 -19
  90. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  91. package/pan-wizard-core/bin/lib/cost.cjs +142 -13
  92. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  93. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  94. package/pan-wizard-core/bin/lib/findings.cjs +135 -21
  95. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  96. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  97. package/pan-wizard-core/bin/lib/hygiene.cjs +184 -17
  98. package/pan-wizard-core/bin/lib/init.cjs +13 -4
  99. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  100. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  101. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  102. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  103. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  104. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  105. package/pan-wizard-core/bin/lib/optimize.cjs +97 -17
  106. package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
  107. package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
  108. package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
  109. package/pan-wizard-core/bin/lib/phase.cjs +39 -13
  110. package/pan-wizard-core/bin/lib/preview.cjs +34 -31
  111. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  112. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  113. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  114. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  115. package/pan-wizard-core/bin/lib/state.cjs +43 -28
  116. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  117. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  118. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  119. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  120. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
  121. package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
  122. package/pan-wizard-core/bin/lib/verify-scope.cjs +11 -7
  123. package/pan-wizard-core/bin/lib/verify.cjs +30 -28
  124. package/pan-wizard-core/bin/lib/whatif.cjs +53 -9
  125. package/pan-wizard-core/bin/pan-tools.cjs +114 -19
  126. package/pan-wizard-core/learnings/index.json +5 -5
  127. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  128. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  129. package/pan-wizard-core/opencode/pan-wizard.js +139 -0
  130. package/pan-wizard-core/references/checkpoints.md +3 -3
  131. package/pan-wizard-core/references/continuation-format.md +3 -3
  132. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  133. package/pan-wizard-core/references/design-methodology.md +1 -1
  134. package/pan-wizard-core/references/git-integration.md +4 -6
  135. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  136. package/pan-wizard-core/references/guardrails.md +2 -2
  137. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  138. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  139. package/pan-wizard-core/references/model-profiles.md +15 -10
  140. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  141. package/pan-wizard-core/references/planning-config.md +7 -7
  142. package/pan-wizard-core/references/tdd.md +10 -1
  143. package/pan-wizard-core/references/verdict-contract.md +6 -1
  144. package/pan-wizard-core/references/verification-patterns.md +5 -5
  145. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  146. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  147. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  148. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  149. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  150. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  151. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  152. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  153. package/pan-wizard-core/templates/config.json +2 -6
  154. package/pan-wizard-core/templates/continue-here.md +1 -1
  155. package/pan-wizard-core/templates/debug-subagent-prompt.md +5 -3
  156. package/pan-wizard-core/templates/debug.md +15 -1
  157. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  158. package/pan-wizard-core/templates/milestone.md +1 -1
  159. package/pan-wizard-core/templates/phase-prompt.md +31 -17
  160. package/pan-wizard-core/templates/planner-subagent-prompt.md +5 -5
  161. package/pan-wizard-core/templates/preview-report.md +1 -1
  162. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  163. package/pan-wizard-core/templates/research-project/features.md +1 -1
  164. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  165. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  166. package/pan-wizard-core/templates/research.md +5 -5
  167. package/pan-wizard-core/templates/retrospective.md +1 -1
  168. package/pan-wizard-core/templates/roadmap.md +15 -15
  169. package/pan-wizard-core/templates/state.md +4 -4
  170. package/pan-wizard-core/templates/summary-complex.md +1 -0
  171. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  172. package/pan-wizard-core/templates/summary-standard.md +2 -1
  173. package/pan-wizard-core/templates/summary.md +18 -2
  174. package/pan-wizard-core/templates/uat.md +3 -3
  175. package/pan-wizard-core/templates/user-setup.md +8 -8
  176. package/pan-wizard-core/templates/verification-report.md +53 -8
  177. package/pan-wizard-core/workflows/add-phase.md +1 -1
  178. package/pan-wizard-core/workflows/assumptions.md +1 -1
  179. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  180. package/pan-wizard-core/workflows/discuss-phase.md +10 -8
  181. package/pan-wizard-core/workflows/exec-phase.md +75 -95
  182. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  183. package/pan-wizard-core/workflows/health.md +13 -12
  184. package/pan-wizard-core/workflows/help.md +8 -9
  185. package/pan-wizard-core/workflows/learn.md +13 -11
  186. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  187. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  188. package/pan-wizard-core/workflows/milestone-gaps.md +18 -25
  189. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  190. package/pan-wizard-core/workflows/new-project.md +17 -25
  191. package/pan-wizard-core/workflows/optimize.md +11 -9
  192. package/pan-wizard-core/workflows/pause.md +6 -1
  193. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  194. package/pan-wizard-core/workflows/plan-phase.md +53 -42
  195. package/pan-wizard-core/workflows/profile.md +2 -2
  196. package/pan-wizard-core/workflows/progress.md +6 -8
  197. package/pan-wizard-core/workflows/quick.md +7 -5
  198. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  199. package/pan-wizard-core/workflows/research-phase.md +9 -5
  200. package/pan-wizard-core/workflows/resume-project.md +4 -6
  201. package/pan-wizard-core/workflows/retro.md +3 -1
  202. package/pan-wizard-core/workflows/settings.md +16 -24
  203. package/pan-wizard-core/workflows/todo-check.md +4 -3
  204. package/pan-wizard-core/workflows/transition.md +16 -12
  205. package/pan-wizard-core/workflows/update.md +25 -17
  206. package/pan-wizard-core/workflows/verify-phase.md +22 -20
  207. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  208. package/pan-zcode/README.md +7 -7
  209. package/pan-zcode/bin/install-zcode.js +1 -1
  210. package/scripts/build-hooks.js +1 -0
  211. package/scripts/build-plugin.js +3 -1
  212. package/scripts/generate-skills-docs.py +15 -6
  213. package/scripts/prompt-lint.cjs +167 -0
  214. package/scripts/test-surface.cjs +1 -0
@@ -655,6 +655,7 @@ function buildCostRecord(data, cwd) {
655
655
  let cacheWrite = 0;
656
656
  // { h1, m5 } when the source carried the cache-write lifetime split (M13).
657
657
  let ttlSplit = null;
658
+ let longPrompt = null;
658
659
  let durationMs = null;
659
660
  // token_source records WHICH path produced the counts: `agent-transcript` (the
660
661
  // subagent's own conversation file — see resolveAgentTranscript), `transcript`
@@ -717,6 +718,9 @@ function buildCostRecord(data, cwd) {
717
718
  if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
718
719
  ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
719
720
  }
721
+ // The long-prompt part of the slice, kept only when nothing was clamped (a clamped
722
+ // total no longer contains it).
723
+ if (fromTranscript.long_prompt && !clamped) longPrompt = fromTranscript.long_prompt;
720
724
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
721
725
  if (!model) model = fromTranscript.model;
722
726
  if (fromTranscript.lineCount > since) {
@@ -809,6 +813,8 @@ function buildCostRecord(data, cwd) {
809
813
  cache_write_tokens: cacheWrite,
810
814
  // The lifetime split of cache_write_tokens, present only when measured (M13).
811
815
  ...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
816
+ // Tokens of the requests whose prompt was over LONG_PROMPT_THRESHOLD, when any were.
817
+ ...(longPrompt ? { long_prompt: longPrompt } : {}),
812
818
  cost_usd: null,
813
819
  duration_ms: durationMs,
814
820
  phase,
@@ -938,6 +944,7 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
938
944
  totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
939
945
  totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
940
946
  addCacheTtlSplit(totals, usage);
947
+ addLongPrompt(totals, usage);
941
948
  }
942
949
  totals.lineCount = seen;
943
950
  return totals;
@@ -958,6 +965,29 @@ function addCacheTtlSplit(totals, usage) {
958
965
  totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
959
966
  }
960
967
 
968
+ // Haiku 5.5 bills a request whose prompt (input plus cache reads and writes) is over
969
+ // this many tokens at higher rates, request by request. A row sums many requests, so
970
+ // the tokens of those over it are kept apart as `long_prompt`, and the read side
971
+ // (lib/cost.cjs computeCost) prices them at the model's long rate. A copy of
972
+ // LONG_PROMPT_THRESHOLD in lib/cost.cjs: hooks require nothing from lib, and
973
+ // tests/cost-long-prompt.test.cjs pins the two equal.
974
+ const LONG_PROMPT_THRESHOLD = 100000;
975
+
976
+ /** Add one request's usage to `totals.long_prompt` when its prompt is over the threshold. */
977
+ function addLongPrompt(totals, usage) {
978
+ const inp = extractNumber(usage, 'input_tokens');
979
+ const cr = extractNumber(usage, 'cache_read_input_tokens');
980
+ const cw = extractNumber(usage, 'cache_creation_input_tokens');
981
+ if (inp + cr + cw <= LONG_PROMPT_THRESHOLD) return;
982
+ const lp = totals.long_prompt || (totals.long_prompt = { above: LONG_PROMPT_THRESHOLD, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cache_write_1h_tokens: 0 });
983
+ lp.input_tokens += inp;
984
+ lp.output_tokens += extractNumber(usage, 'output_tokens');
985
+ lp.cache_read_tokens += cr;
986
+ lp.cache_write_tokens += cw;
987
+ const cc = usage && usage.cache_creation;
988
+ if (cc && typeof cc === 'object') lp.cache_write_1h_tokens += extractNumber(cc, 'ephemeral_1h_input_tokens');
989
+ }
990
+
961
991
  /**
962
992
  * Append record to .planning/metrics/tokens.jsonl. Silently succeeds
963
993
  * even if the file or directory can't be written — hook must not block.
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // PAN guard — a pre-tool hook (program A7).
4
+ //
5
+ // Registered on the hosts with a pre-tool event that can deny a call: Claude Code
6
+ // and Codex (`PreToolUse`), Gemini CLI (`BeforeTool`) and Copilot CLI (`preToolUse`).
7
+ // OpenCode registers no hooks. The command carries `--host <runtime>`, because the
8
+ // payload and the way to deny differ per host.
9
+ //
10
+ // Rules, over the files a tool call is about to write:
11
+ // secret_write the written text holds a credential (known token shapes,
12
+ // or a long key=value secret)
13
+ // state_write a direct write to PAN's own state: state.md, findings.jsonl,
14
+ // the token ledger (pan-tools owns those)
15
+ // undeclared_test_edit a test file the phase in flight does not declare in any
16
+ // unfinished plan's files_modified
17
+ //
18
+ // Mode, from the planning tree's config.json `hooks.guard`:
19
+ // off nothing
20
+ // report (default) every match is appended to <planning>/guard.jsonl, the call proceeds
21
+ // enforce as report, and a secret_write is denied; the other rules still only report
22
+ // until the writers of PAN's state go through pan-tools verbs (A7b)
23
+ //
24
+ // Inert outside a PAN project. Fail-open on anything it cannot parse; never fail-open
25
+ // on a matched deny. Never writes outside the planning tree.
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const TOKEN_RE = /\b(?:sk-ant-[A-Za-z0-9_-]{10,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----)/;
31
+ const SECRET_KV_RE = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd)\b["']?\s*[:=]\s*["']([^"'\s]{12,})["']/i;
32
+ const TEST_PATH_RE = /(^|\/)(tests?|__tests__|specs?)\//i;
33
+ const TEST_FILE_RE = /(\.(test|spec)\.[a-z0-9]+|(^|\/)test_[^/]+\.py|_test\.(py|go)|Tests?\.(java|kt|cs))$/i;
34
+ const MODES = ['off', 'report', 'enforce'];
35
+
36
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
37
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
38
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
39
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
40
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
41
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
42
+ // aside whenever the project's Claude settings register the same script, so the hook
43
+ // runs once, and runs again from here the moment the Claude registration is gone.
44
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
45
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
46
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
47
+ // Copilot copy loads where repository hooks may not. Identical in every hook
48
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
49
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
50
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
51
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
52
+ const claudeDir = ['.', 'claude'].join('');
53
+ try {
54
+ const hooksDir = path.dirname(hookFile);
55
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
56
+ if (typeof projectDir !== 'string' || !projectDir) return false;
57
+ const script = path.basename(hookFile);
58
+ for (const name of ['settings.json', 'settings.local.json']) {
59
+ let settings;
60
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
61
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
62
+ if (!events) continue;
63
+ for (const groups of Object.values(events)) {
64
+ if (!Array.isArray(groups)) continue;
65
+ for (const group of groups) {
66
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
67
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
68
+ }
69
+ }
70
+ }
71
+ } catch { /* fail open: run this copy */ }
72
+ return false;
73
+ }
74
+
75
+
76
+ function planningDirName() {
77
+ const raw = process.env.PAN_PLANNING_DIR || '';
78
+ if (raw.trim()) {
79
+ const rel = raw.trim().replace(/\\/g, '/');
80
+ const bad = rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || rel.split('/').includes('..');
81
+ if (!bad) return rel;
82
+ }
83
+ const track = (process.env.PAN_TRACK || '').trim();
84
+ if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) return `.planning/tracks/${track}`;
85
+ return '.planning';
86
+ }
87
+
88
+ function readJson(p) {
89
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
90
+ }
91
+
92
+ /** A value that is a placeholder, not a credential. */
93
+ function isPlaceholder(v) {
94
+ return /^(?:\$\{|\$[A-Z_]|process\.env|os\.environ|<|xxx|your[_-]|changeme|example|dummy|placeholder|redacted|\*+$)/i.test(v);
95
+ }
96
+
97
+ function hasSecret(text) {
98
+ if (typeof text !== 'string' || !text) return false;
99
+ if (TOKEN_RE.test(text)) return true;
100
+ const m = text.match(SECRET_KV_RE);
101
+ return !!(m && !isPlaceholder(m[2]) && /\d/.test(m[2]) && /[A-Za-z]/.test(m[2]));
102
+ }
103
+
104
+ /**
105
+ * The tool call as `{tool, writes: [{path, text}], command}`, whatever the host.
106
+ * Claude/Codex/Gemini send `tool_name` + `tool_input`; Copilot sends `toolName` +
107
+ * `toolArgs` (a JSON string).
108
+ */
109
+ function normalise(payload) {
110
+ const p = payload && typeof payload === 'object' ? payload : {};
111
+ const tool = String(p.tool_name || p.toolName || p.tool || '');
112
+ let input = p.tool_input || p.toolArgs || p.input || {};
113
+ if (typeof input === 'string') { try { input = JSON.parse(input); } catch { input = { command: input }; } }
114
+ if (!input || typeof input !== 'object') input = {};
115
+ const writes = [];
116
+ const file = input.file_path || input.filePath || input.path || input.notebook_path || input.absolute_path;
117
+ const texts = [input.content, input.new_string, input.newString, input.new_source, input.file_text, input.text]
118
+ .concat(Array.isArray(input.edits) ? input.edits.map((e) => e && (e.new_string || e.newString)) : [])
119
+ .filter((t) => typeof t === 'string');
120
+ if (typeof file === 'string' && file && (texts.length || /write|edit|create|replace|notebook/i.test(tool))) {
121
+ writes.push({ path: file, text: texts.join('\n') });
122
+ }
123
+ const command = typeof input.command === 'string' ? input.command : (Array.isArray(input.command) ? input.command.join(' ') : '');
124
+ // Codex edits files through apply_patch: the patch names its files and carries the text.
125
+ if (/\*\*\* (?:Add|Update) File: /.test(command)) {
126
+ let cur = null;
127
+ for (const line of command.split(/\r?\n/)) {
128
+ const m = line.match(/^\*\*\* (?:Add|Update) File: (.+)$/);
129
+ if (m) { cur = { path: m[1].trim(), text: '' }; writes.push(cur); continue; }
130
+ if (cur && line.startsWith('+')) cur.text += line.slice(1) + '\n';
131
+ }
132
+ }
133
+ return { tool, writes, command };
134
+ }
135
+
136
+ /** Project-relative POSIX path, or null when the path is outside the project. */
137
+ function rel(projectDir, p) {
138
+ const abs = path.resolve(projectDir, p);
139
+ const r = path.relative(projectDir, abs).replace(/\\/g, '/');
140
+ return r.startsWith('..') || path.isAbsolute(r) ? null : r;
141
+ }
142
+
143
+ function isTestFile(r) {
144
+ return TEST_PATH_RE.test(r) || TEST_FILE_RE.test(r);
145
+ }
146
+
147
+ /** files_modified of the unfinished plans of the phase state.md names, or null when none is in flight. */
148
+ function declaredFiles(projectDir, planningRel) {
149
+ let state;
150
+ try { state = fs.readFileSync(path.join(projectDir, planningRel, 'state.md'), 'utf8'); } catch { return null; }
151
+ const m = state.match(/\*\*Current Phase:\*\*[ \t]*([0-9]+(?:\.[0-9]+)?)/i) || state.match(/^Phase:[ \t]*([0-9]+(?:\.[0-9]+)?)/m);
152
+ if (!m) return null;
153
+ const want = String(parseFloat(m[1]));
154
+ const phasesDir = path.join(projectDir, planningRel, 'phases');
155
+ let dir;
156
+ try { dir = fs.readdirSync(phasesDir).find((d) => { const n = d.match(/^(\d+(?:\.\d+)?)-/); return n && String(parseFloat(n[1])) === want; }); } catch { return null; }
157
+ if (!dir) return null;
158
+ const full = path.join(phasesDir, dir);
159
+ const names = fs.readdirSync(full);
160
+ const done = new Set(names.filter((n) => n.endsWith('-summary.md')).map((n) => n.replace(/-summary\.md$/, '')));
161
+ const out = new Set();
162
+ let plans = 0;
163
+ for (const n of names.filter((x) => x.endsWith('-plan.md'))) {
164
+ if (done.has(n.replace(/-plan\.md$/, ''))) continue;
165
+ plans++;
166
+ const text = fs.readFileSync(path.join(full, n), 'utf8').replace(/\r\n/g, '\n');
167
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
168
+ if (!fm) continue;
169
+ const inline = fm[1].match(/^files_modified:[ \t]*\[([^\]]*)\]/m);
170
+ if (inline) inline[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
171
+ const block = fm[1].match(/^files_modified:[ \t]*\n((?:[ \t]+-.*\n?)+)/m);
172
+ if (block) block[1].split('\n').map((l) => l.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
173
+ }
174
+ return plans ? out : null;
175
+ }
176
+
177
+ /** The rule matches for one normalised call. */
178
+ function evaluate(call, projectDir, planningRel) {
179
+ const hits = [];
180
+ const declared = call.writes.length ? declaredFiles(projectDir, planningRel) : null;
181
+ const ownState = new Set([`${planningRel}/state.md`, `${planningRel}/findings.jsonl`, `${planningRel}/metrics/tokens.jsonl`]);
182
+ for (const w of call.writes) {
183
+ const r = rel(projectDir, w.path);
184
+ if (hasSecret(w.text)) hits.push({ rule: 'secret_write', path: r || w.path });
185
+ if (!r) continue;
186
+ if (ownState.has(r)) hits.push({ rule: 'state_write', path: r });
187
+ if (declared && isTestFile(r) && !declared.has(r)) hits.push({ rule: 'undeclared_test_edit', path: r });
188
+ }
189
+ return hits;
190
+ }
191
+
192
+ function decide(hits, mode) {
193
+ if (mode !== 'enforce') return null;
194
+ return hits.find((h) => h.rule === 'secret_write') || null;
195
+ }
196
+
197
+ /** The host whose deny format this call needs: Copilot by payload shape, else the --host flag. */
198
+ function hostOf(payload, flagHost) {
199
+ const p = payload && typeof payload === 'object' ? payload : {};
200
+ if (typeof p.toolName === 'string' && p.tool_name === undefined) return 'copilot';
201
+ return flagHost;
202
+ }
203
+
204
+ function main() {
205
+ const flagHost = (process.argv[process.argv.indexOf('--host') + 1] || 'claude').toLowerCase();
206
+ let raw = '';
207
+ try { raw = fs.readFileSync(0, 'utf8'); } catch { return; }
208
+ let payload;
209
+ try { payload = JSON.parse(raw); } catch { return; } // fail open
210
+ const projectDir = (payload && (payload.cwd || payload.workspace || payload.project_dir)) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
211
+ if (deferToClaudeRegistration(projectDir)) return; // the Claude registration covers this call under Copilot
212
+ // The payload names the host better than the registration does: Copilot sends its own
213
+ // camelCase payload (toolName, toolArgs) even to a hook registered in .claude/settings.json,
214
+ // and denies only on the JSON decision, so a Copilot payload gets Copilot's deny whatever
215
+ // --host says. The flag decides for the snake_case hosts (Claude Code, Codex, Gemini).
216
+ const host = hostOf(payload, flagHost);
217
+ const planningRel = planningDirName();
218
+ const planning = path.join(projectDir, planningRel);
219
+ if (!fs.existsSync(planning)) return; // not a PAN project
220
+ const cfg = readJson(path.join(planning, 'config.json')) || {};
221
+ const mode = MODES.includes(cfg.hooks && cfg.hooks.guard) ? cfg.hooks.guard : 'report';
222
+ if (mode === 'off') return;
223
+ let hits = [];
224
+ try { hits = evaluate(normalise(payload), projectDir, planningRel); } catch { return; } // fail open
225
+ if (!hits.length) return;
226
+ const deny = decide(hits, mode);
227
+ try {
228
+ const ts = new Date().toISOString();
229
+ const lines = hits.map((h) => JSON.stringify({ ts, host, mode, rule: h.rule, path: h.path, tool: normalise(payload).tool, action: deny && h === deny ? 'denied' : 'reported' }));
230
+ fs.appendFileSync(path.join(planning, 'guard.jsonl'), lines.join('\n') + '\n');
231
+ } catch { /* the log is best effort; a deny below still stands */ }
232
+ if (!deny) return;
233
+ const reason = `PAN guard: this write puts a credential into ${deny.path}. Keep secrets out of the repository (an environment variable or a local, ignored file), then retry. (hooks.guard: enforce)`;
234
+ if (host === 'copilot') {
235
+ process.stdout.write(JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason }) + '\n');
236
+ return;
237
+ }
238
+ process.stderr.write(reason + '\n');
239
+ process.exitCode = 2;
240
+ }
241
+
242
+ if (require.main === module) main();
243
+
244
+ module.exports = { normalise, evaluate, decide, hasSecret, declaredFiles, isTestFile, hostOf, deferToClaudeRegistration };
@@ -13,7 +13,7 @@ const os = require('os');
13
13
  *
14
14
  * @param {Object} data - Parsed stdin JSON from Claude Code.
15
15
  * @param {Object} [deps] - Optional dep injection for testing.
16
- * {fs, path, homeDir, tmpDir} — defaults to real modules + OS paths.
16
+ * {fs, path, homeDir, tmpDir, configDir} — defaults to real modules + OS paths (configDir: CLAUDE_CONFIG_DIR, ignored when homeDir is given).
17
17
  * @returns {string} The statusline content.
18
18
  */
19
19
  function buildStatuslineOutput(data, deps) {
@@ -30,6 +30,54 @@ function buildStatuslineOutput(data, deps) {
30
30
  const session = data.session_id || '';
31
31
  const remaining = data.context_window?.remaining_percentage;
32
32
 
33
+ // The per-user 0700 bridge directory, or null when it is not safe to write into.
34
+ // Mirrors bridgeDir() in pan-context-monitor.js (the reader).
35
+ let bridgeSubdirMemo;
36
+ const secureBridgeDir = () => {
37
+ if (bridgeSubdirMemo !== undefined) return bridgeSubdirMemo;
38
+ bridgeSubdirMemo = null;
39
+ const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
40
+ const sub = pathMod.join(tmpDir, `pan-hooks-${uid}`);
41
+ // Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by someone
42
+ // else: mkdirSync{recursive} silently no-ops on an existing dir, so verify
43
+ // ownership+mode before writing (M60).
44
+ try {
45
+ fsMod.mkdirSync(sub, { recursive: true, mode: 0o700 });
46
+ const st = fsMod.lstatSync(sub);
47
+ // Symlink check is cross-platform; POSIX ownership/mode checks apply only
48
+ // where getuid exists (Windows fakes mode bits; gating on them there
49
+ // disabled the bridge entirely, N15).
50
+ let secure = !st.isSymbolicLink();
51
+ if (secure && typeof process.getuid === 'function') {
52
+ secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
53
+ }
54
+ if (secure) bridgeSubdirMemo = sub;
55
+ } catch { /* not safe */ }
56
+ return bridgeSubdirMemo;
57
+ };
58
+
59
+ // Usage limits (Claude Code subscriptions: `rate_limits.five_hour` / `seven_day`).
60
+ // Account-wide, so one file for every session; `pan-tools cost limits` and the
61
+ // focus-auto weekly stop read it. Written only when the host sent the block.
62
+ const limits = data.rate_limits && typeof data.rate_limits === 'object' ? data.rate_limits : null;
63
+ const limitWin = (w) => (w && Number.isFinite(Number(w.used_percentage))
64
+ ? { used_percentage: Number(w.used_percentage), ...(Number.isFinite(Number(w.resets_at)) ? { resets_at: Number(w.resets_at) } : {}) }
65
+ : null);
66
+ const fiveHour = limits ? limitWin(limits.five_hour) : null;
67
+ const sevenDay = limits ? limitWin(limits.seven_day) : null;
68
+ if ((fiveHour || sevenDay) && d.skipBridge !== true) {
69
+ try {
70
+ const sub = secureBridgeDir();
71
+ if (sub) {
72
+ fsMod.writeFileSync(pathMod.join(sub, 'claude-limits.json'), JSON.stringify({
73
+ ...(fiveHour ? { five_hour: fiveHour } : {}),
74
+ ...(sevenDay ? { seven_day: sevenDay } : {}),
75
+ timestamp: Math.floor(Date.now() / 1000),
76
+ }), { mode: 0o600 });
77
+ }
78
+ } catch { /* best-effort */ }
79
+ }
80
+
33
81
  // Context window bar — shows USED percentage scaled so 80% real = 100% shown.
34
82
  let ctx = '';
35
83
  if (remaining != null) {
@@ -39,34 +87,23 @@ function buildStatuslineOutput(data, deps) {
39
87
 
40
88
  if (session && d.skipBridge !== true) {
41
89
  try {
42
- // Write the bridge file into a per-user 0700 subdir so another user on
90
+ // The session bridge goes into the per-user 0700 subdir so another user on
43
91
  // a shared host can't symlink-attack the predictable session path.
44
- // Mirrors bridgeDir() in pan-context-monitor.js (the reader).
45
- const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
46
- const bridgeSubdir = pathMod.join(tmpDir, `pan-hooks-${uid}`);
47
- // Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by
48
- // someone else — mkdirSync{recursive} silently no-ops on an existing dir,
49
- // so verify ownership+mode before writing the session bridge (M60).
50
- let secure = false;
51
- try {
52
- fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 });
53
- const st = fsMod.lstatSync(bridgeSubdir);
54
- // Symlink check is cross-platform; POSIX ownership/mode checks apply
55
- // only where getuid exists (Windows fakes mode bits — gating on them
56
- // there disabled the bridge entirely, N15).
57
- secure = !st.isSymbolicLink();
58
- if (secure && typeof process.getuid === 'function') {
59
- secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
60
- }
61
- } catch { secure = false; }
62
- if (secure) {
92
+ const bridgeSubdir = secureBridgeDir();
93
+ if (bridgeSubdir) {
63
94
  const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
95
+ // Token counts and the model id let the context monitor measure against
96
+ // the window the host compacts at, not the model window (O7).
97
+ const cw = data.context_window || {};
64
98
  fsMod.writeFileSync(bridgePath, JSON.stringify({
65
99
  session_id: session,
66
100
  remaining_percentage: remaining,
67
101
  used_pct: used,
102
+ ...(Number.isFinite(cw.total_input_tokens) ? { total_input_tokens: cw.total_input_tokens } : {}),
103
+ ...(Number.isFinite(cw.context_window_size) ? { context_window_size: cw.context_window_size } : {}),
104
+ ...(data.model && data.model.id ? { model_id: data.model.id } : {}),
68
105
  timestamp: Math.floor(Date.now() / 1000),
69
- }));
106
+ }), { mode: 0o600 });
70
107
  }
71
108
  } catch { /* bridge is best-effort */ }
72
109
  }
@@ -118,7 +155,10 @@ function buildStatuslineOutput(data, deps) {
118
155
 
119
156
  // Current task from todos
120
157
  let task = '';
121
- const todosDir = pathMod.join(homeDir, '.claude', 'todos');
158
+ // Claude Code keeps its todos in CLAUDE_CONFIG_DIR when that is set (a given
159
+ // homeDir is a test seam and ignores it).
160
+ const configDir = d.configDir !== undefined ? d.configDir : (d.homeDir ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
161
+ const todosDir = configDir ? pathMod.join(configDir, 'todos') : pathMod.join(homeDir, '.claude', 'todos');
122
162
  let todosExists = false;
123
163
  try { todosExists = fsMod.statSync(todosDir).isDirectory(); } catch { /* missing */ }
124
164
  if (session && todosExists) {
@@ -154,7 +194,16 @@ function buildStatuslineOutput(data, deps) {
154
194
  const head = `${panUpdate}\x1b[2m${model}\x1b[0m`;
155
195
  const taskSegment = task ? ` │ \x1b[1m${task}\x1b[0m` : '';
156
196
  const dirSegment = ` │ \x1b[2m${dirname}\x1b[0m`;
157
- return `${head}${taskSegment}${dirSegment}${ctx}${cacheBadge}${thinkingBadge}`;
197
+ // Usage-limit badge: the window closer to its cap, green below 70%, yellow below
198
+ // 90%, red from 90% (focus-auto's default weekly stop).
199
+ let limitBadge = '';
200
+ const shown = [fiveHour && ['5h', fiveHour.used_percentage], sevenDay && ['7d', sevenDay.used_percentage]].filter(Boolean);
201
+ if (shown.length) {
202
+ const worst = Math.max(...shown.map(([, v]) => v));
203
+ const color = worst >= 90 ? '' : worst >= 70 ? '' : '';
204
+ limitBadge = ` ${color}${shown.map(([k, v]) => `${k} ${Math.round(v)}%`).join(' · ')}`;
205
+ }
206
+ return `${head}${taskSegment}${dirSegment}${ctx}${cacheBadge}${thinkingBadge}${limitBadge}`;
158
207
  }
159
208
 
160
209
  // ─── Stdin driver ───────────────────────────────────────────────────────────
@@ -73,6 +73,10 @@ const fs = require('fs');
73
73
  const os = require('os');
74
74
  const path = require('path');
75
75
 
76
+ // The CLI the block reason tells the agent to run. Installs and the plugin put the
77
+ // core beside hooks/; no `pan-tools` bin is on PATH.
78
+ const PAN_TOOLS = `node "${path.join(__dirname, '..', 'pan-wizard-core', 'bin', 'pan-tools.cjs').split(path.sep).join('/')}"`;
79
+
76
80
  // ─── R39: one run per hook when Claude and Copilot share a project ───────────
77
81
  // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
78
82
  // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
@@ -205,8 +209,9 @@ function buildStopDecision({ stopHookActive, config, stateContent, roadmapConten
205
209
  `Continue the chain now: if the current phase is finished, follow transition.md's ` +
206
210
  `offer_next_phase Route A and spawn Phase ${nextPhase} as a Task subagent ` +
207
211
  `(plan-phase orchestrator, ARGUMENTS='${nextPhase} --auto'); if the current phase is ` +
208
- `mid-flight, resume it instead. If the user explicitly asked to stop, first run: ` +
209
- `pan-tools config-set workflow.auto_advance false — then stop. ` +
212
+ `mid-flight, resume it instead. If the user explicitly asked to stop, first run ` +
213
+ `\`${PAN_TOOLS} config-set workflow.auto_advance false\` (under mode: yolo, which arms this ` +
214
+ `guard by itself, set \`workflow.stop_guard false\` the same way instead) — then stop. ` +
210
215
  `(Disable this guard permanently with workflow.stop_guard: false.)`
211
216
  };
212
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.33.0",
3
+ "version": "3.35.0",
4
4
  "description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -82,19 +82,56 @@ function removeAgentsMdSection(existing) {
82
82
  }
83
83
 
84
84
  /**
85
- * Ensure CLAUDE.md bridges to AGENTS.md via a marker-fenced @AGENTS.md import
86
- * (Claude Code's documented pattern for adopting the universal rules file).
87
- * Idempotent; preserves all user content.
85
+ * PAN's block in CLAUDE.md: the @AGENTS.md import, and Claude Code's "Compact
86
+ * instructions" section (memory optimisation O8). Claude Code reads a section of
87
+ * that name in the project-root CLAUDE.md when it summarises a conversation, and
88
+ * re-reads CLAUDE.md from disk after compaction. The section names what the summary
89
+ * must keep for PAN work to resume where it stopped.
90
+ *
91
+ * Codex is deliberately left out: its `compact_prompt` replaces the built-in
92
+ * compaction prompt rather than adding to it, and the default OpenAI provider
93
+ * ignores it (read 2026-10-04), so PAN would be swapping a tuned prompt for one
94
+ * that may never run. PAN's state re-injection after compaction covers Codex.
95
+ */
96
+ function buildClaudeMdBridge() {
97
+ return [
98
+ PAN_AGENTS_BEGIN,
99
+ '@AGENTS.md',
100
+ '',
101
+ '# Compact instructions',
102
+ '',
103
+ 'When this conversation is compacted, keep what PAN work needs to resume where it stopped:',
104
+ '- the current phase and plan, and the task in progress with its stopping point (`.planning/state.md` records them; keep that path);',
105
+ '- decisions made in this session that `.planning/state.md` does not yet hold;',
106
+ '- the files changed since the last commit.',
107
+ 'Leave out file contents and command output: they can be read again.',
108
+ PAN_AGENTS_END,
109
+ ].join('\n');
110
+ }
111
+
112
+ /**
113
+ * Ensure CLAUDE.md carries PAN's block: the marker-fenced @AGENTS.md import
114
+ * (Claude Code's documented pattern for adopting the universal rules file) and
115
+ * the compact instructions. Idempotent; preserves all user content. An older PAN
116
+ * bridge (the import alone) is replaced in place, so a reinstall or `memory
117
+ * rebuild` brings existing projects up to date.
88
118
  * @param {string|null} existing - Current CLAUDE.md content, or null if absent
89
119
  * @returns {string} New file content
90
120
  */
91
121
  function ensureClaudeMdImport(existing) {
92
- const block = `${PAN_AGENTS_BEGIN}\n@AGENTS.md\n${PAN_AGENTS_END}`;
122
+ const block = buildClaudeMdBridge();
93
123
  if (!existing || !existing.trim()) {
94
124
  return block + '\n';
95
125
  }
96
- if (existing.includes(PAN_AGENTS_BEGIN)) {
97
- return existing; // bridge (or another PAN block) already present
126
+ const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
127
+ const endIdx = existing.indexOf(PAN_AGENTS_END);
128
+ if (beginIdx !== -1) {
129
+ // PAN's own bridge: bring it up to date. Any other PAN block is left alone.
130
+ if (endIdx > beginIdx && /^@AGENTS\.md\s*$/m.test(existing.slice(beginIdx, endIdx))) {
131
+ const eol = existing.includes('\r\n') ? '\r\n' : '\n';
132
+ return existing.slice(0, beginIdx) + block.replace(/\n/g, eol) + existing.slice(endIdx + PAN_AGENTS_END.length);
133
+ }
134
+ return existing;
98
135
  }
99
136
  if (/^@AGENTS\.md\s*$/m.test(existing)) {
100
137
  return existing; // user already imports AGENTS.md themselves
@@ -118,6 +155,7 @@ module.exports = {
118
155
  buildAgentsMdSection,
119
156
  upsertAgentsMdSection,
120
157
  removeAgentsMdSection,
158
+ buildClaudeMdBridge,
121
159
  ensureClaudeMdImport,
122
160
  removeClaudeMdImport,
123
161
  };
@@ -23,8 +23,9 @@
23
23
  * ]
24
24
  * }
25
25
  *
26
- * Populating this file is the host runtime's responsibility (Claude Code's
27
- * MCP list API, etc.). This module reads the cache and reasons over it.
26
+ * Nothing populates this file on its own: no hook or host writes it.
27
+ * `bridge cache --servers <json>` (or an external script) seeds it; this module
28
+ * reads the cache and reasons over it.
28
29
  */
29
30
 
30
31
  const fs = require('fs');
@@ -184,7 +185,7 @@ function recommendForPhase(cwd, phaseNum, opts) {
184
185
  phase: String(phaseNum),
185
186
  runtime: cache.runtime,
186
187
  recommendations: [],
187
- reason: 'no MCP tools cached — run `pan-tools bridge cache` or ensure host runtime populates .planning/bridge/available-tools.json',
188
+ reason: 'no MCP tools cached — seed .planning/bridge/available-tools.json with `pan-tools bridge cache --servers <json>`; no hook or host writes it',
188
189
  };
189
190
  }
190
191
 
@@ -240,8 +241,8 @@ function cmdBridgeRecommend(cwd, phaseNum, opts, raw) {
240
241
  }
241
242
 
242
243
  function cmdBridgeCache(cwd, serversJson, runtime, raw) {
243
- // For scripted cache writes. Normally the host runtime writes the file,
244
- // but this CLI path lets users seed it for testing or from external scripts.
244
+ // The cache's only writer: no hook or host runtime populates the file, so this
245
+ // CLI path (or an external script) is how users seed it.
245
246
  if (!serversJson) {
246
247
  // No payload — just echo the current cache path/state.
247
248
  output(listTools(cwd), raw);
@@ -66,12 +66,14 @@ function writeScheduleFile(cwd, schedule) {
66
66
  * @returns {object|{error}} the written descriptor
67
67
  */
68
68
  function writeSchedule(cwd, opts, now) {
69
- const cadence = opts.cadence || 'daily';
69
+ // An update keeps the stored cadence like every other field: `--pause` on an
70
+ // hourly campaign used to set it back to daily.
71
+ const existing = readSchedule(cwd) || {};
72
+ const cadence = opts.cadence || existing.cadence || 'daily';
70
73
  if (parseCadence(cadence) === null) {
71
74
  return { error: `Invalid cadence "${cadence}". Use hourly | daily | weekly | Nh | Nd.` };
72
75
  }
73
76
  const at = now || new Date();
74
- const existing = readSchedule(cwd) || {};
75
77
  const schedule = {
76
78
  goal: opts.goal ?? existing.goal ?? null,
77
79
  source: opts.source ?? existing.source ?? 'backlog',
@@ -299,6 +299,23 @@ function formatLearningEntry(learning) {
299
299
  return lines.join('\n');
300
300
  }
301
301
 
302
+ /**
303
+ * A summary's `key-files` as one list. The summary templates write `{created, modified}`;
304
+ * older summaries carry a flat list. Only the flat list was read, so summaries PAN writes
305
+ * gave no co-change learnings, and one beside a flat-list summary crashed on `.includes`.
306
+ * @param {Object} fm - Summary frontmatter
307
+ * @returns {string[]}
308
+ */
309
+ function keyFileList(fm) {
310
+ const kf = fm['key-files'] || fm.key_files;
311
+ if (Array.isArray(kf)) return kf.map(String);
312
+ if (kf && typeof kf === 'object') {
313
+ const list = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
314
+ return [...new Set([...list(kf.created), ...list(kf.modified)].map(String))];
315
+ }
316
+ return [];
317
+ }
318
+
302
319
  /**
303
320
  * Extract learnings from session summaries and error patterns.
304
321
  * Reads session history + error patterns, extracts file co-change patterns
@@ -347,7 +364,7 @@ function cmdLearningsExtract(cwd, raw) {
347
364
  const fileCoChanges = new Map(); // file -> Set of co-changed files
348
365
 
349
366
  for (const { frontmatter } of summaries) {
350
- const keyFiles = Array.isArray(frontmatter['key-files']) ? frontmatter['key-files'] : [];
367
+ const keyFiles = keyFileList(frontmatter);
351
368
  if (keyFiles.length < 2) continue;
352
369
  for (const file of keyFiles) {
353
370
  if (!fileCoChanges.has(file)) fileCoChanges.set(file, new Set());
@@ -368,7 +385,7 @@ function cmdLearningsExtract(cwd, raw) {
368
385
  // Count co-occurrences
369
386
  let count = 0;
370
387
  for (const { frontmatter } of summaries) {
371
- const kf = frontmatter['key-files'] || [];
388
+ const kf = keyFileList(frontmatter);
372
389
  if (kf.includes(file) && kf.includes(coFile)) count++;
373
390
  }
374
391
  if (count < 2) continue;