pan-wizard 3.32.0 → 3.34.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 (209) hide show
  1. package/README.md +23 -20
  2. package/agents/pan-conductor.md +18 -17
  3. package/agents/pan-counterfactual.md +2 -2
  4. package/agents/pan-debugger.md +27 -8
  5. package/agents/pan-design-checker.md +1 -1
  6. package/agents/pan-designer.md +2 -2
  7. package/agents/pan-distiller.md +3 -3
  8. package/agents/pan-document_code.md +16 -16
  9. package/agents/pan-executor.md +15 -11
  10. package/agents/pan-experiment-runner.md +15 -17
  11. package/agents/pan-hardener.md +15 -15
  12. package/agents/pan-integration-checker.md +2 -2
  13. package/agents/pan-knowledge.md +2 -2
  14. package/agents/pan-meta-reviewer.md +5 -5
  15. package/agents/pan-optimizer.md +40 -44
  16. package/agents/pan-phase-researcher.md +10 -10
  17. package/agents/pan-plan-checker.md +36 -30
  18. package/agents/pan-planner.md +77 -53
  19. package/agents/pan-previewer.md +1 -1
  20. package/agents/pan-project-researcher.md +21 -78
  21. package/agents/pan-release.md +1 -1
  22. package/agents/pan-research-synthesizer.md +2 -2
  23. package/agents/pan-reviewer.md +24 -2
  24. package/agents/pan-roadmapper.md +20 -62
  25. package/agents/pan-verifier.md +53 -30
  26. package/bin/install-lib.cjs +228 -38
  27. package/bin/install.js +86 -15
  28. package/commands/pan/army.md +16 -12
  29. package/commands/pan/assumptions.md +2 -2
  30. package/commands/pan/audit-deployment.md +11 -12
  31. package/commands/pan/cost.md +8 -4
  32. package/commands/pan/debug.md +10 -7
  33. package/commands/pan/design-phase.md +3 -3
  34. package/commands/pan/discuss-phase.md +2 -0
  35. package/commands/pan/exec-phase.md +21 -37
  36. package/commands/pan/experiment.md +11 -10
  37. package/commands/pan/focus-auto.md +33 -27
  38. package/commands/pan/focus-design.md +9 -6
  39. package/commands/pan/focus-doc-audit.md +4 -3
  40. package/commands/pan/focus-drift-walking.md +4 -3
  41. package/commands/pan/focus-exec.md +7 -7
  42. package/commands/pan/focus-plan.md +12 -11
  43. package/commands/pan/focus-scan.md +7 -10
  44. package/commands/pan/focus-sync.md +3 -2
  45. package/commands/pan/git.md +8 -8
  46. package/commands/pan/health.md +2 -2
  47. package/commands/pan/hud.md +3 -2
  48. package/commands/pan/hygiene.md +14 -7
  49. package/commands/pan/knowledge.md +9 -9
  50. package/commands/pan/learn.md +10 -7
  51. package/commands/pan/links.md +4 -1
  52. package/commands/pan/map-codebase.md +5 -5
  53. package/commands/pan/mcp-bridge.md +18 -18
  54. package/commands/pan/milestone-done.md +7 -5
  55. package/commands/pan/milestone-new.md +1 -1
  56. package/commands/pan/new-project.md +10 -10
  57. package/commands/pan/optimize.md +16 -14
  58. package/commands/pan/patches.md +2 -6
  59. package/commands/pan/pause.md +1 -1
  60. package/commands/pan/phase-tests.md +4 -3
  61. package/commands/pan/plan-phase.md +9 -19
  62. package/commands/pan/preview.md +1 -1
  63. package/commands/pan/profile.md +4 -4
  64. package/commands/pan/quick.md +1 -1
  65. package/commands/pan/remove-phase.md +1 -1
  66. package/commands/pan/report.md +2 -2
  67. package/commands/pan/research-phase.md +14 -15
  68. package/commands/pan/resume.md +9 -11
  69. package/commands/pan/retro.md +5 -3
  70. package/commands/pan/review-deep.md +10 -10
  71. package/commands/pan/settings.md +1 -1
  72. package/commands/pan/todo-add.md +2 -2
  73. package/commands/pan/todo-check.md +2 -2
  74. package/commands/pan/verify-phase.md +0 -11
  75. package/commands/pan/what-if.md +2 -2
  76. package/hooks/dist/pan-context-monitor.js +343 -44
  77. package/hooks/dist/pan-cost-logger.js +30 -0
  78. package/hooks/dist/pan-state-reinject.js +140 -15
  79. package/hooks/dist/pan-statusline.js +68 -22
  80. package/hooks/dist/pan-stop-guard.js +7 -2
  81. package/package.json +1 -1
  82. package/pan-wizard-core/bin/lib/agents-md.cjs +48 -7
  83. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  84. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  85. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  86. package/pan-wizard-core/bin/lib/commands.cjs +126 -5
  87. package/pan-wizard-core/bin/lib/config.cjs +112 -1
  88. package/pan-wizard-core/bin/lib/constants.cjs +29 -15
  89. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  90. package/pan-wizard-core/bin/lib/core.cjs +77 -23
  91. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  92. package/pan-wizard-core/bin/lib/cost.cjs +149 -10
  93. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  94. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  95. package/pan-wizard-core/bin/lib/findings.cjs +15 -4
  96. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  97. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  98. package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
  99. package/pan-wizard-core/bin/lib/init.cjs +12 -5
  100. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  101. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  102. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  103. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  104. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  105. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  106. package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
  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 +67 -15
  110. package/pan-wizard-core/bin/lib/preview.cjs +3 -1
  111. package/pan-wizard-core/bin/lib/review-deep.cjs +2 -2
  112. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  113. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  114. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  115. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  116. package/pan-wizard-core/bin/lib/state.cjs +47 -31
  117. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  118. package/pan-wizard-core/bin/lib/verdict.cjs +54 -4
  119. package/pan-wizard-core/bin/lib/verify-drift.cjs +14 -5
  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 +74 -4
  123. package/pan-wizard-core/bin/lib/verify.cjs +117 -35
  124. package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
  125. package/pan-wizard-core/bin/pan-tools.cjs +111 -17
  126. package/pan-wizard-core/learnings/index.json +5 -5
  127. package/pan-wizard-core/mcp/server.cjs +18 -9
  128. package/pan-wizard-core/opencode/pan-wizard.js +111 -0
  129. package/pan-wizard-core/references/checkpoints.md +7 -5
  130. package/pan-wizard-core/references/continuation-format.md +3 -3
  131. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  132. package/pan-wizard-core/references/design-methodology.md +1 -1
  133. package/pan-wizard-core/references/git-integration.md +4 -6
  134. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  135. package/pan-wizard-core/references/guardrails.md +2 -2
  136. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  137. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  138. package/pan-wizard-core/references/model-profiles.md +14 -9
  139. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  140. package/pan-wizard-core/references/planning-config.md +4 -4
  141. package/pan-wizard-core/references/tdd.md +10 -1
  142. package/pan-wizard-core/references/verdict-contract.md +2 -1
  143. package/pan-wizard-core/references/verification-patterns.md +5 -5
  144. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  145. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  146. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  147. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  148. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  149. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  150. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  151. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  152. package/pan-wizard-core/templates/continue-here.md +1 -1
  153. package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
  154. package/pan-wizard-core/templates/debug.md +15 -1
  155. package/pan-wizard-core/templates/discovery.md +1 -1
  156. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  157. package/pan-wizard-core/templates/milestone.md +1 -1
  158. package/pan-wizard-core/templates/phase-prompt.md +31 -18
  159. package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
  160. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  161. package/pan-wizard-core/templates/research-project/features.md +1 -1
  162. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  163. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  164. package/pan-wizard-core/templates/research.md +5 -5
  165. package/pan-wizard-core/templates/retrospective.md +1 -1
  166. package/pan-wizard-core/templates/roadmap.md +15 -15
  167. package/pan-wizard-core/templates/summary-complex.md +1 -0
  168. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  169. package/pan-wizard-core/templates/summary-standard.md +2 -1
  170. package/pan-wizard-core/templates/summary.md +18 -2
  171. package/pan-wizard-core/templates/uat.md +3 -3
  172. package/pan-wizard-core/templates/user-setup.md +8 -8
  173. package/pan-wizard-core/templates/verification-report.md +54 -4
  174. package/pan-wizard-core/workflows/add-phase.md +1 -1
  175. package/pan-wizard-core/workflows/assumptions.md +1 -1
  176. package/pan-wizard-core/workflows/discuss-phase.md +9 -8
  177. package/pan-wizard-core/workflows/exec-phase.md +34 -60
  178. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  179. package/pan-wizard-core/workflows/health.md +13 -11
  180. package/pan-wizard-core/workflows/help.md +8 -9
  181. package/pan-wizard-core/workflows/learn.md +13 -11
  182. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  183. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  184. package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
  185. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  186. package/pan-wizard-core/workflows/new-project.md +10 -10
  187. package/pan-wizard-core/workflows/optimize.md +11 -9
  188. package/pan-wizard-core/workflows/pause.md +6 -1
  189. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  190. package/pan-wizard-core/workflows/plan-phase.md +31 -34
  191. package/pan-wizard-core/workflows/profile.md +2 -2
  192. package/pan-wizard-core/workflows/progress.md +5 -7
  193. package/pan-wizard-core/workflows/quick.md +10 -8
  194. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  195. package/pan-wizard-core/workflows/research-phase.md +9 -5
  196. package/pan-wizard-core/workflows/resume-project.md +4 -6
  197. package/pan-wizard-core/workflows/retro.md +3 -1
  198. package/pan-wizard-core/workflows/settings.md +16 -24
  199. package/pan-wizard-core/workflows/todo-check.md +1 -1
  200. package/pan-wizard-core/workflows/transition.md +12 -12
  201. package/pan-wizard-core/workflows/update.md +25 -17
  202. package/pan-wizard-core/workflows/verify-phase.md +24 -17
  203. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  204. package/pan-zcode/README.md +7 -7
  205. package/pan-zcode/bin/install-zcode.js +1 -1
  206. package/scripts/build-agent-plugin.js +19 -5
  207. package/scripts/build-plugin.js +3 -1
  208. package/scripts/generate-skills-docs.py +13 -4
  209. package/scripts/test-surface.cjs +4 -0
@@ -39,8 +39,13 @@ const { planningPath, planningRel } = require('./utils.cjs');
39
39
  // budget-profile project on Codex or OpenCode was told to spawn a model called
40
40
  // "mid" (reality check R28).
41
41
  // - openai: Codex's subagent docs (learn.chatgpt.com/docs/agent-configuration/
42
- // subagents, read 2026-09-23): "start with gpt-6-sol. Use gpt-6-luna when you
43
- // want a faster, lower-cost option". gpt-6-astra is the flagship above both.
42
+ // subagents, re-read 2026-10-03) now start demanding agents on gpt-6.1-sol "when
43
+ // your signed-in account or workspace has access", and keep gpt-6-luna as the
44
+ // "faster, lower-cost option". The mid tier stays gpt-6-sol: GPT-6.1 Sol's launch
45
+ // keeps it off on Enterprise and Edu until an administrator enables it
46
+ // (learn.chatgpt.com/docs/models, read 2026-10-03), so pinning it would hand
47
+ // those users a model they cannot run. The inherit tier runs Codex's own
48
+ // default, which is gpt-6.1-sol where available. gpt-6-astra is the flagship.
44
49
  // - google: the newest stable Flash and Flash-Lite on ai.google.dev's models page
45
50
  // (read 2026-09-23). The 2.5 family these rows used is "not deprecated" but
46
51
  // limited to users who have used it before, so a new project may not reach it.
@@ -58,12 +63,16 @@ const PROVIDER_MODELS = {
58
63
  // packages/web/src/content/docs/models.mdx and models.dev/api.json, both read
59
64
  // 2026-09-26; every id below exists there under that provider). Same tiers as
60
65
  // PROVIDER_MODELS, provider-qualified; `default` is Anthropic as it is there.
61
- // Reality check R41.
66
+ // Reality check R41. The Anthropic mid tier follows the `sonnet` alias, which
67
+ // resolves to Sonnet 5.5 on the Anthropic API from Claude Code 2.1.284;
68
+ // models.dev lists `anthropic/claude-sonnet-5-5` (api.json, read 2026-10-03). The fast
69
+ // tier follows the `haiku` alias, which resolves to Haiku 5.5 on the Anthropic API from
70
+ // Claude Code 2.1.293; models.dev lists `anthropic/claude-haiku-5-5` (read 2026-10-10).
62
71
  const OPENCODE_MODELS = {
63
- anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
64
- openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol', fast: 'openai/gpt-6-luna' },
65
- google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash', fast: 'google/gemini-3.5-flash-lite' },
66
- default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
72
+ anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5-5', fast: 'anthropic/claude-haiku-5-5' },
73
+ openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol', fast: 'openai/gpt-6-luna' },
74
+ google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash', fast: 'google/gemini-3.5-flash-lite' },
75
+ default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5-5', fast: 'anthropic/claude-haiku-5-5' },
67
76
  };
68
77
 
69
78
  /**
@@ -382,6 +391,32 @@ function safeReadFile(filePath) {
382
391
  }
383
392
  }
384
393
 
394
+ // ─── Line endings ───────────────────────────────────────────────────────────
395
+ // A Windows working tree (core.autocrlf=true) holds planning files with CRLF
396
+ // endings, and a parser written for '\n' finds nothing in them without saying so:
397
+ // on 2026-10-04 every Windows field project measured was CRLF throughout, and the
398
+ // memory optimizer, the must-haves parser and `frontmatter set` had all been
399
+ // silently no-op (or worse) there. Parse on LF; write back in the file's own ending.
400
+
401
+ /** LF text: CRLF → LF and a leading BOM dropped. */
402
+ function toLf(text) {
403
+ return String(text == null ? '' : text).replace(/^/, '').replace(/\r\n/g, '\n');
404
+ }
405
+
406
+ /** The ending a text mostly uses: '\r\n' when its CRLF lines outnumber its bare LF ones. */
407
+ function dominantEol(text) {
408
+ const s = String(text == null ? '' : text);
409
+ const crlf = (s.match(/\r\n/g) || []).length;
410
+ const lf = (s.match(/\n/g) || []).length - crlf;
411
+ return crlf > lf ? '\r\n' : '\n';
412
+ }
413
+
414
+ /** LF text given back in the ending `eol` names. */
415
+ function withEol(text, eol) {
416
+ const s = String(text == null ? '' : text);
417
+ return eol === '\r\n' ? s.replace(/\r?\n/g, '\r\n') : s;
418
+ }
419
+
385
420
  /**
386
421
  * Load project config from .planning/config.json, merging with defaults.
387
422
  * Handles nested config sections (planning.*, workflow.*, git.*) and flat keys.
@@ -455,10 +490,10 @@ function loadConfig(cwd) {
455
490
  // Cost dashboard config: `cost.rates` per-model overrides (surfaced so the
456
491
  // documented override actually reaches cost.cjs — it was dropped before).
457
492
  cost: parsed.cost || {},
458
- // Prompt-cache config: `cache.extra_files` lets a project add its own
459
- // stable documents to the cached context block. Needed because the
460
- // built-in list is the phase-model spine, so a focus-model project had
461
- // an empty block and therefore no prompt caching at all.
493
+ // Cache config: `cache.extra_files` lets a project add its own stable
494
+ // documents to the block `cache prime` and the size checks measure. Needed
495
+ // because the built-in list is the phase-model spine, so a focus-model
496
+ // project had an empty block and nothing measured.
462
497
  cache: parsed.cache || {},
463
498
  // ADR-0031: project build/verification commands. null = not configured
464
499
  // (focus-auto --clean-seal then asks or skips rather than guessing).
@@ -552,6 +587,18 @@ function escapeRegex(value) {
552
587
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
553
588
  }
554
589
 
590
+ /**
591
+ * Make free text safe for one markdown table cell: escape backslashes and pipes,
592
+ * and fold every run of line breaks into one space. A raw `|` splits the row into
593
+ * an extra column, and state.md's tables are appended to for the life of a
594
+ * project, so one bad row breaks every later read of the table (market-ideas M21).
595
+ * @param {*} value - Cell text; null and undefined become ''
596
+ * @returns {string}
597
+ */
598
+ function escapeTableCell(value) {
599
+ return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
600
+ }
601
+
555
602
  /**
556
603
  * Normalize a phase identifier to zero-padded format.
557
604
  * Examples: "1" → "01", "3A" → "03A", "12.1" → "12.1"
@@ -615,7 +662,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
615
662
  try {
616
663
  const entries = fs.readdirSync(baseDir, { withFileTypes: true });
617
664
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
618
- const match = dirs.find(d => d.startsWith(normalized));
665
+ const match = dirs.find(d => d === normalized || d.startsWith(normalized + '-'));
619
666
  if (!match) return null;
620
667
 
621
668
  const dirMatch = match.match(PHASE_DIR_RE);
@@ -766,7 +813,11 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
766
813
  const restOfContent = content.slice(headerIndex);
767
814
  const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
768
815
  const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
769
- const section = content.slice(headerIndex, sectionEnd).trim();
816
+ let section = content.slice(headerIndex, sectionEnd).trim();
817
+ // A phase moved out by `roadmap compact` leaves a stub; the full section
818
+ // (and any model_tier override with it) is in roadmap-history.md.
819
+ const compacted = require('./roadmap-compact.cjs').compactedPhaseSection(cwd, unpadded, section);
820
+ if (compacted && compacted.section) section = compacted.section;
770
821
 
771
822
  const goalMatch = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
772
823
  const goal = goalMatch ? goalMatch[1].trim() : null;
@@ -777,6 +828,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
777
828
  phase_name: phaseName,
778
829
  goal,
779
830
  section,
831
+ ...(compacted ? { compacted: true } : {}),
780
832
  };
781
833
  } catch {
782
834
  return null;
@@ -1343,12 +1395,12 @@ function scanPendingTodos(cwd, area) {
1343
1395
  *
1344
1396
  * Reads files from .planning/ that are stable across agent calls within a phase
1345
1397
  * (project.md, requirements.md, roadmap.md, state.md, standards.md). Each block
1346
- * is tagged `cache: true` so the host runtime (or installer) can translate to
1347
- * the appropriate per-runtime caching syntax (Anthropic cache_control, etc.).
1398
+ * is tagged `cache: true`. Nothing translates that tag into a cache marker: the
1399
+ * host caches each agent's prompt prefix by itself, so this list is what PAN
1400
+ * measures (context-budget, hygiene's cache-context check), not what it primes.
1348
1401
  *
1349
1402
  * Files that don't exist are skipped silently. The order matches the file list
1350
- * in constants.cjs to keep prompt prefixes byte-stable across calls (which is
1351
- * what cache key matching requires).
1403
+ * in constants.cjs, so the `sha` of an unchanged set stays the same.
1352
1404
  *
1353
1405
  * @param {string} cwd - Project root
1354
1406
  * @returns {{blocks: Array<{path: string, content: string, cache: true}>, total_bytes: number, sha: string}}
@@ -1361,14 +1413,12 @@ function buildCachedContext(cwd) {
1361
1413
  const hasher = crypto.createHash('sha256');
1362
1414
 
1363
1415
  // The built-in list is the PHASE-model spine. A focus-model project has none
1364
- // of those files, so its cached block came out empty and it silently received
1365
- // no prompt caching at all. `cache.extra_files` lets such a project name its
1366
- // own stable documents rather than PAN inventing a convention it doesn't
1367
- // otherwise define.
1416
+ // of those files, so its measured block came out empty and the size checks had
1417
+ // nothing to read. `cache.extra_files` lets such a project name its own stable
1418
+ // documents rather than PAN inventing a convention it doesn't otherwise define.
1368
1419
  //
1369
1420
  // Entries are planning-root-relative, must stay inside it, and are appended
1370
- // after the built-ins so the prefix stays byte-stable for projects that set
1371
- // nothing — changing the prefix would invalidate every existing cache key.
1421
+ // after the built-ins, so the `sha` of a project that sets nothing does not change.
1372
1422
  const extra = [];
1373
1423
  try {
1374
1424
  const configured = loadConfig(cwd)?.cache?.extra_files;
@@ -1449,11 +1499,15 @@ module.exports = {
1449
1499
  error,
1450
1500
  verbose,
1451
1501
  safeReadFile,
1502
+ toLf,
1503
+ dominantEol,
1504
+ withEol,
1452
1505
  loadConfig,
1453
1506
  isGitIgnored,
1454
1507
  isGitRepo,
1455
1508
  execGit,
1456
1509
  escapeRegex,
1510
+ escapeTableCell,
1457
1511
  normalizePhaseName,
1458
1512
  comparePhaseNum,
1459
1513
  searchPhaseInDir,
@@ -39,7 +39,7 @@ const os = require('os');
39
39
  const path = require('path');
40
40
  const { output, error, loadConfig } = require('./core.cjs');
41
41
  const { planningPath } = require('./utils.cjs');
42
- const { readRecords, computeCost, effectiveRates, isSuspectRecord, isEmptyRecord, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
42
+ const { readRecords, computeCost, effectiveRates, isSuspectRecord, isEmptyRecord, LONG_PROMPT_THRESHOLD, METRICS_DIR, TOKENS_FILE } = require('./cost.cjs');
43
43
 
44
44
  const SCHEMA_V = 4; // matches hooks/pan-cost-logger.js SCHEMA_V
45
45
  const MAIN_THREAD_AGENT = '(main thread)';
@@ -144,6 +144,17 @@ function sumTranscriptUsage(file) {
144
144
  totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + num(cc, 'ephemeral_1h_input_tokens');
145
145
  totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + num(cc, 'ephemeral_5m_input_tokens');
146
146
  }
147
+ // A request whose prompt is over LONG_PROMPT_THRESHOLD bills at a model's long
148
+ // rate where it has one (Haiku 5.5); the same rule as the cost-logger hook.
149
+ const prompt = num(u, 'input_tokens') + num(u, 'cache_read_input_tokens') + num(u, 'cache_creation_input_tokens');
150
+ if (prompt > LONG_PROMPT_THRESHOLD) {
151
+ 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 });
152
+ lp.input_tokens += num(u, 'input_tokens');
153
+ lp.output_tokens += num(u, 'output_tokens');
154
+ lp.cache_read_tokens += num(u, 'cache_read_input_tokens');
155
+ lp.cache_write_tokens += num(u, 'cache_creation_input_tokens');
156
+ if (cc && typeof cc === 'object') lp.cache_write_1h_tokens += num(cc, 'ephemeral_1h_input_tokens');
157
+ }
147
158
  }
148
159
  totals.turns = byMessage.size;
149
160
  let best = null;
@@ -304,6 +315,7 @@ function makeRow(fields) {
304
315
  ...(typeof fields.usage.cache_write_1h_tokens === 'number'
305
316
  ? { cache_write_1h_tokens: fields.usage.cache_write_1h_tokens, cache_write_5m_tokens: fields.usage.cache_write_5m_tokens || 0 }
306
317
  : {}),
318
+ ...(fields.usage.long_prompt ? { long_prompt: fields.usage.long_prompt } : {}),
307
319
  cost_usd: null,
308
320
  duration_ms: fields.usage.first_ts && fields.usage.last_ts ? Date.parse(fields.usage.last_ts) - Date.parse(fields.usage.first_ts) : null,
309
321
  phase: null,
@@ -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,10 +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.
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 },
94
106
  'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
95
107
  'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
96
108
  // Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
97
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 } },
98
118
  // Haiku 4.5: API id claude-haiku-4-5-20251001, alias claude-haiku-4-5.
99
119
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25, cache_write_1h: 2.0 },
100
120
 
@@ -105,10 +125,14 @@ const DEFAULT_RATES = {
105
125
  // as plain input. Until 2026-09-22 this block said "no separate write charge" for
106
126
  // every row, which had stopped being true — Terra and Luna writes were a fifth
107
127
  // low (R27). Longest-prefix match lets each tier key win over a shorter one.
108
- // GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22. Codex's
109
- // config docs use gpt-6-sol as the default model and gpt-6-luna for lower-cost
110
- // subagents (learn.chatgpt.com/docs/config-file/config-basic, /agent-configuration/subagents).
128
+ // GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22. GPT-6.1 Sol
129
+ // is Codex's default model from 0.159.1 (2026-09-29): `model = "gpt-6.1-sol"` in
130
+ // config-basic, and the subagent docs' starting model for demanding agents, with
131
+ // gpt-6-luna for lower-cost ones (learn.chatgpt.com/docs/config-file/config-basic,
132
+ // /agent-configuration/subagents, both read 2026-10-03). Its row was read from the
133
+ // pricing page on 2026-10-03: GPT-6 Sol's prices, except cached input at 0.05×.
111
134
  'gpt-6-astra': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
135
+ 'gpt-6.1-sol': { input: 2.0, output: 10.0, cache_read: 0.1, cache_write: 2.5 },
112
136
  'gpt-6-sol': { input: 2.0, output: 10.0, cache_read: 0.2, cache_write: 2.5 },
113
137
  'gpt-6-luna': { input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125 },
114
138
  // GPT-5.6. The bare `gpt-5.6` id is an alias that "routes requests to GPT-5.6 Sol"
@@ -232,6 +256,7 @@ const scaleRate = (rate, m) => ({
232
256
  cache_read: round6(rate.cache_read * m),
233
257
  cache_write: round6(rate.cache_write * m),
234
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) } } : {}),
235
260
  });
236
261
 
237
262
  /**
@@ -378,12 +403,35 @@ function computeCost(rec, configRates) {
378
403
  // bills at the one-hour rate; the rest — and every write on a row or rate without
379
404
  // the split — at `cache_write`.
380
405
  const write1h = Math.min(cacheWrite, Math.max(0, Number(rec.cache_write_1h_tokens) || 0));
381
- const rate1h = typeof rate.cache_write_1h === 'number' ? rate.cache_write_1h : rate.cache_write;
382
- const usd = (input * rate.input + output * rate.output + cacheRead * rate.cache_read
383
- + (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);
384
425
  return Math.round(usd * 10000) / 10000;
385
426
  }
386
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
+
387
435
  /**
388
436
  * Append a cost record. Non-blocking — errors are swallowed so instrumentation
389
437
  * never breaks the caller.
@@ -522,7 +570,11 @@ function aggregate(cwd, opts) {
522
570
  const records = readRecords(cwd);
523
571
  const malformedSkipped = _lastReadMalformed; // captured before any later read
524
572
  const since = opts?.since ? new Date(opts.since).getTime() : null;
525
- 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;
526
578
  const config = loadConfig(cwd);
527
579
  const configRates = effectiveRates(config);
528
580
 
@@ -689,10 +741,12 @@ function renderChart(agg) {
689
741
  function cmdCostReport(cwd, opts, raw) {
690
742
  const format = opts?.format || 'json';
691
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.
692
746
  if (format === 'table') {
693
- output(agg, raw, renderTable(agg));
747
+ output(agg, true, renderTable(agg));
694
748
  } else if (format === 'chart') {
695
- output(agg, raw, renderChart(agg));
749
+ output(agg, true, renderChart(agg));
696
750
  } else {
697
751
  output(agg, raw);
698
752
  }
@@ -712,6 +766,86 @@ function cmdCostClear(cwd, raw) {
712
766
  }
713
767
  }
714
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
+
715
849
  // ─── Rate-table staleness ───────────────────────────────────────────────────
716
850
 
717
851
  // Date DEFAULT_RATES was last verified against published provider pricing — ALL
@@ -728,7 +862,7 @@ function cmdCostClear(cwd, raw) {
728
862
  // a default-model change inside the window — that is the job of the
729
863
  // documented-default-ids fixture test (tests/fixtures/documented-default-models.json,
730
864
  // R31), which fails the suite the day a documented default id has no exact row.
731
- const RATES_VERIFIED_AT = '2026-09-23';
865
+ const RATES_VERIFIED_AT = '2026-10-10';
732
866
  const RATES_STALE_AFTER_DAYS = 60;
733
867
  const RATE_TIERS = ['reasoning', 'mid', 'fast'];
734
868
 
@@ -774,6 +908,11 @@ module.exports = {
774
908
  renderTable,
775
909
  renderChart,
776
910
  resolveRate,
911
+ LONG_PROMPT_THRESHOLD,
912
+ readHostLimits,
913
+ weeklyLimitStop,
914
+ cmdCostLimits,
915
+ HOST_LIMITS_FILE,
777
916
  ratesFromModelPricing,
778
917
  managedSettingsDir,
779
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) {
@@ -160,7 +160,10 @@ function recordVerdict(cwd, input) {
160
160
 
161
161
  const { rows } = readLedger(cwd);
162
162
  const fold = foldFindings(rows);
163
- const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, source_kind: parsed.source_kind, warnings: parsed.warnings };
163
+ // `not_checked` (M23): what the judge could not run. A pass with entries here is a
164
+ // pass over less than the whole phase, and the record says so.
165
+ 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 };
164
167
 
165
168
  const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
166
169
  if (dup) {
@@ -204,6 +207,7 @@ function recordVerdict(cwd, input) {
204
207
  v: LEDGER_V, kind: 'verdict', ts, verdict_id: verdictId, record_sig: recordSig, phase, milestone, agent,
205
208
  verdict: verdict.verdict, outcome: verdict.outcome, attempt, source, finding_ids: ids,
206
209
  by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
210
+ ...(notChecked.length ? { not_checked: notChecked } : {}),
207
211
  });
208
212
  appendRows(cwd, [...findingRows, ...newRows, ...autoFixRows]);
209
213
 
@@ -216,7 +220,7 @@ function recordVerdict(cwd, input) {
216
220
  phase,
217
221
  description: `${agent} ${verdict.verdict || verdict.outcome} on phase ${phase} (attempt ${attempt}, ${ids.length} finding${ids.length === 1 ? '' : 's'})`,
218
222
  impact: verdict.outcome === 'fail' ? (serious ? 'major' : 'minor') : 'minor',
219
- context: { agent, phase, verdict: verdict.verdict, attempt, findings: ids.length, by_severity: bySeverity, by_class: byClass, verdict_id: verdictId },
223
+ context: { agent, phase, verdict: verdict.verdict, attempt, findings: ids.length, by_severity: bySeverity, by_class: byClass, verdict_id: verdictId, not_checked: notChecked.map((n) => n.check) },
220
224
  });
221
225
  if (previous && previous.outcome === 'fail') {
222
226
  traceEvent(cwd, {
@@ -272,6 +276,13 @@ function disposeFindings(cwd, input) {
272
276
  return { contract: V.VERDICT_CONTRACT, disposition: as, disposed, skipped };
273
277
  }
274
278
 
279
+ /**
280
+ * Milestone versions compare with or without the leading `v`: the ledger stores
281
+ * `getMilestoneInfo`'s `v1.0`, while milestone-audit names its version `1.0` (its
282
+ * report is `v{version}-milestone-audit.md`), so `--milestone 1.0` found no debt.
283
+ */
284
+ const sameMilestone = (a, b) => String(a || '').trim().replace(/^v/i, '') === String(b || '').trim().replace(/^v/i, '');
285
+
275
286
  /** Findings with their folded status, filtered. */
276
287
  function listFindings(cwd, filters = {}) {
277
288
  const { rows, malformed } = readLedger(cwd);
@@ -281,7 +292,7 @@ function listFindings(cwd, filters = {}) {
281
292
  if (filters.agent) list = list.filter((f) => f.agent === filters.agent);
282
293
  if (filters.status) list = list.filter((f) => f.status === filters.status);
283
294
  if (filters.class) list = list.filter((f) => f.class === filters.class);
284
- if (filters.milestone) list = list.filter((f) => f.milestone === filters.milestone);
295
+ if (filters.milestone) list = list.filter((f) => sameMilestone(f.milestone, filters.milestone));
285
296
  const findings = list.map(publicFinding);
286
297
  return {
287
298
  contract: V.VERDICT_CONTRACT,
@@ -302,7 +313,7 @@ function findingsDebt(cwd, opts = {}) {
302
313
  const fold = foldFindings(rows);
303
314
  const byPhase = new Map();
304
315
  for (const f of fold.findings.values()) {
305
- if (milestone && f.milestone !== milestone) continue;
316
+ if (milestone && !sameMilestone(f.milestone, milestone)) continue;
306
317
  if (f.status !== 'deferred' && f.status !== 'open') continue;
307
318
  const key = V.comparablePhase(f.phase);
308
319
  if (!byPhase.has(key)) byPhase.set(key, { phase: f.phase, deferred: [], open: [] });