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
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * roadmap compact — move shipped phases' detail out of roadmap.md (memory optimisation O3).
5
+ *
6
+ * roadmap.md only grows: every phase adds a detail section (goal, success criteria,
7
+ * plan list, notes) and nothing ever takes one away. A 54-phase field project's
8
+ * roadmap reached 476 KB, about 121k tokens. Phase agents now read a slice of it
9
+ * (`roadmap slice`, O2), but the roadmapper, the milestone workflows and people
10
+ * still read it whole. This bounds it the way `state compact` bounds state.md
11
+ * (ADR-0044).
12
+ *
13
+ * Safety, in order of importance:
14
+ * 1. Nothing is deleted. Sections are appended to roadmap-history.md FIRST, and
15
+ * only then shortened in roadmap.md, so an interruption can only duplicate.
16
+ * 2. Only a shipped phase moves: its checklist line is ticked, it is not the
17
+ * current phase in state.md, and it is not one of the `keep` most recently
18
+ * shipped phases (default 2), which the next phase's planning may lean on.
19
+ * 3. A stub stays: the heading, the Goal, Depends on and Requirements lines and
20
+ * any `<!-- model_tier: … -->` override, so goal lookups, the dependency goals
21
+ * in a slice and `roadmap analyze` still read the phase. Readers that need the
22
+ * rest (`roadmap get-phase`, `phase report`, a slice of that phase) find the
23
+ * stub and read the section from roadmap-history.md.
24
+ * 4. A section already compacted is never compacted again.
25
+ * 5. Dry-run by default; `--apply` writes. Declines when nothing would shrink.
26
+ * 6. Parsed on LF and written back in the file's own line ending.
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { ROADMAP_FILE, STATE_FILE, CHARS_PER_TOKEN } = require('./constants.cjs');
32
+ const { planningPath, planningRel } = require('./utils.cjs');
33
+ const { output, safeReadFile, toLf, dominantEol, withEol } = require('./core.cjs');
34
+
35
+ const ROADMAP_HISTORY_FILE = 'roadmap-history.md';
36
+ const ROADMAP_COMPACT_KEEP = 2;
37
+ /** The line a stub carries in place of the section's body. */
38
+ const STUB_MARK = '_Shipped; the full section is in [roadmap-history.md]';
39
+
40
+ const PHASE_HEADING_RE = /^(#{2,4})\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*(.+?)\s*$/;
41
+ const KEPT_LINE_RE = /^(?:\*\*(Goal|Depends on|Requirements)(?::\*\*|\*\*:)|<!--\s*(model_tier):)/i;
42
+ /** The line each compaction writes into the history ahead of the sections it moved. */
43
+ const HISTORY_BATCH_MARK = `<!-- compacted from ${ROADMAP_FILE} on `;
44
+ const unpad = (n) => String(n).replace(/^0+(?=\d)/, '');
45
+
46
+ /**
47
+ * The phase detail sections of roadmap.md (LF text): each runs from its heading to
48
+ * the next phase heading or the next heading at its level or above, or to an HTML
49
+ * block boundary such as a milestone's `</details>`, so a trailing progress table
50
+ * never rides along. In roadmap-history.md a section also ends at the next
51
+ * compaction's batch line. Trailing blank lines stay outside the section.
52
+ */
53
+ function phaseSections(lines) {
54
+ const out = [];
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const m = lines[i].match(PHASE_HEADING_RE);
57
+ if (!m) continue;
58
+ const level = m[1].length;
59
+ let end = i + 1;
60
+ for (; end < lines.length; end++) {
61
+ const h = lines[end].match(/^(#{1,6})\s/);
62
+ if (h && (h[1].length <= level || PHASE_HEADING_RE.test(lines[end]))) break;
63
+ if (/^\s*<\/?details\b/i.test(lines[end])) break;
64
+ if (lines[end].startsWith(HISTORY_BATCH_MARK)) break;
65
+ }
66
+ let last = end;
67
+ while (last > i + 1 && !lines[last - 1].trim()) last--;
68
+ out.push({ start: i, end: last, number: unpad(m[2]), title: m[3], heading: lines[i] });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Phase numbers whose checklist line is ticked (`- [x] **Phase 3: …`). */
74
+ function shippedPhases(lines) {
75
+ const s = new Set();
76
+ for (const l of lines) {
77
+ const m = l.match(/^\s*-\s*\[[xX]\]\s*\*{0,2}Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/);
78
+ if (m) s.add(unpad(m[1]));
79
+ }
80
+ return s;
81
+ }
82
+
83
+ /** The current phase named in state.md, or null. */
84
+ function currentPhase(stateText) {
85
+ const t = toLf(stateText || '');
86
+ const m = t.match(/\*\*Current Phase:\*\*\s*([0-9]+[A-Z]?(?:\.[0-9]+)*)/i) || t.match(/^current_phase:\s*"?([0-9]+[A-Z]?(?:\.[0-9]+)*)/m);
87
+ return m ? unpad(m[1]) : null;
88
+ }
89
+
90
+ /** The stub left in roadmap.md for an archived section. */
91
+ function stubFor(sectionLines, stamp) {
92
+ const kept = [];
93
+ const seen = new Set();
94
+ for (const l of sectionLines.slice(1)) {
95
+ const m = l.match(KEPT_LINE_RE);
96
+ const key = m && (m[1] || m[2]).toLowerCase();
97
+ if (m && !seen.has(key)) { seen.add(key); kept.push(l); }
98
+ }
99
+ return [sectionLines[0], ...kept, '', `${STUB_MARK}(${ROADMAP_HISTORY_FILE}) (compacted ${stamp})._`];
100
+ }
101
+
102
+ /**
103
+ * Pure: plan (and build) a compaction of roadmap.md text.
104
+ * @param {string} roadmap - roadmap.md content
105
+ * @param {{keep?: number, current?: string|null, now?: number}} [opts]
106
+ * @returns {{sections: Array, archivable: Array, content: string, archivedText: string, bytes_before: number, bytes_after: number}}
107
+ */
108
+ function planRoadmapCompaction(roadmap, opts = {}) {
109
+ const keep = Number.isInteger(opts.keep) && opts.keep >= 0 ? opts.keep : ROADMAP_COMPACT_KEEP;
110
+ const eol = dominantEol(roadmap);
111
+ const lines = toLf(roadmap).split('\n');
112
+ const shipped = shippedPhases(lines);
113
+ const sections = phaseSections(lines);
114
+ const stamp = new Date(opts.now || Date.now()).toISOString().slice(0, 10);
115
+
116
+ const isStub = (s) => lines.slice(s.start, s.end).some(l => l.startsWith(STUB_MARK));
117
+ const candidates = sections.filter(s => shipped.has(s.number) && !isStub(s) && s.number !== opts.current);
118
+ const keepSet = new Set(candidates.slice(Math.max(0, candidates.length - keep)).map(s => s.start));
119
+
120
+ const verdicts = sections.map(s => {
121
+ let archive = false;
122
+ let reason;
123
+ if (isStub(s)) reason = 'already compacted';
124
+ else if (!shipped.has(s.number)) reason = 'not shipped — its checklist line is not ticked';
125
+ else if (s.number === opts.current) reason = 'the current phase in state.md';
126
+ else if (keepSet.has(s.start)) reason = `one of the ${keep} most recently shipped phases`;
127
+ else { archive = true; reason = 'shipped'; }
128
+ return { phase: s.number, title: s.title, bytes: lines.slice(s.start, s.end).join('\n').length, archive, reason, start: s.start, end: s.end };
129
+ });
130
+
131
+ const toArchive = verdicts.filter(v => v.archive);
132
+ const next = [];
133
+ let cursor = 0;
134
+ for (const v of toArchive) {
135
+ next.push(...lines.slice(cursor, v.start), ...stubFor(lines.slice(v.start, v.end), stamp));
136
+ cursor = v.end;
137
+ }
138
+ next.push(...lines.slice(cursor));
139
+ const content = withEol(next.join('\n'), eol);
140
+ const archivedText = toArchive.map(v => lines.slice(v.start, v.end).join('\n')).join('\n\n');
141
+
142
+ let archivable = toArchive.map(({ start, end, ...rest }) => rest);
143
+ let after = content.length;
144
+ // A stub on a tiny section can cost more than the section: decline rather than
145
+ // churn roadmap.md to make it bigger.
146
+ if (archivable.length && after >= roadmap.length) {
147
+ for (const v of verdicts) if (v.archive) { v.archive = false; v.reason = 'archiving it would not shrink roadmap.md'; }
148
+ archivable = [];
149
+ after = roadmap.length;
150
+ }
151
+ return {
152
+ sections: verdicts.map(({ start, end, ...rest }) => rest),
153
+ archivable,
154
+ content: archivable.length ? content : roadmap,
155
+ archivedText: archivable.length ? archivedText : '',
156
+ bytes_before: roadmap.length,
157
+ bytes_after: after,
158
+ stamp,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Plan or apply `roadmap compact` for a project. Archive is written BEFORE
164
+ * roadmap.md is rewritten, so an interruption can only ever leave a duplicate.
165
+ * @param {string} cwd
166
+ * @param {{apply?: boolean, keep?: number, now?: number}} [opts]
167
+ */
168
+ function compactRoadmap(cwd, opts = {}) {
169
+ const roadmapPath = planningPath(cwd, ROADMAP_FILE);
170
+ const roadmap = safeReadFile(roadmapPath);
171
+ if (roadmap == null) return { found: false, path: planningRel(ROADMAP_FILE), archivable: [], applied: false };
172
+ const current = currentPhase(safeReadFile(planningPath(cwd, STATE_FILE)));
173
+ const plan = planRoadmapCompaction(roadmap, { keep: opts.keep, current, now: opts.now });
174
+ const result = {
175
+ found: true,
176
+ path: planningRel(ROADMAP_FILE),
177
+ history_path: planningRel(ROADMAP_HISTORY_FILE),
178
+ keep: Number.isInteger(opts.keep) && opts.keep >= 0 ? opts.keep : ROADMAP_COMPACT_KEEP,
179
+ current_phase: current,
180
+ sections: plan.sections,
181
+ archivable: plan.archivable,
182
+ bytes_before: plan.bytes_before,
183
+ bytes_after: plan.bytes_after,
184
+ tokens_before: Math.ceil(plan.bytes_before / CHARS_PER_TOKEN),
185
+ tokens_after: Math.ceil(plan.bytes_after / CHARS_PER_TOKEN),
186
+ applied: false,
187
+ dry_run: !opts.apply,
188
+ };
189
+ if (!opts.apply || !plan.archivable.length) return result;
190
+
191
+ // 1. History first. The preamble is created with an exclusive open (no
192
+ // check-then-write race); appends follow the history file's own line ending.
193
+ const historyPath = planningPath(cwd, ROADMAP_HISTORY_FILE);
194
+ const preamble = `# Roadmap history\n\nPhase sections compacted out of ${ROADMAP_FILE} by \`pan-tools roadmap compact\`.\n`
195
+ + 'They are kept verbatim; roadmap.md keeps a stub with each heading, goal, dependencies and requirements.\n';
196
+ try {
197
+ fs.writeFileSync(historyPath, preamble, { flag: 'wx', encoding: 'utf-8' });
198
+ } catch (e) {
199
+ if (e.code !== 'EEXIST') throw e;
200
+ }
201
+ const histEol = dominantEol(safeReadFile(historyPath) || '');
202
+ fs.appendFileSync(historyPath, withEol(`\n\n${HISTORY_BATCH_MARK}${plan.stamp} -->\n${plan.archivedText}\n`, histEol), 'utf-8');
203
+
204
+ // 2. Only now shorten roadmap.md.
205
+ fs.writeFileSync(roadmapPath, plan.content, 'utf-8');
206
+ return { ...result, applied: true, dry_run: false, archived: plan.archivable.map(v => v.phase) };
207
+ }
208
+
209
+ /** True when a phase section (LF or CRLF text) is a compaction stub. */
210
+ function isCompactedSection(section) {
211
+ return String(section || '').split('\n').some(l => l.trimStart().startsWith(STUB_MARK));
212
+ }
213
+
214
+ /**
215
+ * A compacted phase's full section from roadmap-history.md text, or null. The last
216
+ * copy wins: each compaction appends.
217
+ */
218
+ function archivedPhaseSection(historyText, phase) {
219
+ if (!historyText) return null;
220
+ const want = unpad(phase);
221
+ const lines = toLf(historyText).split('\n');
222
+ let hit = null;
223
+ for (const s of phaseSections(lines)) if (s.number === want) hit = s;
224
+ return hit ? lines.slice(hit.start, hit.end).join('\n') : null;
225
+ }
226
+
227
+ /**
228
+ * For a reader that found a stub where a phase's section should be: the full
229
+ * section from roadmap-history.md. Returns null when `section` is not a stub, so
230
+ * the history is read only for a compacted phase.
231
+ * @returns {{section: string|null, compacted: true, history_path: string, history_found: boolean}|null}
232
+ */
233
+ function compactedPhaseSection(cwd, phase, section) {
234
+ if (!isCompactedSection(section)) return null;
235
+ const full = archivedPhaseSection(safeReadFile(planningPath(cwd, ROADMAP_HISTORY_FILE)), phase);
236
+ return { section: full, compacted: true, history_path: planningRel(ROADMAP_HISTORY_FILE), history_found: full != null };
237
+ }
238
+
239
+ /** How many sections a compaction would archive now — hygiene offers the fix only then. */
240
+ function roadmapCompactionAvailable(cwd) {
241
+ try { return compactRoadmap(cwd).archivable.length; } catch { return 0; }
242
+ }
243
+
244
+ /** `roadmap compact [--apply] [--keep N]` */
245
+ function cmdRoadmapCompact(cwd, opts, raw) {
246
+ const r = compactRoadmap(cwd, opts);
247
+ if (!r.found) { output(r, raw, 'roadmap.md not found'); return; }
248
+ const saved = Math.max(0, r.tokens_before - r.tokens_after);
249
+ const summary = r.applied
250
+ ? `archived ${r.archived.length} phase section(s) to ${r.history_path}; roadmap.md ~${saved} tokens smaller`
251
+ : r.archivable.length
252
+ ? `would archive ${r.archivable.length} phase section(s) (phases ${r.archivable.map(v => v.phase).join(', ')}); roadmap.md ~${saved} tokens smaller — rerun with --apply`
253
+ : 'nothing to compact — no shipped phase past the most recent ones';
254
+ output(r, raw, summary);
255
+ }
256
+
257
+ module.exports = {
258
+ ROADMAP_HISTORY_FILE,
259
+ ROADMAP_COMPACT_KEEP,
260
+ isCompactedSection,
261
+ archivedPhaseSection,
262
+ compactedPhaseSection,
263
+ planRoadmapCompaction,
264
+ compactRoadmap,
265
+ roadmapCompactionAvailable,
266
+ cmdRoadmapCompact,
267
+ };
@@ -4,9 +4,9 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isContextFile, isResearchFile, PHASE_HEADER_RE, getPlanId, getSummaryId } = require('./constants.cjs');
8
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus } = require('./utils.cjs');
9
- const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs');
7
+ const { PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, ROADMAP_SLICE_SUFFIX, isPlanFile, isSummaryFile, isContextFile, isResearchFile, PHASE_HEADER_RE, getPlanId, getSummaryId } = require('./constants.cjs');
8
+ const { planningPath, planningRel, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus } = require('./utils.cjs');
9
+ const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, safeReadFile, toLf, dominantEol, withEol } = require('./core.cjs');
10
10
 
11
11
  /**
12
12
  * Extract a single phase section from roadmap.md including goal and success criteria.
@@ -21,8 +21,8 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
21
21
  try {
22
22
  const content = fs.readFileSync(roadmapPath, 'utf-8');
23
23
 
24
- // Escape special regex chars in phase number, handle decimal
25
- const escapedPhase = escapeRegex(phaseNum);
24
+ // Padded or not: `init` hands workflows "05", roadmaps write "Phase 5:".
25
+ const escapedPhase = phaseNumRe(phaseNum);
26
26
 
27
27
  // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
28
28
  const phasePattern = new RegExp(
@@ -65,7 +65,12 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
65
65
  ? headerIndex + nextHeaderMatch.index
66
66
  : content.length;
67
67
 
68
- const section = content.slice(headerIndex, sectionEnd).trim();
68
+ let section = content.slice(headerIndex, sectionEnd).trim();
69
+
70
+ // A phase moved out by `roadmap compact` leaves a stub here; its success
71
+ // criteria and the rest of its section are in roadmap-history.md.
72
+ const compacted = require('./roadmap-compact.cjs').compactedPhaseSection(cwd, phaseNum, section);
73
+ if (compacted && compacted.section) section = compacted.section;
69
74
 
70
75
  // Extract goal if present
71
76
  const goalMatch = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
@@ -85,6 +90,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
85
90
  goal,
86
91
  success_criteria,
87
92
  section,
93
+ ...(compacted ? { compacted: true, history_path: compacted.history_path, history_found: compacted.history_found } : {}),
88
94
  },
89
95
  raw,
90
96
  section
@@ -346,7 +352,7 @@ const isSeparator = (line) => /^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(line);
346
352
  function updateProgressTableRow(content, phaseNum, values) {
347
353
  const eol = content.includes('\r\n') ? '\r\n' : '\n';
348
354
  const lines = content.split(/\r?\n/);
349
- const phaseRe = new RegExp(`^(?:phase\\s+)?${escapeRegex(String(phaseNum))}\\.?(?=\\s|:|$)`, 'i');
355
+ const phaseRe = new RegExp(`^(?:phase\\s+)?${phaseNumRe(phaseNum)}\\.?(?=\\s|:|$)`, 'i');
350
356
  let reason = null;
351
357
  for (let i = 0; i + 1 < lines.length; i++) {
352
358
  if (!isTableLine(lines[i]) || !isSeparator(lines[i + 1])) continue;
@@ -377,7 +383,7 @@ function updateProgressTableRow(content, phaseNum, values) {
377
383
  /** Rewrite `**Plans:**` inside the phase's own section only. Pure. */
378
384
  function updatePhasePlansLine(content, phaseNum, text) {
379
385
  const lines = content.split(/(?<=\n)/);
380
- const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${escapeRegex(String(phaseNum))}(?=[\\s:.]|$)`, 'i');
386
+ const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${phaseNumRe(phaseNum)}(?=[\\s:.]|$)`, 'i');
381
387
  const start = lines.findIndex((l) => headRe.test(l));
382
388
  if (start < 0) return { content, updated: false };
383
389
  const level = lines[start].match(headRe)[1].length;
@@ -426,7 +432,9 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
426
432
  output({ updated: false, reason: 'roadmap.md not found', error: 'roadmap_not_found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
427
433
  return;
428
434
  }
429
- const phaseEscaped = escapeRegex(phaseNum);
435
+ // Padded or not: the executor passes init's "05" for a `Phase 5` roadmap, and
436
+ // every lookup below used to miss it while the result still said `updated: true`.
437
+ const phaseEscaped = phaseNumRe(phaseNum);
430
438
 
431
439
  // Progress table row: the Plans, Status and Completed cells, found by header.
432
440
  const table = updateProgressTableRow(roadmapContent, phaseNum, {
@@ -530,14 +538,240 @@ function syncRequirementCheckboxes(cwd, requirementIds) {
530
538
  return { updated: checked > 0, checked, total: requirementIds.length };
531
539
  }
532
540
 
541
+ // ─── Phase slice (memory optimisation O2) ────────────────────────────────────
542
+ //
543
+ // What work on ONE phase needs from roadmap.md and requirements.md: a one-line
544
+ // index of every phase, this phase's section, the goals of the phases it depends
545
+ // on, and the requirement lines it names. The planner, the plan checker, the
546
+ // researcher, the executors (through the plan's <context>) and the verifier read
547
+ // this instead of the whole files. Measured on the field installs (2026-10-04):
548
+ // 89–98% smaller; a 54-phase project went from ~146k tokens to ~3k, and those
549
+ // whole files had been re-read on every turn of every spawn that listed them.
550
+
551
+ /** Phase numbers compare without zero padding: "03" and "3" are one phase. */
552
+ const unpadPhase = (n) => String(n).trim().replace(/^0+(?=\d)/, '');
553
+
554
+ /** A phase number as a regex source matching it padded or not ("05" finds `Phase 5:` and `Phase 05:`). */
555
+ const phaseNumRe = (n) => `0*${escapeRegex(unpadPhase(n))}`;
556
+
557
+ /**
558
+ * Requirement ids on a phase section's `**Requirements**:` line. Taken by shape,
559
+ * not by splitting on commas: field roadmaps write prose there ("none of its own;
560
+ * pulled forward from Phase 45 (`CI-01`)…", "seeding (replace …), TEST-01 (…)").
561
+ * An id is a `CAT-01`-style token anywhere on the line, or a comma-separated item
562
+ * that is a single token carrying a digit (`R1`, `REQ001`).
563
+ */
564
+ function phaseRequirementIds(section) {
565
+ const m = String(section || '').match(/^\*\*Requirements\*\*:[^\S\n]*([^\n]*)$/m)
566
+ || String(section || '').match(/^\*\*Requirements:\*\*[^\S\n]*([^\n]*)$/m);
567
+ if (!m) return [];
568
+ const line = m[1].replace(/[[\]`]/g, ' ');
569
+ const shaped = line.match(/\b[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+[A-Za-z]?\b/g) || [];
570
+ const tokens = line.split(',').map(s => s.trim()).filter(s => /^[A-Za-z][A-Za-z0-9._-]*\d[A-Za-z0-9._-]*$/.test(s));
571
+ return [...new Set([...shaped, ...tokens])];
572
+ }
573
+
574
+ /** Phase numbers on a section's `**Depends on**:` line ("Phase 2, Phase 2.1" → ['2', '2.1']). */
575
+ function phaseDependencies(section) {
576
+ const m = String(section || '').match(/(?:\*\*Depends on:\*\*|\*\*Depends on\*\*:)\s*([^\n]+)/i);
577
+ if (!m) return [];
578
+ return [...new Set([...m[1].matchAll(/\bPhase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)].map(x => unpadPhase(x[1])))];
579
+ }
580
+
581
+ /** A phase's section in roadmap.md content, or null. Mirrors getRoadmapPhaseInternal. */
582
+ function roadmapPhaseSection(content, phaseNum) {
583
+ const escaped = escapeRegex(unpadPhase(phaseNum));
584
+ const header = content.match(new RegExp(`#{2,4}\\s*Phase\\s+0*${escaped}:\\s*([^\\n]+)`, 'i'));
585
+ if (!header) return null;
586
+ const rest = content.slice(header.index);
587
+ const next = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
588
+ const section = content.slice(header.index, next ? header.index + next.index : content.length).trim();
589
+ return { name: header[1].trim(), section };
590
+ }
591
+
592
+ /**
593
+ * The requirement ids roadmap.md names for one phase: from its own `### Phase N:`
594
+ * section, else from a `**Requirements:**` line under its checklist entry, before
595
+ * the next "Phase <n>" (the layout older roadmaps use). `phase complete` and
596
+ * `validate health --repair` read them here: their own search took the first
597
+ * Requirements line after the first "Phase N" anywhere, which on the template's
598
+ * layout (checklist above the sections) was phase 1's, and matched "05" to nothing.
599
+ */
600
+ function roadmapRequirementIds(content, phaseNum) {
601
+ const text = String(content || '');
602
+ const sec = roadmapPhaseSection(text, phaseNum);
603
+ if (sec) return phaseRequirementIds(sec.section);
604
+ const start = text.search(new RegExp(`Phase\\s+${phaseNumRe(phaseNum)}(?![\\d.])`, 'i'));
605
+ if (start < 0) return [];
606
+ const rest = text.slice(start);
607
+ const next = rest.slice(1).search(/Phase\s+\d/i);
608
+ const span = next < 0 ? rest : rest.slice(0, next + 1);
609
+ return phaseRequirementIds(span.replace(/^[ \t]+(?=\*\*Requirements)/gm, ''));
610
+ }
611
+
612
+ /** Lines of requirements.md that name one of `ids`, each with its indented continuation. */
613
+ function requirementLines(requirements, ids) {
614
+ const lines = String(requirements || '').split('\n');
615
+ const pick = new Set();
616
+ const found = new Set();
617
+ const patterns = ids.map(id => ({ id, re: new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegex(id)}(?![A-Za-z0-9_-])`) }));
618
+ lines.forEach((line, i) => {
619
+ const hits = patterns.filter(p => p.re.test(line));
620
+ if (!hits.length) return;
621
+ for (const h of hits) found.add(h.id);
622
+ pick.add(i);
623
+ // A list item keeps its detail: the lines after it indented deeper than it.
624
+ if (/^\s*[-*]\s/.test(line)) {
625
+ const indent = line.length - line.trimStart().length;
626
+ for (let j = i + 1; j < lines.length; j++) {
627
+ const l = lines[j];
628
+ if (!l.trim() || l.length - l.trimStart().length <= indent) break;
629
+ pick.add(j);
630
+ }
631
+ }
632
+ });
633
+ // A picked table row keeps its table's header and separator, so it still reads as a table.
634
+ for (const i of [...pick]) {
635
+ if (!/^\s*\|/.test(lines[i])) continue;
636
+ let top = i;
637
+ while (top > 0 && /^\s*\|/.test(lines[top - 1])) top--;
638
+ if (top < i) pick.add(top);
639
+ if (top + 1 < i && /^\s*\|[\s:|-]+\|\s*$/.test(lines[top + 1])) pick.add(top + 1);
640
+ }
641
+ return { lines: [...pick].sort((a, b) => a - b).map(i => lines[i]), missing: ids.filter(id => !found.has(id)) };
642
+ }
643
+
644
+ /**
645
+ * Pure: the slice of `roadmap` and `requirements` for one phase.
646
+ * `history` (roadmap-history.md text, or a function returning it) is consulted only
647
+ * when the phase's section is a `roadmap compact` stub, to put its full section back.
648
+ * @param {{roadmap: string, requirements: string|null, phase: string, planningRel?: string,
649
+ * history?: string|null|(() => string|null)}} args
650
+ * @returns {{found: boolean, phase_number: string, phase_name?: string, content?: string,
651
+ * requirement_ids?: string[], missing_requirement_ids?: string[], depends_on?: string[],
652
+ * tokens?: number, whole_tokens?: number}}
653
+ */
654
+ function buildRoadmapSlice({ roadmap, requirements, phase, planningRel = '.planning', history = null }) {
655
+ const rm = toLf(roadmap);
656
+ const req = requirements == null ? null : toLf(requirements);
657
+ const target = roadmapPhaseSection(rm, phase);
658
+ if (!target) return { found: false, phase_number: String(phase) };
659
+ const { isCompactedSection, archivedPhaseSection } = require('./roadmap-compact.cjs');
660
+ if (isCompactedSection(target.section)) {
661
+ const full = archivedPhaseSection(typeof history === 'function' ? history() : history, phase);
662
+ if (full) target.section = full;
663
+ }
664
+
665
+ const num = unpadPhase(phase);
666
+ const phases = enumerateRoadmapPhases(rm);
667
+ // Any one-character box: roadmaps mark work in flight `[~]` or `[-]` as well as `[ ]` / `[x]`.
668
+ const checklist = rm.split('\n').filter(l => /^\s*-\s*\[[^\]]\]\s*\*\*Phase\s+\d/i.test(l)).map(l => l.trim());
669
+ const thisLine = new RegExp(`\\*\\*Phase\\s+0*${escapeRegex(num)}\\s*:`, 'i');
670
+ const index = (checklist.length ? checklist : phases.map(p => `- Phase ${p.number}: ${p.name}`))
671
+ .map(l => (thisLine.test(l) || new RegExp(`^- Phase 0*${escapeRegex(num)}:`).test(l) ? `${l} ← this phase` : l));
672
+
673
+ const deps = phaseDependencies(target.section);
674
+ const depLines = deps.map(d => {
675
+ const p = phases.find(x => unpadPhase(x.number) === d);
676
+ return p ? `- **Phase ${p.number}: ${p.name}** — ${p.goal || 'no goal line in roadmap.md'}` : `- **Phase ${d}** — not found in roadmap.md`;
677
+ });
678
+
679
+ const ids = phaseRequirementIds(target.section);
680
+ let reqBody;
681
+ let missing = [];
682
+ if (req == null) reqBody = `_There is no \`${planningRel}/requirements.md\`._`;
683
+ else if (!ids.length) reqBody = '_The roadmap names no requirement ids for this phase._';
684
+ else {
685
+ const r = requirementLines(req, ids);
686
+ missing = r.missing;
687
+ reqBody = (r.lines.length ? r.lines.join('\n') : '') + (missing.length ? `${r.lines.length ? '\n\n' : ''}_Not found in requirements.md: ${missing.join(', ')}._` : '');
688
+ }
689
+
690
+ const content = [
691
+ `# Roadmap slice — Phase ${num}: ${target.name}`,
692
+ '',
693
+ `Generated by \`pan-tools roadmap slice ${num}\` from \`${planningRel}/roadmap.md\` and \`${planningRel}/requirements.md\`. It holds what work on this phase needs from those files; read the whole files only for something it leaves out.`,
694
+ '',
695
+ '## Every phase',
696
+ '',
697
+ ...index,
698
+ '',
699
+ '## This phase',
700
+ '',
701
+ target.section,
702
+ '',
703
+ '## Phases it depends on',
704
+ '',
705
+ ...(depLines.length ? depLines : ['_None._']),
706
+ '',
707
+ '## Its requirements',
708
+ '',
709
+ reqBody,
710
+ '',
711
+ ].join('\n');
712
+
713
+ return {
714
+ found: true,
715
+ phase_number: num,
716
+ phase_name: target.name,
717
+ requirement_ids: ids,
718
+ missing_requirement_ids: missing,
719
+ depends_on: deps,
720
+ content,
721
+ tokens: Math.ceil(content.length / 4),
722
+ whole_tokens: Math.ceil((rm.length + (req || '').length) / 4),
723
+ };
724
+ }
725
+
726
+ /**
727
+ * `roadmap slice <phase> [--write]` — the phase's slice; `--write` puts it at
728
+ * `<phase dir>/<NN>-roadmap-slice.md` (the path the plan template's <context> names)
729
+ * and prints the path and sizes.
730
+ */
731
+ function cmdRoadmapSlice(cwd, phase, opts, raw) {
732
+ const roadmap = safeReadFile(path.join(planningPath(cwd), ROADMAP_FILE));
733
+ if (roadmap == null) { output({ found: false, error: 'roadmap.md not found' }, raw, ''); return; }
734
+ const requirements = safeReadFile(path.join(planningPath(cwd), REQUIREMENTS_FILE));
735
+ const history = () => safeReadFile(path.join(planningPath(cwd), require('./roadmap-compact.cjs').ROADMAP_HISTORY_FILE));
736
+ const slice = buildRoadmapSlice({ roadmap, requirements, phase, planningRel: planningRel(), history });
737
+ if (!slice.found) { output(slice, raw, ''); return; }
738
+ const summary = {
739
+ found: true, phase_number: slice.phase_number, phase_name: slice.phase_name,
740
+ requirement_ids: slice.requirement_ids, missing_requirement_ids: slice.missing_requirement_ids,
741
+ depends_on: slice.depends_on, tokens: slice.tokens, whole_tokens: slice.whole_tokens,
742
+ smaller_pct: slice.whole_tokens ? Math.round(100 * (1 - slice.tokens / slice.whole_tokens)) : 0,
743
+ };
744
+ if (!opts || !opts.write) { output({ ...summary, content: slice.content }, raw, slice.content); return; }
745
+
746
+ const dir = findPhaseInternal(cwd, phase);
747
+ if (!dir || !dir.directory) {
748
+ output({ ...summary, written: false, reason: 'no phase directory yet — create it first, or read the whole files' }, raw, '');
749
+ return;
750
+ }
751
+ const rel = `${dir.directory}/${normalizePhaseName(phase)}${ROADMAP_SLICE_SUFFIX}`;
752
+ const abs = path.join(cwd, rel);
753
+ // Same bytes in the file's own line ending, and no write when nothing changed,
754
+ // so regenerating at each workflow start leaves git alone.
755
+ const existing = safeReadFile(abs);
756
+ const next = withEol(slice.content, existing == null ? '\n' : dominantEol(existing));
757
+ if (existing !== next) fs.writeFileSync(abs, next, 'utf-8');
758
+ output({ ...summary, written: true, changed: existing !== next, path: rel }, raw, rel);
759
+ }
760
+
533
761
  module.exports = {
534
762
  cmdRoadmapGetPhase,
535
763
  cmdRoadmapAnalyze,
536
764
  cmdRoadmapUpdatePlanProgress,
765
+ cmdRoadmapSlice,
537
766
  syncRequirementCheckboxes,
538
767
  // Exported for testability
539
768
  enumerateRoadmapPhases,
540
769
  enrichPhaseWithDiskStatus,
541
770
  extractMilestones,
542
771
  computeRoadmapStats,
772
+ buildRoadmapSlice,
773
+ roadmapRequirementIds,
774
+ phaseRequirementIds,
775
+ phaseDependencies,
776
+ ROADMAP_SLICE_SUFFIX,
543
777
  };
@@ -38,7 +38,8 @@ const { getExperimentManifest, PAN_EXPERIMENTS_ROOT_DEFAULT } = require('./exper
38
38
  * shell: 'win32' — they default to direct spawn, which suits test mocks like
39
39
  * `node -e '...'` that are resolvable directly. P-102 fix (v3.7.1).
40
40
  *
41
- * GitHub Copilot CLI has no documented headless prompt mode, so it's null.
41
+ * GitHub Copilot CLI is null: no adapter is written yet, although `copilot -p`
42
+ * runs a prompt headless (the PAN Harness drives it that way).
42
43
  */
43
44
  // P-1302 fix (v3.7.2): autonomous claude/gemini runs default to non-interactive
44
45
  // permissions. Without these flags, the CLI prompts for tool approval, which
@@ -26,7 +26,7 @@
26
26
 
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
- const { output, error } = require('./core.cjs');
29
+ const { output, error, dominantEol, withEol } = require('./core.cjs');
30
30
  const {
31
31
  STATE_FILE,
32
32
  STATE_HISTORY_FILE,
@@ -210,9 +210,13 @@ function planStateCompaction(cwd, opts = {}) {
210
210
  // Build the post-compaction text for real rather than estimating it. An
211
211
  // estimate that ignores pointer overhead can claim a saving on a file where
212
212
  // the pointers cost more than the sections they replace — a dry-run that
213
- // overstates its own benefit is worse than no dry-run.
213
+ // overstates its own benefit is worse than no dry-run. The rebuild comes back
214
+ // LF and writeStateMd restores the file's own ending on disk, so measure it in
215
+ // that ending too: on a CRLF file the LF rebuild otherwise "saved" a byte per
216
+ // line, and a compaction that archived nothing still reported tokens saved.
214
217
  const stamp = new Date(now).toISOString().slice(0, 10);
215
- let bytesAfter = rebuildState(content, new Set(archivable.map(v => v.title)), stamp).length;
218
+ const eol = dominantEol(content);
219
+ let bytesAfter = withEol(rebuildState(content, new Set(archivable.map(v => v.title)), stamp), eol).length;
216
220
 
217
221
  // The point of compaction is a smaller re-read on every agent call. On a file
218
222
  // whose history sections are tiny, the pointers left behind cost more than the
@@ -291,14 +295,15 @@ function compactState(cwd, opts = {}) {
291
295
  const { writeStateMd } = require('./state.cjs');
292
296
  writeStateMd(statePath, next, cwd);
293
297
 
298
+ const onDisk = withEol(next, dominantEol(content));
294
299
  return {
295
300
  ...plan,
296
301
  applied: true,
297
302
  dry_run: false,
298
303
  archived: plan.archivable.map(v => v.title),
299
- bytes_after: next.length,
300
- tokens_after: Math.ceil(next.length / CHARS_PER_TOKEN),
301
- tokens_saved_per_call: Math.max(0, Math.ceil((plan.bytes_before - next.length) / CHARS_PER_TOKEN)),
304
+ bytes_after: onDisk.length,
305
+ tokens_after: Math.ceil(onDisk.length / CHARS_PER_TOKEN),
306
+ tokens_saved_per_call: Math.max(0, Math.ceil((plan.bytes_before - onDisk.length) / CHARS_PER_TOKEN)),
302
307
  };
303
308
  }
304
309