pan-wizard 3.33.0 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +29 -26
  2. package/agents/pan-conductor.md +18 -17
  3. package/agents/pan-counterfactual.md +2 -2
  4. package/agents/pan-debugger.md +30 -9
  5. package/agents/pan-design-checker.md +2 -1
  6. package/agents/pan-designer.md +2 -2
  7. package/agents/pan-distiller.md +3 -3
  8. package/agents/pan-document_code.md +14 -14
  9. package/agents/pan-executor.md +30 -9
  10. package/agents/pan-experiment-runner.md +9 -8
  11. package/agents/pan-hardener.md +1 -1
  12. package/agents/pan-integration-checker.md +2 -2
  13. package/agents/pan-knowledge.md +1 -1
  14. package/agents/pan-meta-reviewer.md +4 -4
  15. package/agents/pan-optimizer.md +39 -43
  16. package/agents/pan-phase-researcher.md +10 -10
  17. package/agents/pan-plan-checker.md +37 -34
  18. package/agents/pan-planner.md +57 -44
  19. package/agents/pan-previewer.md +1 -1
  20. package/agents/pan-project-researcher.md +12 -12
  21. package/agents/pan-release.md +1 -1
  22. package/agents/pan-research-synthesizer.md +3 -3
  23. package/agents/pan-reviewer.md +26 -3
  24. package/agents/pan-roadmapper.md +15 -14
  25. package/agents/pan-verifier.md +33 -25
  26. package/bin/install-lib.cjs +176 -38
  27. package/bin/install.js +81 -63
  28. package/commands/pan/army.md +15 -11
  29. package/commands/pan/assumptions.md +2 -2
  30. package/commands/pan/audit-deployment.md +11 -12
  31. package/commands/pan/cost.md +8 -8
  32. package/commands/pan/dashboard.md +0 -3
  33. package/commands/pan/debug.md +10 -7
  34. package/commands/pan/design-phase.md +5 -5
  35. package/commands/pan/discuss-phase.md +2 -0
  36. package/commands/pan/exec-phase.md +21 -37
  37. package/commands/pan/experiment.md +9 -8
  38. package/commands/pan/focus-auto.md +22 -20
  39. package/commands/pan/focus-design.md +7 -6
  40. package/commands/pan/focus-doc-audit.md +4 -3
  41. package/commands/pan/focus-drift-walking.md +4 -3
  42. package/commands/pan/focus-exec.md +6 -6
  43. package/commands/pan/focus-plan.md +12 -11
  44. package/commands/pan/focus-scan.md +5 -3
  45. package/commands/pan/focus-sync.md +3 -2
  46. package/commands/pan/git.md +8 -8
  47. package/commands/pan/health.md +2 -2
  48. package/commands/pan/hud.md +4 -7
  49. package/commands/pan/hygiene.md +14 -7
  50. package/commands/pan/knowledge.md +9 -10
  51. package/commands/pan/learn.md +10 -7
  52. package/commands/pan/links.md +4 -1
  53. package/commands/pan/map-codebase.md +1 -1
  54. package/commands/pan/mcp-bridge.md +19 -21
  55. package/commands/pan/milestone-done.md +6 -3
  56. package/commands/pan/milestone-new.md +1 -1
  57. package/commands/pan/new-project.md +10 -10
  58. package/commands/pan/optimize.md +18 -14
  59. package/commands/pan/patches.md +2 -6
  60. package/commands/pan/pause.md +1 -1
  61. package/commands/pan/phase-tests.md +3 -2
  62. package/commands/pan/plan-phase.md +9 -19
  63. package/commands/pan/preview.md +2 -3
  64. package/commands/pan/profile.md +2 -2
  65. package/commands/pan/quick.md +1 -1
  66. package/commands/pan/remove-phase.md +1 -1
  67. package/commands/pan/report.md +3 -7
  68. package/commands/pan/research-phase.md +16 -15
  69. package/commands/pan/resume.md +9 -11
  70. package/commands/pan/retro.md +5 -3
  71. package/commands/pan/review-deep.md +7 -9
  72. package/commands/pan/settings.md +1 -1
  73. package/commands/pan/verify-phase.md +0 -11
  74. package/commands/pan/what-if.md +3 -4
  75. package/hooks/dist/pan-context-monitor.js +352 -44
  76. package/hooks/dist/pan-cost-logger.js +30 -0
  77. package/hooks/dist/pan-guard.js +244 -0
  78. package/hooks/dist/pan-statusline.js +73 -24
  79. package/hooks/dist/pan-stop-guard.js +7 -2
  80. package/package.json +1 -1
  81. package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
  82. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  83. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  84. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  85. package/pan-wizard-core/bin/lib/commands.cjs +57 -6
  86. package/pan-wizard-core/bin/lib/config.cjs +7 -1
  87. package/pan-wizard-core/bin/lib/constants.cjs +18 -6
  88. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  89. package/pan-wizard-core/bin/lib/core.cjs +53 -19
  90. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  91. package/pan-wizard-core/bin/lib/cost.cjs +142 -13
  92. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  93. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  94. package/pan-wizard-core/bin/lib/findings.cjs +135 -21
  95. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  96. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  97. package/pan-wizard-core/bin/lib/hygiene.cjs +184 -17
  98. package/pan-wizard-core/bin/lib/init.cjs +13 -4
  99. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  100. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  101. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  102. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  103. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  104. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  105. package/pan-wizard-core/bin/lib/optimize.cjs +97 -17
  106. package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
  107. package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
  108. package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
  109. package/pan-wizard-core/bin/lib/phase.cjs +39 -13
  110. package/pan-wizard-core/bin/lib/preview.cjs +34 -31
  111. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  112. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  113. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  114. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  115. package/pan-wizard-core/bin/lib/state.cjs +43 -28
  116. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  117. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  118. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  119. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  120. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
  121. package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
  122. package/pan-wizard-core/bin/lib/verify-scope.cjs +11 -7
  123. package/pan-wizard-core/bin/lib/verify.cjs +30 -28
  124. package/pan-wizard-core/bin/lib/whatif.cjs +53 -9
  125. package/pan-wizard-core/bin/pan-tools.cjs +114 -19
  126. package/pan-wizard-core/learnings/index.json +5 -5
  127. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  128. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  129. package/pan-wizard-core/opencode/pan-wizard.js +139 -0
  130. package/pan-wizard-core/references/checkpoints.md +3 -3
  131. package/pan-wizard-core/references/continuation-format.md +3 -3
  132. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  133. package/pan-wizard-core/references/design-methodology.md +1 -1
  134. package/pan-wizard-core/references/git-integration.md +4 -6
  135. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  136. package/pan-wizard-core/references/guardrails.md +2 -2
  137. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  138. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  139. package/pan-wizard-core/references/model-profiles.md +15 -10
  140. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  141. package/pan-wizard-core/references/planning-config.md +7 -7
  142. package/pan-wizard-core/references/tdd.md +10 -1
  143. package/pan-wizard-core/references/verdict-contract.md +6 -1
  144. package/pan-wizard-core/references/verification-patterns.md +5 -5
  145. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  146. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  147. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  148. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  149. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  150. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  151. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  152. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  153. package/pan-wizard-core/templates/config.json +2 -6
  154. package/pan-wizard-core/templates/continue-here.md +1 -1
  155. package/pan-wizard-core/templates/debug-subagent-prompt.md +5 -3
  156. package/pan-wizard-core/templates/debug.md +15 -1
  157. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  158. package/pan-wizard-core/templates/milestone.md +1 -1
  159. package/pan-wizard-core/templates/phase-prompt.md +31 -17
  160. package/pan-wizard-core/templates/planner-subagent-prompt.md +5 -5
  161. package/pan-wizard-core/templates/preview-report.md +1 -1
  162. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  163. package/pan-wizard-core/templates/research-project/features.md +1 -1
  164. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  165. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  166. package/pan-wizard-core/templates/research.md +5 -5
  167. package/pan-wizard-core/templates/retrospective.md +1 -1
  168. package/pan-wizard-core/templates/roadmap.md +15 -15
  169. package/pan-wizard-core/templates/state.md +4 -4
  170. package/pan-wizard-core/templates/summary-complex.md +1 -0
  171. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  172. package/pan-wizard-core/templates/summary-standard.md +2 -1
  173. package/pan-wizard-core/templates/summary.md +18 -2
  174. package/pan-wizard-core/templates/uat.md +3 -3
  175. package/pan-wizard-core/templates/user-setup.md +8 -8
  176. package/pan-wizard-core/templates/verification-report.md +53 -8
  177. package/pan-wizard-core/workflows/add-phase.md +1 -1
  178. package/pan-wizard-core/workflows/assumptions.md +1 -1
  179. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  180. package/pan-wizard-core/workflows/discuss-phase.md +10 -8
  181. package/pan-wizard-core/workflows/exec-phase.md +75 -95
  182. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  183. package/pan-wizard-core/workflows/health.md +13 -12
  184. package/pan-wizard-core/workflows/help.md +8 -9
  185. package/pan-wizard-core/workflows/learn.md +13 -11
  186. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  187. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  188. package/pan-wizard-core/workflows/milestone-gaps.md +18 -25
  189. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  190. package/pan-wizard-core/workflows/new-project.md +17 -25
  191. package/pan-wizard-core/workflows/optimize.md +11 -9
  192. package/pan-wizard-core/workflows/pause.md +6 -1
  193. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  194. package/pan-wizard-core/workflows/plan-phase.md +53 -42
  195. package/pan-wizard-core/workflows/profile.md +2 -2
  196. package/pan-wizard-core/workflows/progress.md +6 -8
  197. package/pan-wizard-core/workflows/quick.md +7 -5
  198. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  199. package/pan-wizard-core/workflows/research-phase.md +9 -5
  200. package/pan-wizard-core/workflows/resume-project.md +4 -6
  201. package/pan-wizard-core/workflows/retro.md +3 -1
  202. package/pan-wizard-core/workflows/settings.md +16 -24
  203. package/pan-wizard-core/workflows/todo-check.md +4 -3
  204. package/pan-wizard-core/workflows/transition.md +16 -12
  205. package/pan-wizard-core/workflows/update.md +25 -17
  206. package/pan-wizard-core/workflows/verify-phase.md +22 -20
  207. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  208. package/pan-zcode/README.md +7 -7
  209. package/pan-zcode/bin/install-zcode.js +1 -1
  210. package/scripts/build-hooks.js +1 -0
  211. package/scripts/build-plugin.js +3 -1
  212. package/scripts/generate-skills-docs.py +15 -6
  213. package/scripts/prompt-lint.cjs +167 -0
  214. package/scripts/test-surface.cjs +1 -0
@@ -4,10 +4,10 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, loadConfig, output, error, toPosix, isGitRepo, execGit } = require('./core.cjs');
7
+ const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, loadConfig, output, error, toPosix, isGitRepo, isGitIgnored, execGit } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
9
  const { writeStateMd, readStateSafe } = require('./state.cjs');
10
- const { enumerateRoadmapPhases } = require('./roadmap.cjs');
10
+ const { enumerateRoadmapPhases, roadmapRequirementIds } = require('./roadmap.cjs');
11
11
  const { PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile, isSummaryFile, getPlanId, PHASE_DIR_RE, ARCHIVE_DIR_RE } = require('./constants.cjs');
12
12
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, parsePhaseDir, fileAccessible, planningRel } = require('./utils.cjs');
13
13
  // Phase removal lives in phase-remove.cjs; re-exported below so consumers of
@@ -63,7 +63,7 @@ function cmdPhasesList(cwd, options, raw) {
63
63
  // If filtering by phase number
64
64
  if (phase) {
65
65
  const normalized = normalizePhaseName(phase);
66
- const match = dirs.find(dir => dir.startsWith(normalized));
66
+ const match = dirs.find(dir => dir === normalized || dir.startsWith(normalized + '-'));
67
67
  if (!match) {
68
68
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
69
69
  return;
@@ -200,7 +200,7 @@ function cmdFindPhase(cwd, phase, raw) {
200
200
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
201
201
  const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
202
202
 
203
- const match = dirs.find(dir => dir.startsWith(normalized));
203
+ const match = dirs.find(dir => dir === normalized || dir.startsWith(normalized + '-'));
204
204
  if (!match) {
205
205
  output(notFound, raw, '');
206
206
  return;
@@ -253,7 +253,7 @@ function cmdPhasePlanIndex(cwd, phase, raw, opts = {}) {
253
253
  try {
254
254
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
255
255
  const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
256
- const match = dirs.find(dir => dir.startsWith(normalized));
256
+ const match = dirs.find(dir => dir === normalized || dir.startsWith(normalized + '-'));
257
257
  if (match) {
258
258
  phaseDir = path.join(phasesDir, match);
259
259
  phaseDirName = match;
@@ -708,12 +708,10 @@ function markRequirementsCompleteForPhase(cwd, phaseNum, roadmapContent) {
708
708
  const reqPath = path.join(planningPath(cwd), REQUIREMENTS_FILE);
709
709
  try {
710
710
  const reqReadContent = fs.readFileSync(reqPath, 'utf-8');
711
- const reqMatch = roadmapContent.match(
712
- new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?(?:\\*\\*Requirements:\\*\\*|\\*\\*Requirements\\*\\*:)\\s*([^\\n]+)`, 'i')
713
- );
714
- if (!reqMatch) return;
715
-
716
- const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(id => id.trim()).filter(Boolean);
711
+ // The phase's own requirements, matched padded or not (roadmapRequirementIds): a
712
+ // bare `Phase N … Requirements` search ticked phase 1's when completing phase 5.
713
+ const reqIds = roadmapRequirementIds(roadmapContent, phaseNum);
714
+ if (reqIds.length === 0) return;
717
715
  let reqContent = reqReadContent;
718
716
 
719
717
  for (const reqId of reqIds) {
@@ -825,6 +823,26 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
825
823
  error(`Phase ${phaseNum} not found`);
826
824
  }
827
825
 
826
+ // The phase gate (V1). In `enforce` mode a refusal stops here unless --force
827
+ // --reason, which records a decision; in `warn` mode the reasons travel with the
828
+ // result. A --force without a reason is refused in either mode.
829
+ const { phaseGate, recordGateOverride } = require('./phase-gate.cjs');
830
+ const force = !!(opts && opts.force);
831
+ const forceReason = opts && typeof opts.reason === 'string' ? opts.reason.trim() : '';
832
+ if (force && !forceReason) {
833
+ output({ error: 'reason_required', reason: 'phase complete --force needs --reason "<why the gate is being overridden>"' }, raw);
834
+ return;
835
+ }
836
+ const gate = phaseGate(cwd, phaseNum);
837
+ let gateOverride = null;
838
+ if (!gate.error && !gate.ok) {
839
+ if (gate.mode === 'enforce' && !force) {
840
+ output({ error: 'phase_gate_refused', reason: gate.reasons.map((r) => `${r.code}: ${r.detail}`).join(' | '), gate }, raw);
841
+ return;
842
+ }
843
+ if (force) gateOverride = recordGateOverride(cwd, gate, forceReason);
844
+ }
845
+
828
846
  const planCount = phaseInfo.plans.length;
829
847
  const summaryCount = phaseInfo.summaries.length;
830
848
 
@@ -888,6 +906,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
888
906
  date: today,
889
907
  roadmap_updated: fileAccessible(roadmapPath),
890
908
  state_updated: fileAccessible(statePath),
909
+ gate: gate.error ? { error: gate.error } : { ok: gate.ok, mode: gate.mode, reasons: gate.reasons, not_checked: gate.not_checked },
910
+ ...(gateOverride ? { gate_override: gateOverride } : {}),
891
911
  };
892
912
  if (roadmapResult && roadmapResult.requirements_warning) {
893
913
  result.requirements_warning = roadmapResult.requirements_warning;
@@ -900,8 +920,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
900
920
  result.roadmap_updated = false;
901
921
  }
902
922
 
903
- // Auto-commit .planning/ metadata unless --no-commit or not a git repo
904
- const noCommit = opts && opts.noCommit;
923
+ // Auto-commit .planning/ metadata unless --no-commit, not a git repo, or the project
924
+ // keeps planning files out of git: `commit_docs: false` or a gitignored planning
925
+ // tree, the same rules `pan-tools commit` follows (this commit used to ignore both).
926
+ const noCommit = (opts && opts.noCommit)
927
+ || loadConfig(cwd).commit_docs === false
928
+ || isGitIgnored(cwd, planningRel());
905
929
  if (!noCommit && isGitRepo(cwd)) {
906
930
  const commitMsg = `docs(${normalized}): complete phase — ${phaseInfo.phase_name}`;
907
931
  execGit(cwd, ['add', planningRel() + '/']);
@@ -980,6 +1004,8 @@ module.exports = {
980
1004
  cmdFindPhase,
981
1005
  cmdPhasePlanIndex,
982
1006
  blockedPlans,
1007
+ buildPlanIndex,
1008
+ dependencyNames,
983
1009
  cmdPhaseAdd,
984
1010
  cmdPhaseInsert,
985
1011
  cmdPhaseRemove,
@@ -20,6 +20,7 @@ const {
20
20
  findPhaseInternal,
21
21
  getRoadmapPhaseInternal,
22
22
  toPosix,
23
+ toLf,
23
24
  } = require('./core.cjs');
24
25
  const {
25
26
  ROADMAP_FILE,
@@ -160,7 +161,8 @@ function buildPhasePreview(cwd, phaseNum) {
160
161
  * Produce a dependency graph + mermaid source + parallel-batch recommendation.
161
162
  *
162
163
  * Dependency detection:
163
- * - plan frontmatter `depends_on: [phase:NN, phase:MM]` (explicit)
164
+ * - the roadmap's `**Depends on:** Phase N` line (explicit)
165
+ * - plan frontmatter `depends_on: [phase:NN, phase:MM]` (explicit; bare plan ids are not phases)
164
166
  * - mentions of prior phases in plan text (heuristic, flagged as "hidden")
165
167
  *
166
168
  * @param {string} cwd - Project root
@@ -194,6 +196,13 @@ function buildPhaseDependencyGraph(cwd) {
194
196
  }
195
197
  }
196
198
 
199
+ // Graph keys are the roadmap checklist's numbers; a dependency written "01" or
200
+ // "Phase 1" resolves to the key whose unpadded form matches.
201
+ const unpad = (n) => String(n).trim().replace(/^0+(?=\d)/, '');
202
+ const keyByUnpadded = new Map(phaseList.map(p => [unpad(p.num), p.num]));
203
+ const keyOf = (n) => keyByUnpadded.get(unpad(n)) || unpad(n);
204
+ const { phaseDependencies } = require('./roadmap.cjs');
205
+
197
206
  for (const p of phaseList) {
198
207
  graph[p.num] = {
199
208
  num: p.num,
@@ -203,6 +212,15 @@ function buildPhaseDependencyGraph(cwd) {
203
212
  hidden_deps: [],
204
213
  };
205
214
 
215
+ const addExplicit = (d) => {
216
+ const key = keyOf(d);
217
+ if (key !== keyOf(p.num) && !graph[p.num].explicit_deps.includes(key)) graph[p.num].explicit_deps.push(key);
218
+ };
219
+
220
+ // Explicit via the roadmap: the phase's `**Depends on:** Phase N` line.
221
+ const roadmapPhase = getRoadmapPhaseInternal(cwd, p.num);
222
+ for (const d of phaseDependencies(roadmapPhase && roadmapPhase.section)) addExplicit(d);
223
+
206
224
  // Read plan files for depends_on frontmatter + prior-phase mentions.
207
225
  const dir = phaseDirs[p.num];
208
226
  if (!dir) continue;
@@ -210,35 +228,19 @@ function buildPhaseDependencyGraph(cwd) {
210
228
  let files = [];
211
229
  try { files = fs.readdirSync(fullDir).filter(isPlanFile); } catch { continue; }
212
230
 
213
- const phaseText = files.map(f => safeReadFile(path.join(fullDir, f)) || '').join('\n');
214
-
215
- // Explicit via frontmatter. Parse depends_on as freeform string (either
216
- // inline `[phase:1, phase:2]`, or block-list). Extract all digit runs.
217
- const fmMatch = phaseText.match(/^---\n([\s\S]*?)\n---/);
218
- if (fmMatch) {
219
- const depsLine = fmMatch[1].match(/^depends_on:\s*(.+)$/m);
220
- if (depsLine) {
221
- const digitRuns = depsLine[1].match(/\d+(?:\.\d+)?/g) || [];
222
- for (const d of digitRuns) {
223
- if (d !== p.num && !graph[p.num].explicit_deps.includes(d)) {
224
- graph[p.num].explicit_deps.push(d);
225
- }
226
- }
227
- }
228
- // Also support block-list form:
229
- // depends_on:
230
- // - phase:1
231
- // - phase:2
232
- const blockMatch = fmMatch[1].match(/^depends_on:\s*\n((?:\s*-\s*.+\n?)*)/m);
233
- if (blockMatch && !depsLine) {
234
- const items = blockMatch[1].match(/^\s*-\s*(.+)$/gm) || [];
235
- for (const item of items) {
236
- const d = item.match(/\d+(?:\.\d+)?/);
237
- if (d && d[0] !== p.num && !graph[p.num].explicit_deps.includes(d[0])) {
238
- graph[p.num].explicit_deps.push(d[0]);
239
- }
240
- }
241
- }
231
+ // LF, so the frontmatter match below also finds a CRLF plan's depends_on.
232
+ const planTexts = files.map(f => toLf(safeReadFile(path.join(fullDir, f)) || ''));
233
+ const phaseText = planTexts.join('\n');
234
+
235
+ // Explicit via each plan's frontmatter. A plan's `depends_on` names plan ids
236
+ // inside its own phase (`["01"]`), so only `phase:N` entries are phase
237
+ // dependencies, inline (`[phase:1, phase:2]`) or as a block list.
238
+ for (const text of planTexts) {
239
+ const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
240
+ if (!fmMatch) continue;
241
+ const depsBlock = fmMatch[1].match(/^depends_on:[^\n]*(?:\n[ \t]*-[^\n]*)*/m);
242
+ if (!depsBlock) continue;
243
+ for (const m of depsBlock[0].matchAll(/\bphase[:\s]*(\d+(?:\.\d+)?)/gi)) addExplicit(m[1]);
242
244
  }
243
245
 
244
246
  // Hidden via mentions: "phase N", "from phase N", "as in phase N".
@@ -246,7 +248,8 @@ function buildPhaseDependencyGraph(cwd) {
246
248
  let mm;
247
249
  const mentions = new Set();
248
250
  while ((mm = mentionRe.exec(phaseText)) !== null) {
249
- if (mm[1] !== p.num) mentions.add(mm[1]);
251
+ const k = keyOf(mm[1]);
252
+ if (k !== keyOf(p.num)) mentions.add(k);
250
253
  }
251
254
  graph[p.num].hidden_deps = [...mentions].filter(d => !graph[p.num].explicit_deps.includes(d));
252
255
  }
@@ -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
+ };