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
@@ -55,6 +55,12 @@ const TOKENS_FILE = 'tokens.jsonl';
55
55
  * write at `cache_write` (market-ideas queue M13). OpenAI and Google report no
56
56
  * lifetime split, so their rows carry none.
57
57
  */
58
+ // The prompt size above which a long-prompt rate applies (Haiku 5.5's tier). The
59
+ // cost-logger hook and `cost rebuild` record the tokens of every request over it as a
60
+ // row's `long_prompt`, with this figure as its `above`; hooks/pan-cost-logger.js keeps
61
+ // its own copy (hooks require nothing from lib), and a test pins the two equal.
62
+ const LONG_PROMPT_THRESHOLD = 100000;
63
+
58
64
  const DEFAULT_RATES = {
59
65
  // Anthropic — platform.claude.com/docs/en/about-claude/pricing, every row read
60
66
  // 2026-09-23. Opus 4.6+/Opus 5 are $5/$25 (the old $15/$75 Opus pricing ended with
@@ -91,16 +97,24 @@ const DEFAULT_RATES = {
91
97
  // 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled.
92
98
  // Lesson: never write down a pre-announced price — this row carried the future
93
99
  // rate for a month and over-billed by half.
94
- // Sonnet 5.5 (claude-sonnet-5-5): the same prices as Sonnet 5 — $2/$10, 5-minute
95
- // writes $2.50, 1-hour $4, hits $0.20 (pricing page, read 2026-10-03). Claude Code
96
- // 2.1.284 points the `sonnet` alias at it on the Anthropic API (model-config, read
97
- // 2026-10-03). The family prefix already priced it at Sonnet 5's identical row; its
98
- // own row means a later price change to either model cannot misprice the other.
99
- 'claude-sonnet-5-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
100
+ // Sonnet 5.5 (claude-sonnet-5-5): $2/$10, 5-minute writes $2.50, 1-hour $4, and
101
+ // hits at 0.05x input, $0.10 (pricing page, read 2026-10-10; Claude Code 2.1.296
102
+ // corrected its own figure from $0.20 the same week). Claude Code 2.1.284 points the
103
+ // `sonnet` alias at it on the Anthropic API (model-config, read 2026-10-03). Its own
104
+ // row is what keeps it off Sonnet 5's $0.20 hits through the family prefix.
105
+ 'claude-sonnet-5-5': { input: 2.0, output: 10.0, cache_read: 0.10, cache_write: 2.50, cache_write_1h: 4.0 },
100
106
  'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
101
107
  'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
102
108
  // Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
103
109
  'claude-sonnet-4-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
110
+ // Haiku 5.5 (claude-haiku-5-5): $0.10/$0.50, 5-minute writes $0.125, 1-hour $0.20,
111
+ // hits $0.01 — and every rate 5x for a request whose prompt is over 100,000 tokens,
112
+ // counting cache reads and writes (pricing page "Long context pricing", read
113
+ // 2026-10-10). Claude Code 2.1.293 makes it the `haiku` alias on the Anthropic API,
114
+ // which is PAN's fast tier there. `long` prices the part of a row that came from
115
+ // such requests (`long_prompt`, see computeCost).
116
+ 'claude-haiku-5-5': { input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125, cache_write_1h: 0.20,
117
+ long: { above: LONG_PROMPT_THRESHOLD, input: 0.50, output: 2.50, cache_read: 0.05, cache_write: 0.625, cache_write_1h: 1.0 } },
104
118
  // Haiku 4.5: API id claude-haiku-4-5-20251001, alias claude-haiku-4-5.
105
119
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25, cache_write_1h: 2.0 },
106
120
 
@@ -242,6 +256,7 @@ const scaleRate = (rate, m) => ({
242
256
  cache_read: round6(rate.cache_read * m),
243
257
  cache_write: round6(rate.cache_write * m),
244
258
  ...(typeof rate.cache_write_1h === 'number' ? { cache_write_1h: round6(rate.cache_write_1h * m) } : {}),
259
+ ...(rate.long && typeof rate.long === 'object' ? { long: { above: rate.long.above, ...scaleRate(rate.long, m) } } : {}),
245
260
  });
246
261
 
247
262
  /**
@@ -388,12 +403,35 @@ function computeCost(rec, configRates) {
388
403
  // bills at the one-hour rate; the rest — and every write on a row or rate without
389
404
  // the split — at `cache_write`.
390
405
  const write1h = Math.min(cacheWrite, Math.max(0, Number(rec.cache_write_1h_tokens) || 0));
391
- const rate1h = typeof rate.cache_write_1h === 'number' ? rate.cache_write_1h : rate.cache_write;
392
- const usd = (input * rate.input + output * rate.output + cacheRead * rate.cache_read
393
- + (cacheWrite - write1h) * rate.cache_write + write1h * rate1h) / 1_000_000;
406
+ const tokens = { input, output, cacheRead, cacheWrite, write1h };
407
+ // A long-prompt tier (Haiku 5.5): the row's `long_prompt` holds the tokens of the
408
+ // requests whose prompt was over the threshold, and those bill at the long rates.
409
+ // Measured at the same threshold only — a row split at another figure cannot be
410
+ // re-cut here, so it prices at the base rates as before.
411
+ const lp = rec.long_prompt;
412
+ let usd = 0;
413
+ if (rate.long && lp && typeof lp === 'object' && Number(lp.above) === Number(rate.long.above)) {
414
+ const part = {
415
+ input: Math.min(input, Number(lp.input_tokens) || 0),
416
+ output: Math.min(output, Number(lp.output_tokens) || 0),
417
+ cacheRead: Math.min(cacheRead, Number(lp.cache_read_tokens) || 0),
418
+ cacheWrite: Math.min(cacheWrite, Number(lp.cache_write_tokens) || 0),
419
+ };
420
+ part.write1h = Math.min(part.cacheWrite, write1h, Number(lp.cache_write_1h_tokens) || 0);
421
+ usd += priceTokens(part, rate.long);
422
+ for (const k of Object.keys(part)) tokens[k] -= part[k];
423
+ }
424
+ usd += priceTokens(tokens, rate);
394
425
  return Math.round(usd * 10000) / 10000;
395
426
  }
396
427
 
428
+ /** USD for disjoint token counts at one rate row (1-hour writes at their own rate). */
429
+ function priceTokens(t, rate) {
430
+ const rate1h = typeof rate.cache_write_1h === 'number' ? rate.cache_write_1h : rate.cache_write;
431
+ return (t.input * rate.input + t.output * rate.output + t.cacheRead * rate.cache_read
432
+ + (t.cacheWrite - t.write1h) * rate.cache_write + t.write1h * rate1h) / 1_000_000;
433
+ }
434
+
397
435
  /**
398
436
  * Append a cost record. Non-blocking — errors are swallowed so instrumentation
399
437
  * never breaks the caller.
@@ -532,7 +570,11 @@ function aggregate(cwd, opts) {
532
570
  const records = readRecords(cwd);
533
571
  const malformedSkipped = _lastReadMalformed; // captured before any later read
534
572
  const since = opts?.since ? new Date(opts.since).getTime() : null;
535
- const until = opts?.until ? new Date(opts.until).getTime() : null;
573
+ // A bare date is that day's first instant, so as an upper bound it left out the
574
+ // whole day the docs call inclusive: read it as the day's last millisecond.
575
+ const until = opts?.until
576
+ ? new Date(/^\d{4}-\d{2}-\d{2}$/.test(String(opts.until)) ? `${opts.until}T23:59:59.999Z` : opts.until).getTime()
577
+ : null;
536
578
  const config = loadConfig(cwd);
537
579
  const configRates = effectiveRates(config);
538
580
 
@@ -699,10 +741,12 @@ function renderChart(agg) {
699
741
  function cmdCostReport(cwd, opts, raw) {
700
742
  const format = opts?.format || 'json';
701
743
  const agg = aggregate(cwd, opts);
744
+ // `--format table|chart` asks for text, so it prints text with or without
745
+ // `--raw`; without `--raw` it used to print the JSON report unchanged.
702
746
  if (format === 'table') {
703
- output(agg, raw, renderTable(agg));
747
+ output(agg, true, renderTable(agg));
704
748
  } else if (format === 'chart') {
705
- output(agg, raw, renderChart(agg));
749
+ output(agg, true, renderChart(agg));
706
750
  } else {
707
751
  output(agg, raw);
708
752
  }
@@ -722,6 +766,86 @@ function cmdCostClear(cwd, raw) {
722
766
  }
723
767
  }
724
768
 
769
+ // ─── Host usage limits (Claude Code subscriptions) ───────────────────────────
770
+ //
771
+ // Claude Code's status-line payload carries `rate_limits.five_hour` and
772
+ // `rate_limits.seven_day` (`used_percentage`, `resets_at` in epoch seconds;
773
+ // code.claude.com/docs/en/statusline, read 2026-10-10). On a subscription those
774
+ // windows, not dollars, are what a long campaign runs out of. PAN's status line
775
+ // writes them, account-wide, to claude-limits.json in the per-user pan-hooks
776
+ // directory; campaigns read them here. Headless `claude -p` runs no status line, so
777
+ // there the file goes stale and nothing stops on it.
778
+
779
+ const HOST_LIMITS_FILE = 'claude-limits.json';
780
+ const HOST_LIMITS_FRESH_SECONDS = 15 * 60;
781
+ const DEFAULT_WEEKLY_LIMIT_STOP_PCT = 90;
782
+
783
+ /** The per-user pan-hooks directory when it is safe to read (ours, not a symlink, owner-only), else null. */
784
+ function hooksDirIfSafe(tmpDir) {
785
+ const uid = typeof process.getuid === 'function' ? process.getuid() : (process.env.USERNAME || 'win');
786
+ const dir = path.join(tmpDir || require('os').tmpdir(), `pan-hooks-${uid}`);
787
+ try {
788
+ const st = fs.lstatSync(dir);
789
+ if (st.isSymbolicLink() || !st.isDirectory()) return null;
790
+ if (typeof process.getuid === 'function' && (st.uid !== process.getuid() || (st.mode & 0o077) !== 0)) return null;
791
+ return dir;
792
+ } catch { return null; }
793
+ }
794
+
795
+ /**
796
+ * The host's usage-limit windows as the status line last saw them, or null when no
797
+ * status line has written them. A window whose `resets_at` has passed reads as reset
798
+ * (used 0); the whole reading is `stale` past HOST_LIMITS_FRESH_SECONDS.
799
+ * @param {{tmpDir?: string, now?: number}} [opts] - now in epoch seconds
800
+ */
801
+ function readHostLimits(opts = {}) {
802
+ const dir = hooksDirIfSafe(opts.tmpDir);
803
+ if (!dir) return null;
804
+ let raw;
805
+ try { raw = JSON.parse(fs.readFileSync(path.join(dir, HOST_LIMITS_FILE), 'utf8')); } catch { return null; }
806
+ if (!raw || typeof raw !== 'object') return null;
807
+ const now = Number.isFinite(opts.now) ? opts.now : Math.floor(Date.now() / 1000);
808
+ const win = (w) => {
809
+ if (!w || typeof w !== 'object' || !Number.isFinite(Number(w.used_percentage))) return null;
810
+ const resetsAt = Number.isFinite(Number(w.resets_at)) ? Number(w.resets_at) : null;
811
+ const reset = resetsAt !== null && resetsAt <= now;
812
+ return { used_pct: reset ? 0 : Math.max(0, Math.min(100, Number(w.used_percentage))), resets_at: resetsAt, reset_since_read: reset };
813
+ };
814
+ const age = Number.isFinite(Number(raw.timestamp)) ? now - Number(raw.timestamp) : null;
815
+ return {
816
+ five_hour: win(raw.five_hour),
817
+ seven_day: win(raw.seven_day),
818
+ age_seconds: age,
819
+ stale: age === null || age > HOST_LIMITS_FRESH_SECONDS,
820
+ };
821
+ }
822
+
823
+ /**
824
+ * Whether a campaign should stop on the 7-day window: a fresh reading at or past
825
+ * `cost.weekly_limit_stop_pct` (default 90; 0 or false turns it off).
826
+ */
827
+ function weeklyLimitStop(cwd, limits = readHostLimits()) {
828
+ let pct = DEFAULT_WEEKLY_LIMIT_STOP_PCT;
829
+ try {
830
+ const v = loadConfig(cwd)?.cost?.weekly_limit_stop_pct;
831
+ if (v === false || v === 0) pct = 0;
832
+ else if (Number.isFinite(Number(v)) && Number(v) > 0 && Number(v) <= 100) pct = Number(v);
833
+ } catch { /* defaults */ }
834
+ const used = limits && !limits.stale && limits.seven_day ? limits.seven_day.used_pct : null;
835
+ return { stop: pct > 0 && used !== null && used >= pct, threshold_pct: pct, seven_day_used_pct: used };
836
+ }
837
+
838
+ function cmdCostLimits(cwd, raw) {
839
+ const limits = readHostLimits();
840
+ const stop = weeklyLimitStop(cwd, limits);
841
+ output({
842
+ available: !!limits,
843
+ ...(limits || { five_hour: null, seven_day: null, age_seconds: null, stale: true }),
844
+ weekly_limit_stop_pct: stop.threshold_pct,
845
+ weekly_limit_reached: stop.stop,
846
+ }, raw, stop.stop ? 'weekly_limit_reached' : (limits ? 'ok' : 'no_reading'));
847
+ }
848
+
725
849
  // ─── Rate-table staleness ───────────────────────────────────────────────────
726
850
 
727
851
  // Date DEFAULT_RATES was last verified against published provider pricing — ALL
@@ -738,7 +862,7 @@ function cmdCostClear(cwd, raw) {
738
862
  // a default-model change inside the window — that is the job of the
739
863
  // documented-default-ids fixture test (tests/fixtures/documented-default-models.json,
740
864
  // R31), which fails the suite the day a documented default id has no exact row.
741
- const RATES_VERIFIED_AT = '2026-09-23';
865
+ const RATES_VERIFIED_AT = '2026-10-10';
742
866
  const RATES_STALE_AFTER_DAYS = 60;
743
867
  const RATE_TIERS = ['reasoning', 'mid', 'fast'];
744
868
 
@@ -784,6 +908,11 @@ module.exports = {
784
908
  renderTable,
785
909
  renderChart,
786
910
  resolveRate,
911
+ LONG_PROMPT_THRESHOLD,
912
+ readHostLimits,
913
+ weeklyLimitStop,
914
+ cmdCostLimits,
915
+ HOST_LIMITS_FILE,
787
916
  ratesFromModelPricing,
788
917
  managedSettingsDir,
789
918
  loadManagedModelPricing,
@@ -21,8 +21,10 @@ const SCANNABLE_EXTS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py'];
21
21
 
22
22
  // ─── Pass 1: Deterministic static analysis ───────────────────────────────────
23
23
 
24
- // Calls that genuinely CANNOT throw when given any single argument, so a
25
- // try/catch wrapping only such a call is truly phantom (dead catch clause).
24
+ // Calls the matcher treats as non-throwing: none of them throws on a string or a
25
+ // number, so a try/catch wrapping only such a call is usually phantom. Number, String,
26
+ // parseInt and parseFloat do throw on an object whose toString throws (all but String
27
+ // on a Symbol too), which is why findPhantomTryCatch tiers it review_required.
26
28
  // Deliberately EXCLUDES throwing calls — JSON.parse (SyntaxError on bad input),
27
29
  // JSON.stringify (TypeError on circular refs / BigInt), new RegExp, fs.*Sync,
28
30
  // etc. — because their try/catch is load-bearing and must not be flagged for
@@ -431,7 +433,7 @@ function writePatternsMemory(cwd, findings, opts) {
431
433
  const lines = [
432
434
  '---',
433
435
  'name: distill-patterns',
434
- 'description: Cross-session memory of AI-bloat patterns detected and resolved',
436
+ 'description: Cross-session memory of AI-bloat patterns detected (it records detections, not fixes)',
435
437
  'type: project',
436
438
  '---',
437
439
  '',
@@ -520,6 +522,7 @@ module.exports = {
520
522
  findUnreferencedExports,
521
523
  SAFETY_TIERS,
522
524
  DEFAULT_BLOAT_THRESHOLD,
525
+ PATTERNS_FILE,
523
526
  MAX_FUNCTION_LOC,
524
527
  MAX_PARAM_COUNT,
525
528
  MAX_NESTING_DEPTH,
@@ -44,11 +44,21 @@ const DEFAULT_SCHEMA_PATH = path.join(
44
44
  * calls it must pass its verdict as output()'s 4th argument; a bare
45
45
  * process.exit() after an output() call is unreachable.
46
46
  */
47
+ /**
48
+ * A missing path or a file where a directory is expected ends in error(), not in the
49
+ * stack trace walkMarkdownFiles' throw used to print (`doc-lint counts README.md`).
50
+ * @param {string} targetDir
51
+ */
52
+ function requireDirectory(targetDir) {
53
+ let stat = null;
54
+ try { stat = fs.statSync(targetDir); } catch { /* absent */ }
55
+ if (!stat) error(`directory not found: ${targetDir}`);
56
+ if (!stat.isDirectory()) error(`not a directory: ${targetDir} — doc-lint scans a directory of markdown files`);
57
+ }
58
+
47
59
  function cmdDocLint(cwd, dir, opts = {}) {
48
60
  const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
49
- if (!fs.existsSync(targetDir)) {
50
- error(`directory not found: ${targetDir}`);
51
- }
61
+ requireDirectory(targetDir);
52
62
 
53
63
  const schemaPath = opts.schema
54
64
  ? (path.isAbsolute(opts.schema) ? opts.schema : path.join(cwd, opts.schema))
@@ -209,7 +219,6 @@ function isCountAllowed(relativePath) {
209
219
  // Things that LOOK like counts but are stable identities (allowed everywhere):
210
220
  const STABLE_IDENTITIES = [
211
221
  /\b5\s+(target\s+)?runtimes\b/i, // 5 target runtimes
212
- /\b5\s+hooks\b/i, // 5 hooks (named individually)
213
222
  /\bLAYER\s+\d+\b/, // architecture layer labels
214
223
  /\b5\s+(parallel\s+)?(researchers?|research\s+)/i, // 5 parallel researchers
215
224
  /\b6\s+(parallel\s+)?agents\b/i, // 6 parallel agents (codebase mapper)
@@ -218,9 +227,17 @@ const STABLE_IDENTITIES = [
218
227
  /\bthree\s+phases\b|\bfour\s+phases\b/i, // generic phase counts in narrative
219
228
  ];
220
229
 
221
- function isStableIdentity(matchText, surrounding) {
230
+ function isStableIdentity(matchText, surrounding, matchIndex = 0) {
222
231
  for (const re of STABLE_IDENTITIES) {
223
- if (re.test(matchText) || re.test(surrounding)) return true;
232
+ if (re.test(matchText)) return true;
233
+ // Only an identity that overlaps the count excuses it ("LAYER 6 modules"). One
234
+ // elsewhere on the line excused every count there: "52 commands on all 5 runtimes".
235
+ const all = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
236
+ let s;
237
+ while ((s = all.exec(surrounding)) !== null) {
238
+ if (s.index < matchIndex + matchText.length && matchIndex < s.index + s[0].length) return true;
239
+ if (s[0].length === 0) all.lastIndex++;
240
+ }
224
241
  }
225
242
  return false;
226
243
  }
@@ -233,9 +250,7 @@ function isStableIdentity(matchText, surrounding) {
233
250
  */
234
251
  function cmdDocLintCounts(cwd, dir, opts = {}) {
235
252
  const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
236
- if (!fs.existsSync(targetDir)) {
237
- error(`directory not found: ${targetDir}`);
238
- }
253
+ requireDirectory(targetDir);
239
254
 
240
255
  const exclude = opts.exclude || [];
241
256
  const files = walkMarkdownFiles(targetDir, { exclude });
@@ -260,8 +275,8 @@ function cmdDocLintCounts(cwd, dir, opts = {}) {
260
275
  re.lastIndex = 0; // reset for /g
261
276
  let m;
262
277
  while ((m = re.exec(line)) !== null) {
263
- // Skip if this whole line is in a stable-identity surrounding
264
- if (isStableIdentity(m[0], line)) continue;
278
+ // Skip a count that is part of a stable identity
279
+ if (isStableIdentity(m[0], line, m.index)) continue;
265
280
  violations.push({
266
281
  file: file.relativePath,
267
282
  line: i + 1,
@@ -363,6 +378,10 @@ function scanDocFlags(cwd, opts = {}) {
363
378
  }
364
379
 
365
380
  function cmdDocLintFlags(cwd, opts = {}, raw) {
381
+ for (const d of opts.docDirs || []) {
382
+ const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
383
+ if (fs.existsSync(abs)) requireDirectory(abs);
384
+ }
366
385
  const r = scanDocFlags(cwd, opts);
367
386
  if (raw) {
368
387
  if (r.violation_count === 0) {
@@ -16,11 +16,14 @@
16
16
  * again (a regression);
17
17
  * - with no disposition, a finding is `open`.
18
18
  *
19
- * Two rules are applied when a verdict is recorded:
20
- * - AUTO-FIX: the same agent's earlier open findings on the phase that the new
21
- * verdict does not report become `fixed`. That is how a gap-closure round
22
- * closes its gaps. `human` and `unrequested` are exempt, because a re-run does
23
- * not re-check them.
19
+ * Rules applied when a verdict is recorded:
20
+ * - EVIDENCE CLOSES, SILENCE DOES NOT (V6): an earlier open finding closes only when
21
+ * the judge answers it `resolved` with evidence (a `resolutions` entry, or the
22
+ * verifier's `re_verification.gaps_closed`), or, for a `code-fact:<fact>` finding,
23
+ * when that fact passes. One the new verdict neither reports nor answers stays
24
+ * open and is listed as `unaddressed`. `still_present` counts as a re-report.
25
+ * - IDENTITY: a reworded report of an earlier finding (same class family, same
26
+ * file, same truth or a similar summary) keeps that finding's id.
24
27
  * - IDEMPOTENCY: recording the same artifact twice is a no-op that returns the
25
28
  * first verdict (record_sig). That is why exec-phase and verify-phase may both
26
29
  * record one verification.md.
@@ -122,6 +125,69 @@ function samePhase(a, b) {
122
125
  return V.comparablePhase(a) === V.comparablePhase(b);
123
126
  }
124
127
 
128
+ // ─── Finding identity across reports (V6) ───────────────────────────────────
129
+
130
+ /** The file a `where` names, without a line or column suffix. */
131
+ function fileOf(where) {
132
+ return where ? String(where).replace(/\\/g, '/').replace(/(:\d+){1,2}$/, '').replace(/#L?\d+.*$/, '') : '';
133
+ }
134
+
135
+ function tokens(s) {
136
+ return new Set(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter((w) => w.length > 2));
137
+ }
138
+
139
+ /** Token overlap (Jaccard) of two summaries at or above one half. */
140
+ function similar(a, b) {
141
+ const A = tokens(a);
142
+ const B = tokens(b);
143
+ if (!A.size || !B.size) return false;
144
+ let both = 0;
145
+ for (const t of A) if (B.has(t)) both++;
146
+ return both / (A.size + B.size - both) >= 0.5;
147
+ }
148
+
149
+ /** The part of a summary before " — " (a verifier gap's truth), lower-cased. */
150
+ const headOf = (s) => String(s || '').split(' — ')[0].trim().toLowerCase();
151
+ /** A verifier gap moves between missing and partial as it is worked on: one family. */
152
+ const familyOf = (c) => (c === 'partial' ? 'missing' : c);
153
+
154
+ /** Same class family, same file, and the same truth or a similar summary. */
155
+ function sameFinding(e, f) {
156
+ return familyOf(e.class) === familyOf(f.class) && fileOf(e.where) === fileOf(f.where)
157
+ && (headOf(e.summary) === headOf(f.summary) || similar(e.summary, f.summary));
158
+ }
159
+
160
+ /** The earlier finding a judge's resolution answers: by id, else by its truth or summary. */
161
+ function resolutionTarget(mine, r) {
162
+ if (r.id) return mine.find((f) => f.id === r.id) || null;
163
+ const head = headOf(r.summary);
164
+ return mine.find((f) => headOf(f.summary) === head) || mine.find((f) => similar(f.summary, r.summary)) || null;
165
+ }
166
+
167
+ function truncateReason(s) {
168
+ return String(s).replace(/\s+/g, ' ').trim().slice(0, REASON_MAX);
169
+ }
170
+
171
+ /**
172
+ * How stable a judge's findings are across its reports on one phase (V6): between
173
+ * consecutive verdicts, how many ids appeared and how many disappeared. A judge whose
174
+ * findings churn while the code does not change is not measuring the code.
175
+ */
176
+ function findingsChurn(rows, phase, agent) {
177
+ const seq = foldFindings(rows).verdicts.filter((v) => v.agent === agent && samePhase(v.phase, phase));
178
+ let added = 0;
179
+ let dropped = 0;
180
+ let union = 0;
181
+ for (let i = 1; i < seq.length; i++) {
182
+ const a = new Set(seq[i - 1].finding_ids || []);
183
+ const b = new Set(seq[i].finding_ids || []);
184
+ for (const id of b) if (!a.has(id)) added++;
185
+ for (const id of a) if (!b.has(id)) dropped++;
186
+ union += new Set([...a, ...b]).size;
187
+ }
188
+ return { reports: seq.length, transitions: Math.max(0, seq.length - 1), added, dropped, rate: union ? Math.round(((added + dropped) / union) * 1000) / 1000 : 0 };
189
+ }
190
+
125
191
  /** `.planning/` must exist: the ledger fills a planning tree, it never creates one. */
126
192
  function requirePlanningTree(cwd) {
127
193
  try { return fs.statSync(planningPath(cwd)).isDirectory(); } catch { return false; }
@@ -163,11 +229,15 @@ function recordVerdict(cwd, input) {
163
229
  // `not_checked` (M23): what the judge could not run. A pass with entries here is a
164
230
  // pass over less than the whole phase, and the record says so.
165
231
  const notChecked = verdict.not_checked || [];
166
- const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, not_checked: notChecked, source_kind: parsed.source_kind, warnings: parsed.warnings };
232
+ // Two-key verdict (V9): when a code fact lowered the judge's word, the record keeps
233
+ // both, and `verdict` is the lowered word the workflow branches on.
234
+ const lowered = Array.isArray(parsed.lowered_by) && parsed.lowered_by.length
235
+ ? { agent_verdict: verdict.agent_verdict, lowered_by: parsed.lowered_by } : {};
236
+ const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, ...lowered, not_checked: notChecked, source_kind: parsed.source_kind, warnings: parsed.warnings };
167
237
 
168
238
  const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
169
239
  if (dup) {
170
- return { ...base, recorded: false, duplicate: true, verdict_id: dup.verdict_id, attempt: dup.attempt, phase, findings: (dup.finding_ids || []).length, new: 0, auto_fixed: 0, reopened: 0 };
240
+ return { ...base, recorded: false, duplicate: true, verdict_id: dup.verdict_id, attempt: dup.attempt, phase, findings: (dup.finding_ids || []).length, new: 0, rematched: 0, resolved: 0, unaddressed: [], reopened: 0 };
171
241
  }
172
242
 
173
243
  const prior = fold.verdicts.filter((r) => r.agent === agent && samePhase(r.phase, phase));
@@ -180,21 +250,52 @@ function recordVerdict(cwd, input) {
180
250
  const ids = [];
181
251
  const newRows = [];
182
252
  const findingRows = [];
253
+ const mine = [...fold.findings.values()].filter((e) => e.agent === agent && samePhase(e.phase, phase));
254
+ let rematched = 0;
183
255
  for (const f of verdict.findings) {
184
- const id = V.findingId(phase, agent, f);
256
+ let id = V.findingId(phase, agent, f);
257
+ if (!fold.findings.has(id)) {
258
+ // A reworded report of an earlier finding keeps that finding's id (V6), so a
259
+ // rewording is a re-report, not a fix plus a new finding.
260
+ const earlier = mine.filter((e) => !ids.includes(e.id) && sameFinding(e, f)).sort((a, b) => (a.status === 'open' ? 0 : 1) - (b.status === 'open' ? 0 : 1))[0];
261
+ if (earlier) { id = earlier.id; rematched++; }
262
+ }
185
263
  if (ids.includes(id)) continue; // the same finding twice in one report is one finding
186
264
  ids.push(id);
187
265
  if (!fold.findings.has(id)) {
188
266
  findingRows.push({ v: LEDGER_V, kind: 'finding', ts, id, verdict_id: verdictId, phase, milestone, agent, class: f.class, severity: f.severity, where: f.where, summary: f.summary });
189
267
  }
190
268
  }
191
- const reopened = ids.filter((id) => fold.findings.has(id) && fold.findings.get(id).status === 'fixed').length;
192
- const autoFixRows = [];
193
- for (const f of fold.findings.values()) {
194
- if (f.agent !== agent || !samePhase(f.phase, phase) || f.status !== 'open') continue;
195
- if (ids.includes(f.id) || V.AUTO_FIX_EXEMPT.includes(f.class)) continue;
196
- autoFixRows.push({ v: LEDGER_V, kind: 'disposition', ts, id: f.id, disposition: 'fixed', reason: `not reported by ${agent} at attempt ${attempt}`, auto: true, verdict_id: verdictId });
269
+
270
+ // Findings close only on evidence (V6). Silence is not evidence: an earlier open
271
+ // finding the new verdict neither reports nor answers stays open, and the record
272
+ // lists it as unaddressed. What closes one: the judge's `resolved` answer with its
273
+ // evidence, or, for a code-fact finding, the fact passing.
274
+ const resolveRows = [];
275
+ const closing = new Set();
276
+ const close = (f, reason) => {
277
+ if (closing.has(f.id) || ids.includes(f.id) || f.status !== 'open') return;
278
+ closing.add(f.id);
279
+ resolveRows.push({ v: LEDGER_V, kind: 'disposition', ts, id: f.id, disposition: 'fixed', reason: truncateReason(reason), auto: true, verdict_id: verdictId });
280
+ };
281
+ let contradicted = 0;
282
+ for (const r of verdict.resolutions || []) {
283
+ const target = resolutionTarget(mine, r);
284
+ if (!target) { parsed.warnings.push(`resolution_unmatched: ${r.id || r.summary}`); continue; }
285
+ if (r.status === 'resolved') {
286
+ if (ids.includes(target.id)) { contradicted++; continue; }
287
+ close(target, `resolved by ${agent} at attempt ${attempt}: ${r.evidence}`);
288
+ } else if (r.status === 'still_present' && !ids.includes(target.id)) {
289
+ ids.push(target.id); // a re-report: reopens a fixed finding, keeps an open one open
290
+ }
197
291
  }
292
+ if (contradicted) parsed.warnings.push(`resolution_contradicted: ${contradicted} finding(s) answered resolved but reported again; they stay open`);
293
+ for (const fact of parsed.facts_ok || []) {
294
+ for (const f of mine) if (f.where === `code-fact:${fact}`) close(f, `${fact} passed at attempt ${attempt}`);
295
+ }
296
+ const reopened = ids.filter((id) => fold.findings.has(id) && fold.findings.get(id).status === 'fixed').length;
297
+ const unaddressed = mine.filter((f) => f.status === 'open' && !ids.includes(f.id) && !closing.has(f.id)).map((f) => f.id);
298
+ if (unaddressed.length) parsed.warnings.push(`open_findings_unaddressed: ${unaddressed.length} earlier finding(s) neither reported nor resolved; they stay open`);
198
299
 
199
300
  const bySeverity = countBy(verdict.findings, 'severity');
200
301
  const byClass = countBy(verdict.findings, 'class');
@@ -205,11 +306,14 @@ function recordVerdict(cwd, input) {
205
306
  }
206
307
  newRows.push({
207
308
  v: LEDGER_V, kind: 'verdict', ts, verdict_id: verdictId, record_sig: recordSig, phase, milestone, agent,
208
- verdict: verdict.verdict, outcome: verdict.outcome, attempt, source, finding_ids: ids,
309
+ verdict: verdict.verdict, outcome: verdict.outcome, ...lowered, attempt, source, finding_ids: ids,
209
310
  by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
210
311
  ...(notChecked.length ? { not_checked: notChecked } : {}),
312
+ // A plan check is bound to the plans it checked (V7): exec-phase compares this
313
+ // hash with the plans it is about to run.
314
+ ...(agent === 'pan-plan-checker' ? { plans_sha: require('./verify-plan-set.cjs').plansShaFor(cwd, phase) } : {}),
211
315
  });
212
- appendRows(cwd, [...findingRows, ...newRows, ...autoFixRows]);
316
+ appendRows(cwd, [...findingRows, ...newRows, ...resolveRows]);
213
317
 
214
318
  const category = { pass: 'verdict_passed', fail: 'verdict_failed', needs_human: 'verdict_needs_human' }[verdict.outcome];
215
319
  const serious = (bySeverity.critical || 0) + (bySeverity.high || 0) > 0;
@@ -228,13 +332,13 @@ function recordVerdict(cwd, input) {
228
332
  category: 'verdict_retry',
229
333
  agent,
230
334
  phase,
231
- description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${autoFixRows.length} earlier finding(s) no longer reported`,
335
+ description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${resolveRows.length} earlier finding(s) resolved, ${unaddressed.length} unaddressed`,
232
336
  impact: 'minor',
233
- context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome, auto_fixed: autoFixRows.length, reopened, verdict_id: verdictId },
337
+ context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome, resolved: resolveRows.length, unaddressed: unaddressed.length, reopened, verdict_id: verdictId },
234
338
  });
235
339
  }
236
340
 
237
- return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length, auto_fixed: autoFixRows.length, reopened, ledger: planningRel(FINDINGS_FILE) };
341
+ return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length, rematched, resolved: resolveRows.length, unaddressed, reopened, ledger: planningRel(FINDINGS_FILE) };
238
342
  }
239
343
 
240
344
  /**
@@ -276,6 +380,13 @@ function disposeFindings(cwd, input) {
276
380
  return { contract: V.VERDICT_CONTRACT, disposition: as, disposed, skipped };
277
381
  }
278
382
 
383
+ /**
384
+ * Milestone versions compare with or without the leading `v`: the ledger stores
385
+ * `getMilestoneInfo`'s `v1.0`, while milestone-audit names its version `1.0` (its
386
+ * report is `v{version}-milestone-audit.md`), so `--milestone 1.0` found no debt.
387
+ */
388
+ const sameMilestone = (a, b) => String(a || '').trim().replace(/^v/i, '') === String(b || '').trim().replace(/^v/i, '');
389
+
279
390
  /** Findings with their folded status, filtered. */
280
391
  function listFindings(cwd, filters = {}) {
281
392
  const { rows, malformed } = readLedger(cwd);
@@ -285,13 +396,14 @@ function listFindings(cwd, filters = {}) {
285
396
  if (filters.agent) list = list.filter((f) => f.agent === filters.agent);
286
397
  if (filters.status) list = list.filter((f) => f.status === filters.status);
287
398
  if (filters.class) list = list.filter((f) => f.class === filters.class);
288
- if (filters.milestone) list = list.filter((f) => f.milestone === filters.milestone);
399
+ if (filters.milestone) list = list.filter((f) => sameMilestone(f.milestone, filters.milestone));
289
400
  const findings = list.map(publicFinding);
290
401
  return {
291
402
  contract: V.VERDICT_CONTRACT,
292
403
  findings,
293
404
  counts: { total: findings.length, by_status: countBy(findings, 'status'), by_class: countBy(findings, 'class'), by_severity: countBy(findings, 'severity') },
294
405
  verdicts: fold.verdicts.length,
406
+ ...(filters.phase && filters.agent ? { churn: findingsChurn(rows, filters.phase, filters.agent) } : {}),
295
407
  malformed_rows: malformed,
296
408
  };
297
409
  }
@@ -306,7 +418,7 @@ function findingsDebt(cwd, opts = {}) {
306
418
  const fold = foldFindings(rows);
307
419
  const byPhase = new Map();
308
420
  for (const f of fold.findings.values()) {
309
- if (milestone && f.milestone !== milestone) continue;
421
+ if (milestone && !sameMilestone(f.milestone, milestone)) continue;
310
422
  if (f.status !== 'deferred' && f.status !== 'open') continue;
311
423
  const key = V.comparablePhase(f.phase);
312
424
  if (!byPhase.has(key)) byPhase.set(key, { phase: f.phase, deferred: [], open: [] });
@@ -377,6 +489,8 @@ module.exports = {
377
489
  disposeFindings,
378
490
  listFindings,
379
491
  findingsDebt,
492
+ findingsChurn,
493
+ sameFinding,
380
494
  findingIdsIn,
381
495
  cmdFindingsRecord,
382
496
  cmdFindingsDispose,
@@ -22,6 +22,7 @@ const {
22
22
  const { extractFrontmatter, extractPriorityEffort } = require('./frontmatter.cjs');
23
23
  const { enumerateRoadmapPhases } = require('./roadmap.cjs');
24
24
  const { readErrorPatterns } = require('./commands.cjs');
25
+ const { weeklyLimitStop } = require('./cost.cjs');
25
26
  const { planningPath, listPhaseDirs, classifyPhaseStatus, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
26
27
 
27
28
  // ─── Scan helpers ───────────────────────────────────────────────────────────
@@ -302,10 +303,13 @@ function allocateBudget(items, budget, mode) {
302
303
  } else if (mode === 'features') {
303
304
  const { picked: mPicked, used: mUsed } = allocatePass(items, budget, { maxPriority: 0, minPriority: 0, track: 'mandatory' });
304
305
  addToBatch(mPicked);
305
- const featureBudget = Math.floor(budget * FEATURE_RATIO);
306
+ // The feature/stability split shares what the P0 pass left: splitting the
307
+ // whole budget again let a batch reach nearly twice the budget.
308
+ const rest = budget - mUsed;
309
+ const featureBudget = Math.floor(rest * FEATURE_RATIO);
306
310
  const { picked: fPicked, used: fUsed } = allocatePass(items, featureBudget, { minPriority: 3, maxPriority: 5, track: 'feature', exclude: batchIds });
307
311
  addToBatch(fPicked);
308
- const { picked: sPicked, used: sUsed } = allocatePass(items, budget - featureBudget, { minPriority: 1, maxPriority: 2, track: 'stability', exclude: batchIds });
312
+ const { picked: sPicked, used: sUsed } = allocatePass(items, rest - featureBudget, { minPriority: 1, maxPriority: 2, track: 'stability', exclude: batchIds });
309
313
  addToBatch(sPicked);
310
314
  allocated = mUsed + fUsed + sUsed;
311
315
  } else {
@@ -920,9 +924,14 @@ function focusAutoUpdate(cwd, raw, getVal) {
920
924
  run.totals.tests_current = cycle.tests_after;
921
925
  run.status = AUTORUN_STATUSES.IN_PROGRESS;
922
926
 
923
- const stopReason = determineStopReason(cycle, run);
927
+ // The host's 7-day usage window (Claude Code subscriptions, read from what PAN's
928
+ // status line last saw): past cost.weekly_limit_stop_pct the run stops resumable,
929
+ // so the rest of the window stays the user's. No fresh reading never stops it.
930
+ const weekly = weeklyLimitStop(cwd);
931
+ cycle.weekly_limit_used_pct = weekly.seven_day_used_pct;
932
+ const stopReason = determineStopReason(cycle, run) || (weekly.stop ? 'weekly_limit' : null);
924
933
  if (stopReason) {
925
- run.status = stopReason === 'regression' ? AUTORUN_STATUSES.STOPPED : AUTORUN_STATUSES.COMPLETED;
934
+ run.status = stopReason === 'regression' || stopReason === 'weekly_limit' ? AUTORUN_STATUSES.STOPPED : AUTORUN_STATUSES.COMPLETED;
926
935
  run.stop_reason = stopReason;
927
936
  }
928
937