pan-wizard 3.33.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 (197) hide show
  1. package/README.md +23 -20
  2. package/agents/pan-conductor.md +17 -16
  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 +14 -14
  9. package/agents/pan-executor.md +12 -8
  10. package/agents/pan-experiment-runner.md +8 -7
  11. package/agents/pan-integration-checker.md +2 -2
  12. package/agents/pan-knowledge.md +1 -1
  13. package/agents/pan-meta-reviewer.md +3 -3
  14. package/agents/pan-optimizer.md +39 -43
  15. package/agents/pan-phase-researcher.md +8 -8
  16. package/agents/pan-plan-checker.md +30 -28
  17. package/agents/pan-planner.md +54 -43
  18. package/agents/pan-previewer.md +1 -1
  19. package/agents/pan-project-researcher.md +11 -11
  20. package/agents/pan-release.md +1 -1
  21. package/agents/pan-research-synthesizer.md +2 -2
  22. package/agents/pan-reviewer.md +24 -2
  23. package/agents/pan-roadmapper.md +15 -14
  24. package/agents/pan-verifier.md +32 -24
  25. package/bin/install-lib.cjs +58 -23
  26. package/bin/install.js +47 -1
  27. package/commands/pan/army.md +15 -11
  28. package/commands/pan/assumptions.md +2 -2
  29. package/commands/pan/audit-deployment.md +11 -12
  30. package/commands/pan/cost.md +8 -4
  31. package/commands/pan/debug.md +10 -7
  32. package/commands/pan/design-phase.md +3 -3
  33. package/commands/pan/discuss-phase.md +2 -0
  34. package/commands/pan/exec-phase.md +21 -37
  35. package/commands/pan/experiment.md +9 -8
  36. package/commands/pan/focus-auto.md +22 -20
  37. package/commands/pan/focus-design.md +7 -6
  38. package/commands/pan/focus-doc-audit.md +4 -3
  39. package/commands/pan/focus-drift-walking.md +4 -3
  40. package/commands/pan/focus-exec.md +6 -6
  41. package/commands/pan/focus-plan.md +12 -11
  42. package/commands/pan/focus-scan.md +5 -3
  43. package/commands/pan/focus-sync.md +3 -2
  44. package/commands/pan/git.md +8 -8
  45. package/commands/pan/health.md +2 -2
  46. package/commands/pan/hud.md +3 -2
  47. package/commands/pan/hygiene.md +14 -7
  48. package/commands/pan/knowledge.md +9 -9
  49. package/commands/pan/learn.md +10 -7
  50. package/commands/pan/links.md +4 -1
  51. package/commands/pan/map-codebase.md +1 -1
  52. package/commands/pan/mcp-bridge.md +18 -18
  53. package/commands/pan/milestone-done.md +6 -3
  54. package/commands/pan/milestone-new.md +1 -1
  55. package/commands/pan/new-project.md +10 -10
  56. package/commands/pan/optimize.md +16 -14
  57. package/commands/pan/patches.md +2 -6
  58. package/commands/pan/pause.md +1 -1
  59. package/commands/pan/phase-tests.md +2 -1
  60. package/commands/pan/plan-phase.md +9 -19
  61. package/commands/pan/preview.md +1 -1
  62. package/commands/pan/profile.md +2 -2
  63. package/commands/pan/quick.md +1 -1
  64. package/commands/pan/remove-phase.md +1 -1
  65. package/commands/pan/report.md +2 -2
  66. package/commands/pan/research-phase.md +12 -11
  67. package/commands/pan/resume.md +9 -11
  68. package/commands/pan/retro.md +5 -3
  69. package/commands/pan/review-deep.md +7 -7
  70. package/commands/pan/settings.md +1 -1
  71. package/commands/pan/verify-phase.md +0 -11
  72. package/commands/pan/what-if.md +2 -2
  73. package/hooks/dist/pan-context-monitor.js +343 -44
  74. package/hooks/dist/pan-cost-logger.js +30 -0
  75. package/hooks/dist/pan-statusline.js +68 -22
  76. package/hooks/dist/pan-stop-guard.js +7 -2
  77. package/package.json +1 -1
  78. package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
  79. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  80. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  81. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  82. package/pan-wizard-core/bin/lib/commands.cjs +33 -4
  83. package/pan-wizard-core/bin/lib/config.cjs +2 -1
  84. package/pan-wizard-core/bin/lib/constants.cjs +18 -6
  85. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  86. package/pan-wizard-core/bin/lib/core.cjs +53 -19
  87. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  88. package/pan-wizard-core/bin/lib/cost.cjs +142 -13
  89. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  90. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  91. package/pan-wizard-core/bin/lib/findings.cjs +9 -2
  92. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  93. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  94. package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
  95. package/pan-wizard-core/bin/lib/init.cjs +7 -4
  96. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  97. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  98. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  99. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  100. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  101. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  102. package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
  103. package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
  104. package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
  105. package/pan-wizard-core/bin/lib/phase.cjs +15 -13
  106. package/pan-wizard-core/bin/lib/preview.cjs +3 -1
  107. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  108. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  109. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  110. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  111. package/pan-wizard-core/bin/lib/state.cjs +43 -28
  112. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  113. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
  114. package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
  115. package/pan-wizard-core/bin/lib/verify-scope.cjs +5 -2
  116. package/pan-wizard-core/bin/lib/verify.cjs +30 -28
  117. package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
  118. package/pan-wizard-core/bin/pan-tools.cjs +101 -15
  119. package/pan-wizard-core/learnings/index.json +5 -5
  120. package/pan-wizard-core/opencode/pan-wizard.js +111 -0
  121. package/pan-wizard-core/references/checkpoints.md +3 -3
  122. package/pan-wizard-core/references/continuation-format.md +3 -3
  123. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  124. package/pan-wizard-core/references/design-methodology.md +1 -1
  125. package/pan-wizard-core/references/git-integration.md +4 -6
  126. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  127. package/pan-wizard-core/references/guardrails.md +2 -2
  128. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  129. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  130. package/pan-wizard-core/references/model-profiles.md +14 -9
  131. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  132. package/pan-wizard-core/references/planning-config.md +4 -4
  133. package/pan-wizard-core/references/tdd.md +10 -1
  134. package/pan-wizard-core/references/verification-patterns.md +5 -5
  135. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  136. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  137. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  138. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  139. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  140. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  141. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  142. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  143. package/pan-wizard-core/templates/continue-here.md +1 -1
  144. package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
  145. package/pan-wizard-core/templates/debug.md +15 -1
  146. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  147. package/pan-wizard-core/templates/milestone.md +1 -1
  148. package/pan-wizard-core/templates/phase-prompt.md +29 -17
  149. package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
  150. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  151. package/pan-wizard-core/templates/research-project/features.md +1 -1
  152. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  153. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  154. package/pan-wizard-core/templates/research.md +5 -5
  155. package/pan-wizard-core/templates/retrospective.md +1 -1
  156. package/pan-wizard-core/templates/roadmap.md +15 -15
  157. package/pan-wizard-core/templates/summary-complex.md +1 -0
  158. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  159. package/pan-wizard-core/templates/summary-standard.md +2 -1
  160. package/pan-wizard-core/templates/summary.md +18 -2
  161. package/pan-wizard-core/templates/uat.md +3 -3
  162. package/pan-wizard-core/templates/user-setup.md +8 -8
  163. package/pan-wizard-core/templates/verification-report.md +53 -8
  164. package/pan-wizard-core/workflows/add-phase.md +1 -1
  165. package/pan-wizard-core/workflows/assumptions.md +1 -1
  166. package/pan-wizard-core/workflows/discuss-phase.md +9 -8
  167. package/pan-wizard-core/workflows/exec-phase.md +26 -57
  168. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  169. package/pan-wizard-core/workflows/health.md +13 -12
  170. package/pan-wizard-core/workflows/help.md +8 -9
  171. package/pan-wizard-core/workflows/learn.md +13 -11
  172. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  173. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  174. package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
  175. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  176. package/pan-wizard-core/workflows/new-project.md +9 -9
  177. package/pan-wizard-core/workflows/optimize.md +11 -9
  178. package/pan-wizard-core/workflows/pause.md +6 -1
  179. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  180. package/pan-wizard-core/workflows/plan-phase.md +31 -34
  181. package/pan-wizard-core/workflows/profile.md +2 -2
  182. package/pan-wizard-core/workflows/progress.md +5 -7
  183. package/pan-wizard-core/workflows/quick.md +5 -3
  184. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  185. package/pan-wizard-core/workflows/research-phase.md +9 -5
  186. package/pan-wizard-core/workflows/resume-project.md +4 -6
  187. package/pan-wizard-core/workflows/retro.md +3 -1
  188. package/pan-wizard-core/workflows/settings.md +16 -24
  189. package/pan-wizard-core/workflows/todo-check.md +1 -1
  190. package/pan-wizard-core/workflows/transition.md +12 -12
  191. package/pan-wizard-core/workflows/update.md +25 -17
  192. package/pan-wizard-core/workflows/verify-phase.md +17 -15
  193. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  194. package/pan-zcode/README.md +7 -7
  195. package/pan-zcode/bin/install-zcode.js +1 -1
  196. package/scripts/build-plugin.js +3 -1
  197. package/scripts/generate-skills-docs.py +13 -4
@@ -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) {
@@ -276,6 +276,13 @@ function disposeFindings(cwd, input) {
276
276
  return { contract: V.VERDICT_CONTRACT, disposition: as, disposed, skipped };
277
277
  }
278
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
+
279
286
  /** Findings with their folded status, filtered. */
280
287
  function listFindings(cwd, filters = {}) {
281
288
  const { rows, malformed } = readLedger(cwd);
@@ -285,7 +292,7 @@ function listFindings(cwd, filters = {}) {
285
292
  if (filters.agent) list = list.filter((f) => f.agent === filters.agent);
286
293
  if (filters.status) list = list.filter((f) => f.status === filters.status);
287
294
  if (filters.class) list = list.filter((f) => f.class === filters.class);
288
- if (filters.milestone) list = list.filter((f) => f.milestone === filters.milestone);
295
+ if (filters.milestone) list = list.filter((f) => sameMilestone(f.milestone, filters.milestone));
289
296
  const findings = list.map(publicFinding);
290
297
  return {
291
298
  contract: V.VERDICT_CONTRACT,
@@ -306,7 +313,7 @@ function findingsDebt(cwd, opts = {}) {
306
313
  const fold = foldFindings(rows);
307
314
  const byPhase = new Map();
308
315
  for (const f of fold.findings.values()) {
309
- if (milestone && f.milestone !== milestone) continue;
316
+ if (milestone && !sameMilestone(f.milestone, milestone)) continue;
310
317
  if (f.status !== 'deferred' && f.status !== 'open') continue;
311
318
  const key = V.comparablePhase(f.phase);
312
319
  if (!byPhase.has(key)) byPhase.set(key, { phase: f.phase, deferred: [], open: [] });
@@ -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
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { safeReadFile, output, error, escapeRegex } = require('./core.cjs');
7
+ const { safeReadFile, output, error, escapeRegex, toLf, dominantEol, withEol } = require('./core.cjs');
8
8
  const { FIELD_VALUE_RE, PRIORITY_LEVELS, EFFORT_SIZES } = require('./constants.cjs');
9
9
 
10
10
  // --- Inline array rendering thresholds -------------------------------------------
@@ -169,7 +169,21 @@ function renderYamlEntry(lines, key, value, depth) {
169
169
  lines.push(`${indent}${key}:`);
170
170
  const itemIndent = ' '.repeat(depth + 1);
171
171
  for (const item of value) {
172
- lines.push(`${itemIndent}- ${quoteIfNeeded(item)}`);
172
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
173
+ // A list of maps (a plan's must_haves artifacts and key_links): the first
174
+ // key on the dash line, the rest under it. Written as a scalar, each item
175
+ // came out "[object Object]".
176
+ const entries = Object.entries(item).filter(([, v]) => v !== null && v !== undefined);
177
+ entries.forEach(([k, v], idx) => {
178
+ // An array value goes out as a flow sequence of JSON strings, which the
179
+ // parser reads back as an array (`exports: ["GET", "POST"]`).
180
+ const text = Array.isArray(v) ? `[${v.map((x) => JSON.stringify(String(x))).join(', ')}]`
181
+ : quoteIfNeeded(typeof v === 'object' ? JSON.stringify(v) : v);
182
+ lines.push(`${itemIndent}${idx === 0 ? '- ' : ' '}${k}: ${text}`);
183
+ });
184
+ } else {
185
+ lines.push(`${itemIndent}- ${quoteIfNeeded(item)}`);
186
+ }
173
187
  }
174
188
  }
175
189
  } else if (typeof value === 'object') {
@@ -201,34 +215,76 @@ function quoteIfNeeded(value) {
201
215
  */
202
216
  function spliceFrontmatter(content, newObj) {
203
217
  const yamlStr = reconstructFrontmatter(newObj);
204
- const match = content.match(/^---\n[\s\S]+?\n---/);
218
+ // Match on LF and give the file back in its own ending. The LF-only match
219
+ // missed the block in a CRLF file, so `frontmatter set` and `merge` stacked a
220
+ // second frontmatter on top of the first instead of replacing it.
221
+ const eol = dominantEol(content);
222
+ const text = toLf(content);
223
+ const match = text.match(/^---\n[\s\S]+?\n---/);
205
224
  if (match) {
206
- return `---\n${yamlStr}\n---` + content.slice(match[0].length);
225
+ return withEol(`---\n${yamlStr}\n---` + text.slice(match[0].length), eol);
226
+ }
227
+ return withEol(`---\n${yamlStr}\n---\n\n` + text, eol);
228
+ }
229
+
230
+ /**
231
+ * A YAML scalar as written in a must_haves value: a double-quoted one unescapes `\\`
232
+ * and `\"`, a single-quoted one `''`, a plain one is taken as written. The raw `\\.`
233
+ * of the shipped key-link example reached `new RegExp` in `verify key-links`, so
234
+ * `pattern: "prisma\\.message\\.(find|create)"` never matched.
235
+ * @param {string} raw - The value text after `key:` (or after `- `)
236
+ * @returns {string}
237
+ */
238
+ function yamlScalar(raw) {
239
+ const s = String(raw).trim();
240
+ // A flow sequence (`exports: ["GET", "POST"]`, as the plan template writes it) is an
241
+ // array of scalars: taken whole it was one string, and `verify artifacts` then
242
+ // reported every export missing.
243
+ if (s.length >= 2 && s.startsWith('[') && s.endsWith(']')) {
244
+ const items = [];
245
+ let cur = '';
246
+ let quote = null;
247
+ const inner = s.slice(1, -1);
248
+ for (let i = 0; i < inner.length; i++) {
249
+ const c = inner[i];
250
+ if (quote) {
251
+ cur += c;
252
+ if (c === '\\' && quote === '"' && i + 1 < inner.length) cur += inner[++i];
253
+ else if (c === quote) quote = null;
254
+ } else if (c === '"' || c === "'") { quote = c; cur += c; }
255
+ else if (c === ',') { items.push(cur); cur = ''; }
256
+ else cur += c;
257
+ }
258
+ items.push(cur);
259
+ return items.map((x) => yamlScalar(x)).filter((x) => x !== '');
207
260
  }
208
- return `---\n${yamlStr}\n---\n\n` + content;
261
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\(["\\])/g, '$1');
262
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/''/g, "'");
263
+ return s;
209
264
  }
210
265
 
211
266
  /**
212
267
  * Parse a specific block (artifacts, key_links, truths) from must_haves in raw YAML frontmatter.
213
268
  *
214
- * Block structure being parsed:
215
- * The must_haves section is a 3-level nested YAML structure:
216
- * must_haves: (level 1 -- 2-space indent)
217
- * artifacts: (level 2 -- 4-space indent, the blockName)
218
- * - path: foo (level 3 -- 6-space indent, list items)
219
- * provides: bar (level 3+ -- 8-space indent, continuation k-v pairs)
269
+ * Block structure being parsed (indents are relative, never fixed columns):
270
+ * must_haves:
271
+ * artifacts: (the blockName, deeper than must_haves)
272
+ * - path: foo (list items)
273
+ * provides: bar (continuation k-v pairs, deeper than the item's dash)
220
274
  *
221
- * This function finds the named block at the 4-space level, then parses
222
- * its list items (at 6-space indent) and their nested key-value pairs
223
- * (at 8+ space indent) into an array of objects or strings.
275
+ * This function finds the named block under must_haves, then parses its list
276
+ * items and their nested key-value pairs into an array of objects or strings.
277
+ * Values go through yamlScalar.
224
278
  *
225
279
  * @param {string} content - Full markdown content with frontmatter
226
280
  * @param {string} blockName - Block name to extract (e.g., "artifacts", "key_links")
227
281
  * @returns {Array} Parsed array of block items (objects or strings)
228
282
  */
229
283
  function parseMustHavesBlock(content, blockName) {
230
- // Extract raw YAML between --- delimiters
231
- const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
284
+ // Extract raw YAML between --- delimiters. On LF: a CRLF plan (every Windows
285
+ // field project) matched nothing here, so its must_haves read as empty and the
286
+ // reconcile gate below trusted the verdict again, as it did before the indent fix.
287
+ const fmMatch = toLf(content).match(/^---\n([\s\S]+?)\n---/);
232
288
  if (!fmMatch) return [];
233
289
 
234
290
  const yaml = fmMatch[1];
@@ -290,18 +346,18 @@ function parseMustHavesBlock(content, blockName) {
290
346
 
291
347
  const body = dashMatch[2];
292
348
  // A plain string item — no `key: value` shape.
293
- const kvMatch = body.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
349
+ const kvMatch = body.match(/^(\w+):\s*(.*?)\s*$/);
294
350
  if (kvMatch) {
295
351
  currentItem = {};
296
- currentItem[kvMatch[1]] = kvMatch[2];
352
+ currentItem[kvMatch[1]] = yamlScalar(kvMatch[2]);
297
353
  } else {
298
- currentItem = body.replace(/^"|"$/g, '').trim();
354
+ currentItem = yamlScalar(body);
299
355
  }
300
356
  } else if (currentItem && typeof currentItem === 'object') {
301
357
  // Continuation key-value: deeper than the item's dash (properties of it)
302
- const kvMatch = line.match(/^\s+(\w+):\s*"?([^"]*)"?\s*$/);
358
+ const kvMatch = line.match(/^\s+(\w+):\s*(.*?)\s*$/);
303
359
  if (kvMatch) {
304
- const val = kvMatch[2];
360
+ const val = yamlScalar(kvMatch[2]);
305
361
  // Coerce pure-integer strings to numbers for convenience
306
362
  currentItem[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
307
363
  }
@@ -309,7 +365,7 @@ function parseMustHavesBlock(content, blockName) {
309
365
  // already deeper than the list-item dash (a shallower one would have been
310
366
  // treated as a new item above), so relative depth alone identifies it —
311
367
  // no fixed column.
312
- const arrMatch = line.match(/^\s+-\s+"?([^"]+)"?\s*$/);
368
+ const arrMatch = line.match(/^\s+-\s+(.+?)\s*$/);
313
369
  if (arrMatch) {
314
370
  // Convert the most recently added key's scalar value into an array,
315
371
  // then append this item to that array
@@ -318,7 +374,7 @@ function parseMustHavesBlock(content, blockName) {
318
374
  if (lastKey && !Array.isArray(currentItem[lastKey])) {
319
375
  currentItem[lastKey] = currentItem[lastKey] ? [currentItem[lastKey]] : [];
320
376
  }
321
- if (lastKey) currentItem[lastKey].push(arrMatch[1]);
377
+ if (lastKey) currentItem[lastKey].push(yamlScalar(arrMatch[1]));
322
378
  }
323
379
  }
324
380
  }
@@ -354,6 +410,27 @@ const FRONTMATTER_SCHEMAS = {
354
410
  verification: { required: ['phase', 'verified', 'status', 'score'] },
355
411
  };
356
412
 
413
+ /**
414
+ * extractFrontmatter cannot read a list of maps: each must_haves artifact and key
415
+ * link came back as the string of its first line (`path: "src/x.ts`), so the plan
416
+ * checker and verify-phase, which read them through `frontmatter get`, got no
417
+ * objects. parseMustHavesBlock reads both blocks by relative indent.
418
+ * @param {object} fm - extractFrontmatter output
419
+ * @param {string} content - the file it came from
420
+ * @returns {object} fm, with must_haves.artifacts and .key_links as objects
421
+ */
422
+ function withParsedMustHaves(fm, content) {
423
+ const mh = fm && fm.must_haves;
424
+ if (!mh || typeof mh !== 'object' || Array.isArray(mh)) return fm;
425
+ const fixed = { ...mh };
426
+ for (const block of ['artifacts', 'key_links']) {
427
+ if (!(block in fixed)) continue;
428
+ const parsed = parseMustHavesBlock(content, block);
429
+ if (parsed.length) fixed[block] = parsed;
430
+ }
431
+ return { ...fm, must_haves: fixed };
432
+ }
433
+
357
434
  /**
358
435
  * Get frontmatter from a file, optionally filtered to a single field.
359
436
  * @param {string} cwd - Working directory path
@@ -367,7 +444,7 @@ function cmdFrontmatterGet(cwd, filePath, field, raw) {
367
444
  const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
368
445
  const content = safeReadFile(fullPath);
369
446
  if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
370
- const fm = extractFrontmatter(content);
447
+ const fm = withParsedMustHaves(extractFrontmatter(content), content);
371
448
  if (field) {
372
449
  const value = fm[field];
373
450
  if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
@@ -395,7 +472,9 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
395
472
  } catch {
396
473
  output({ error: 'File not found', path: filePath }, raw); return;
397
474
  }
398
- const fm = extractFrontmatter(content);
475
+ // The block is rewritten whole, so a plan's must_haves lists of maps must be read
476
+ // as maps: extractFrontmatter alone flattened each item to its first line.
477
+ const fm = withParsedMustHaves(extractFrontmatter(content), content);
399
478
  let parsedValue;
400
479
  // Attempt JSON parse so callers can pass structured values (arrays, objects);
401
480
  // fall back to raw string if the value is not valid JSON
@@ -427,7 +506,7 @@ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
427
506
  } catch {
428
507
  output({ error: 'File not found', path: filePath }, raw); return;
429
508
  }
430
- const fm = extractFrontmatter(content);
509
+ const fm = withParsedMustHaves(extractFrontmatter(content), content);
431
510
  let mergeData;
432
511
  // Parse the JSON data string; abort with error if the caller passed invalid JSON
433
512
  try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }