@opengsd/gsd-core 1.14.0 → 1.15.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 (283) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-code-fixer.compact.md +7 -6
  8. package/agents/gsd-code-fixer.md +9 -8
  9. package/agents/gsd-debug-session-manager.compact.md +17 -2
  10. package/agents/gsd-debug-session-manager.md +17 -2
  11. package/agents/gsd-debugger.md +2 -2
  12. package/agents/gsd-eval-auditor.compact.md +1 -1
  13. package/agents/gsd-eval-auditor.md +1 -1
  14. package/agents/gsd-executor.md +13 -8
  15. package/agents/gsd-intel-updater.compact.md +1 -1
  16. package/agents/gsd-intel-updater.md +1 -1
  17. package/agents/gsd-phase-researcher.md +19 -11
  18. package/agents/gsd-plan-checker.md +8 -7
  19. package/agents/gsd-planner.md +12 -8
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.md +155 -17
  25. package/agents/gsd-ui-researcher.compact.md +1 -1
  26. package/agents/gsd-ui-researcher.md +1 -1
  27. package/agents/gsd-verifier.md +10 -9
  28. package/bin/install.js +642 -95
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +1 -1
  31. package/commands/gsd/mempalace-capture.md +7 -3
  32. package/commands/gsd/plan-review-convergence.md +6 -6
  33. package/commands/gsd/progress.md +1 -1
  34. package/commands/gsd/quick-batch.md +1 -1
  35. package/commands/gsd/review.md +2 -3
  36. package/gsd-core/bin/gsd-tools.cjs +335 -22
  37. package/gsd-core/bin/lib/adr-parser.cjs +3 -1
  38. package/gsd-core/bin/lib/audit.cjs +81 -13
  39. package/gsd-core/bin/lib/capability-registry.cjs +82 -187
  40. package/gsd-core/bin/lib/capability-validator.cjs +0 -1
  41. package/gsd-core/bin/lib/check-command-router.cjs +101 -14
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  43. package/gsd-core/bin/lib/commands.cjs +175 -42
  44. package/gsd-core/bin/lib/config-loader.cjs +65 -4
  45. package/gsd-core/bin/lib/config.cjs +33 -7
  46. package/gsd-core/bin/lib/decisions.cjs +30 -14
  47. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  48. package/gsd-core/bin/lib/graphify.cjs +10 -2
  49. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  50. package/gsd-core/bin/lib/init.cjs +207 -41
  51. package/gsd-core/bin/lib/install-engine.cjs +13 -0
  52. package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone.cjs +18 -5
  54. package/gsd-core/bin/lib/model-resolver.cjs +159 -50
  55. package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
  56. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  57. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  58. package/gsd-core/bin/lib/phase-id.cjs +109 -7
  59. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  60. package/gsd-core/bin/lib/phase.cjs +227 -26
  61. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  62. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  63. package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
  64. package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
  65. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  66. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  67. package/gsd-core/bin/lib/project-root.cjs +41 -2
  68. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  69. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  70. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  71. package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
  72. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  73. package/gsd-core/bin/lib/roadmap.cjs +251 -31
  74. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
  75. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
  76. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  77. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
  78. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  79. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  80. package/gsd-core/bin/lib/state-transition.cjs +39 -2
  81. package/gsd-core/bin/lib/state.cjs +42 -0
  82. package/gsd-core/bin/lib/surface.cjs +17 -1
  83. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  84. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  85. package/gsd-core/bin/lib/uat.cjs +8 -0
  86. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  87. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  88. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  89. package/gsd-core/bin/lib/verification.cjs +268 -15
  90. package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
  91. package/gsd-core/bin/lib/verify.cjs +132 -25
  92. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  93. package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
  94. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  95. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  96. package/gsd-core/references/checkpoints.md +5 -3
  97. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  98. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  99. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  100. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  101. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  102. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  103. package/gsd-core/references/edge-probe.md +195 -21
  104. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  105. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  106. package/gsd-core/references/gsd-run-resolver.md +1 -1
  107. package/gsd-core/references/model-profiles.md +1 -1
  108. package/gsd-core/references/phase-argument-parsing.md +9 -7
  109. package/gsd-core/references/phase-id-convention.md +28 -0
  110. package/gsd-core/references/planner-gap-closure.md +2 -0
  111. package/gsd-core/references/planner-load-graph-context.md +24 -13
  112. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  113. package/gsd-core/references/planning-config.md +11 -2
  114. package/gsd-core/references/tdd.md +27 -4
  115. package/gsd-core/references/ui-consideration-probe.md +10 -5
  116. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  117. package/gsd-core/references/worktree-path-safety.md +321 -0
  118. package/gsd-core/templates/verification-report.md +1 -1
  119. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/gsd-core/workflows/add-backlog.md +1 -1
  121. package/gsd-core/workflows/add-phase.md +1 -1
  122. package/gsd-core/workflows/add-tests.md +2 -2
  123. package/gsd-core/workflows/add-todo.md +3 -3
  124. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  125. package/gsd-core/workflows/audit-fix.md +1 -1
  126. package/gsd-core/workflows/audit-milestone.md +1 -1
  127. package/gsd-core/workflows/audit-uat.md +1 -1
  128. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  129. package/gsd-core/workflows/autonomous.md +16 -6
  130. package/gsd-core/workflows/check-todos.md +2 -2
  131. package/gsd-core/workflows/cleanup.md +2 -2
  132. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  133. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  134. package/gsd-core/workflows/code-review-fix.md +108 -22
  135. package/gsd-core/workflows/code-review.md +63 -46
  136. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
  137. package/gsd-core/workflows/complete-milestone.md +2 -2
  138. package/gsd-core/workflows/debug.md +3 -3
  139. package/gsd-core/workflows/diagnose-issues.md +1 -1
  140. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  141. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  142. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  143. package/gsd-core/workflows/discuss-phase.md +1 -1
  144. package/gsd-core/workflows/do.md +2 -2
  145. package/gsd-core/workflows/docs-update.md +3 -3
  146. package/gsd-core/workflows/edit-phase.md +1 -1
  147. package/gsd-core/workflows/eval-review.md +10 -3
  148. package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
  149. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  150. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  151. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
  152. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
  153. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  154. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  155. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  156. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  157. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  158. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  159. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  160. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  161. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  162. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  163. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  164. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  165. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  166. package/gsd-core/workflows/execute-phase.md +36 -26
  167. package/gsd-core/workflows/execute-plan.md +5 -4
  168. package/gsd-core/workflows/explore.md +3 -3
  169. package/gsd-core/workflows/extract-learnings.md +2 -1
  170. package/gsd-core/workflows/fast.md +1 -1
  171. package/gsd-core/workflows/forensics.md +1 -1
  172. package/gsd-core/workflows/graduation.md +1 -1
  173. package/gsd-core/workflows/health.md +2 -2
  174. package/gsd-core/workflows/help/modes/full.compact.md +3 -3
  175. package/gsd-core/workflows/help/modes/full.md +5 -5
  176. package/gsd-core/workflows/help/modes/topic.md +15 -5
  177. package/gsd-core/workflows/import.md +2 -2
  178. package/gsd-core/workflows/inbox.md +2 -2
  179. package/gsd-core/workflows/ingest-docs.md +3 -3
  180. package/gsd-core/workflows/insert-phase.md +1 -1
  181. package/gsd-core/workflows/list-seeds.md +1 -1
  182. package/gsd-core/workflows/list-workspaces.md +1 -1
  183. package/gsd-core/workflows/manager.md +2 -2
  184. package/gsd-core/workflows/map-codebase.md +2 -2
  185. package/gsd-core/workflows/milestone-summary.md +1 -1
  186. package/gsd-core/workflows/mvp-phase.md +1 -1
  187. package/gsd-core/workflows/new-milestone.md +2 -2
  188. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  189. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  190. package/gsd-core/workflows/new-project.md +7 -7
  191. package/gsd-core/workflows/new-workspace.md +2 -2
  192. package/gsd-core/workflows/next.md +1 -1
  193. package/gsd-core/workflows/note.md +1 -1
  194. package/gsd-core/workflows/onboard.md +1 -1
  195. package/gsd-core/workflows/pause-work.md +1 -1
  196. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  197. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  198. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  199. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  200. package/gsd-core/workflows/plan-phase.md +24 -7
  201. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  202. package/gsd-core/workflows/plant-seed.md +62 -20
  203. package/gsd-core/workflows/pr-branch.md +113 -13
  204. package/gsd-core/workflows/profile-user.md +2 -2
  205. package/gsd-core/workflows/progress.md +1 -1
  206. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  207. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  208. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  209. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  210. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  211. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  212. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  213. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  214. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  215. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  216. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  217. package/gsd-core/workflows/quick-batch.md +1 -1
  218. package/gsd-core/workflows/quick.md +21 -9
  219. package/gsd-core/workflows/reapply-patches.md +9 -3
  220. package/gsd-core/workflows/remove-phase.md +1 -1
  221. package/gsd-core/workflows/remove-workspace.md +2 -2
  222. package/gsd-core/workflows/resume-project.md +1 -1
  223. package/gsd-core/workflows/review.md +31 -16
  224. package/gsd-core/workflows/scan.md +1 -1
  225. package/gsd-core/workflows/secure-phase.md +3 -2
  226. package/gsd-core/workflows/settings-advanced.md +30 -10
  227. package/gsd-core/workflows/settings-integrations.md +2 -3
  228. package/gsd-core/workflows/settings.md +4 -4
  229. package/gsd-core/workflows/ship.md +3 -2
  230. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  231. package/gsd-core/workflows/sketch.md +1 -1
  232. package/gsd-core/workflows/smart-entry.md +2 -2
  233. package/gsd-core/workflows/spec-phase.md +15 -5
  234. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  235. package/gsd-core/workflows/spike.md +1 -1
  236. package/gsd-core/workflows/stats.md +1 -1
  237. package/gsd-core/workflows/sync-skills.md +5 -5
  238. package/gsd-core/workflows/thread.md +1 -1
  239. package/gsd-core/workflows/transition.md +1 -1
  240. package/gsd-core/workflows/ui-phase.md +44 -8
  241. package/gsd-core/workflows/ui-review.md +18 -4
  242. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  243. package/gsd-core/workflows/undo.md +339 -20
  244. package/gsd-core/workflows/update.md +7 -7
  245. package/gsd-core/workflows/validate-phase.md +3 -2
  246. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  247. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  248. package/gsd-core/workflows/verify-work.md +81 -16
  249. package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
  250. package/hooks/dist/gsd-secret-read-guard.js +27 -1
  251. package/hooks/dist/gsd-statusline.js +70 -13
  252. package/hooks/dist/gsd-validate-commit.sh +63 -4
  253. package/hooks/gsd-agent-isolation-guard.js +24 -0
  254. package/hooks/gsd-secret-read-guard.js +27 -1
  255. package/hooks/gsd-statusline.js +70 -13
  256. package/hooks/gsd-validate-commit.sh +63 -4
  257. package/package.json +3 -2
  258. package/scripts/build-hooks.js +15 -6
  259. package/scripts/check-contract-drift.cjs +127 -11
  260. package/scripts/command-contract-helpers.cjs +3 -0
  261. package/scripts/docs-guard-registry.cjs +28 -0
  262. package/scripts/gen-loop-host-contract.cjs +69 -0
  263. package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
  264. package/scripts/lib/ndjson-reporter.cjs +3 -2
  265. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  266. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
  267. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  268. package/scripts/lint-phase-id-drift.cjs +290 -5
  269. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  270. package/scripts/lint-retired-runtime-name.cjs +619 -0
  271. package/scripts/lint-state-write-path-drift.cjs +93 -0
  272. package/scripts/lint-test-file-count.allowlist.json +28 -9
  273. package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
  274. package/scripts/prompt-injection-scan.sh +4 -0
  275. package/scripts/release-tarball-smoke.cjs +194 -1
  276. package/skills/gsd-autonomous/SKILL.md +2 -2
  277. package/skills/gsd-capture/SKILL.md +1 -1
  278. package/skills/gsd-mempalace-capture/SKILL.md +7 -3
  279. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  280. package/skills/gsd-progress/SKILL.md +1 -1
  281. package/skills/gsd-quick-batch/SKILL.md +1 -1
  282. package/skills/gsd-review/SKILL.md +2 -3
  283. package/vscode/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Roadmap Upgrade — Migration tool for converting legacy 'Phase N' phase IDs
4
- * to milestone-prefixed 'Phase M-NN' form.
4
+ * to milestone-prefixed 'Phase M-NN', or legacy/M-NN IDs to bracket form.
5
5
  *
6
6
  * ADR-457 build-at-publish: the hand-written bin/lib/roadmap-upgrade.cjs collapsed
7
7
  * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
@@ -18,8 +18,46 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
18
18
  const planningWorkspace = require("./planning-workspace.cjs");
19
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
20
  const phaseIdMod = require("./phase-id.cjs");
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const phaseLocatorMod = require("./phase-locator.cjs");
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const planningScopeMod = require("./planning-scope.cjs");
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const frontmatterMod = require("./frontmatter.cjs");
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ const roadmapParserMod = require("./roadmap-parser.cjs");
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ const phaseMod = require("./phase.cjs");
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const coreUtilsMod = require("./core-utils.cjs");
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const markdownSectionizerMod = require("./markdown-sectionizer.cjs");
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const planScanMod = require("./plan-scan.cjs");
21
37
  const { planningDir } = planningWorkspace;
22
- const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
38
+ const { listAllPhaseDirs } = phaseLocatorMod;
39
+ const { SCOPE } = planningScopeMod;
40
+ // #4698 Blocker 1 (round 2): reuse the shared frontmatter reader/writer
41
+ // (src/frontmatter.cts) rather than a bespoke YAML touch — `spliceFrontmatter`
42
+ // already solves "change exactly one key, preserve every other key's raw text
43
+ // byte-for-byte", which is exactly the contract a `depends_on` rewrite needs.
44
+ const { extractFrontmatter, spliceFrontmatter } = frontmatterMod;
45
+ const { milestoneSections, isPhaseHeadingText } = roadmapParserMod;
46
+ const { normalizeDependencyToken } = phaseMod;
47
+ // #4144 round 5 Blocker 3: the single owner of the canonical short-alias
48
+ // derivation the real resolver's own canonicalToId map is built from
49
+ // (src/phase.cts) — reused here rather than re-derived.
50
+ const { extractCanonicalPlanId } = coreUtilsMod;
51
+ // #4144 round 5 Blocker 4: the readers' own fence-scanning engine
52
+ // (tokenizeHeadings is built on this same seam) — reused here instead of a
53
+ // third independent fence parser.
54
+ const { scanFencedBlocks } = markdownSectionizerMod;
55
+ // #4144 round 5 follow-up (lint-plan-count-drift): the plan-scan owner's own
56
+ // root-plan-file predicate (src/plan-scan.cts) — reused in
57
+ // computeDependsOnRewrites instead of a private `/-PLAN\.md$/i` filename
58
+ // re-derivation of the same membership test.
59
+ const { isRootPlanFile } = planScanMod;
60
+ const { BRACKET_ID_SRC, BRACKET_PROJECT_CODE_SRC, isBracketPhaseTokenRepresentable, isSentinelPhaseId, normalizePhaseName, OPTIONAL_PHASE_TAG_SOURCE, PHASE_HEADING_BASELINE, phaseHeadingPrefixSrcFor, PHASE_NUMBER_TOKEN_SOURCE, phaseArtifactTokenSpan, stripProjectCodePrefix, toDir, } = phaseIdMod;
23
61
  // ─── Regex helpers ────────────────────────────────────────────────────────────
24
62
  // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
25
63
  // Captures: (hashes)(spaces)(phase-number)(rest-of-line)
@@ -29,6 +67,92 @@ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+
29
67
  // Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
30
68
  // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
31
69
  const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
70
+ // Bracket headings are terminal migration targets. Both the bracket identity
71
+ // and the following phase token come from the phase-id owner (#2128).
72
+ // #4698 Blocker 3 (round 2): widened with (uncaptured) OPTIONAL_PHASE_TAG_SOURCE
73
+ // so an ALREADY-migrated bracket heading that carries a tag
74
+ // (`### [GSD.02] 05 (Cluster B): Name`) is still recognized as migrated —
75
+ // this constant is exclusively bracket-owned (no shared consumer whose group
76
+ // indices this would disturb), so it is safe to widen in place.
77
+ const BRACKET_PHASE_HEADING_RE = new RegExp(`^#{2,4}\\s*\\[${BRACKET_ID_SRC}\\][ \\t]*(?:Phase\\s+)?${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
78
+ // phase-id-owner: ADR-612 PR-3 exclusively owns the deprecated M-NN source grammar during the migration window.
79
+ // M-NN has exactly one consumer (parseBracketSourcePhases, via
80
+ // MNN_PHASE_HEADING_BRACKET_RE below) — the historical milestone-prefixed
81
+ // target never recognizes M-NN input at all — so only the shared token
82
+ // source lives here; the heading regex itself is bracket-path-owned.
83
+ const MNN_SOURCE_TOKEN_SOURCE = '\\d+-\\d+(?:-\\d+)?';
84
+ const PROJECT_CODE_RE = new RegExp(`^${BRACKET_PROJECT_CODE_SRC}$`);
85
+ // #4698 Blocker 3 (round 2): bracket-source-parsing-OWNED copies of the
86
+ // legacy/M-NN heading grammars above, widened with a CAPTURED
87
+ // `OPTIONAL_PHASE_TAG_SOURCE` so a heading the runtime already supports —
88
+ // `### Phase 2 (Cluster B): Beta`, the optional parenthetical tag between the
89
+ // phase number and the colon (src/phase-id.cts, #1729) — is recognized here
90
+ // too, instead of failing every grammar and vanishing from the plan silently
91
+ // (the exact reported defect). `src/roadmap.cts`'s real bracket readers
92
+ // compose the phaseHeadingPrefixSrcFor selector's result with
93
+ // PHASE_NUMBER_TOKEN_SOURCE + OPTIONAL_PHASE_TAG_SOURCE + ':' for BOTH the
94
+ // bracket and label-only intros (e.g. `src/roadmap.cts:184`) — so the
95
+ // bracket heading grammar DOES have a tag slot, in this exact position, and
96
+ // this migrator must emit into it rather than drop the tag or refuse it.
97
+ // (Named here only for the reader tracing the claim, never called: this file
98
+ // does not consume that convention-gated selector, so scripts/lint-phase-id-
99
+ // drift.cjs's closed selector-consumer census must not, and does not, count
100
+ // it as one — see tests/adr-612-bracket-heading-selection.test.cjs.)
101
+ //
102
+ // LEGACY_PHASE_HEADING_BRACKET_RE is a SEPARATE constant from
103
+ // LEGACY_PHASE_HEADING_RE (used by the historical milestone-prefixed
104
+ // target's OWN parseRoadmapPhases and its own separate heading-rewrite regex
105
+ // in computeMigrationPlan, both with an established, untouched group-index
106
+ // contract) rather than widening that shared regex in place: widening
107
+ // recognition there alone would newly COUNT a tagged heading that target's
108
+ // own rewrite regex still cannot rewrite (a different, unfixed regex),
109
+ // trading a silent skip for a silent half-migration. M-NN needs no such
110
+ // pairing — it has exactly one consumer (this bracket-only source parser),
111
+ // per MNN_SOURCE_TOKEN_SOURCE's own comment above.
112
+ // #4144 round 6 (W-CRLF): the trailing capture is `(.*\r?)`, not `(.*)`. JS
113
+ // `.` never matches `\r` (it is its own LineTerminator, ECMA-262), so a CRLF
114
+ // roadmap's `\r` sat just past the `(.*)` group's end — present in the LINE
115
+ // but absent from `headingTail`, which the heading rebuild below is built
116
+ // from. Since `applyRoadmapEdits` replaces a touched line's FULL text with
117
+ // the rebuilt one, every converted heading silently lost its `\r` while
118
+ // every untouched line (and every checklist bullet, whose rewrite instead
119
+ // slices the line's own remainder rather than reassembling captured groups)
120
+ // kept it — a mixed-EOL file despite this function's own "preserve every
121
+ // terminator" contract. The explicit `\r?` re-admits it into the SAME group
122
+ // `headingTail` is read from, so the rebuilt line carries it forward.
123
+ const LEGACY_PHASE_HEADING_BRACKET_RE = new RegExp(`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})\\s*:(.*\\r?)`, 'i');
124
+ const MNN_PHASE_HEADING_BRACKET_RE = new RegExp(`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${MNN_SOURCE_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})\\s*:(.*\\r?)`, 'i');
125
+ // #4698 Blocker 3 (round 2), part (b): a line that STARTS like a phase
126
+ // heading, or like a bracket-shaped heading, but is parsed by NONE of the
127
+ // three recognized grammars above must never be silently skipped — that is
128
+ // exactly how a tagged/malformed heading went unmigrated while the roadmap
129
+ // still got stamped with the target convention (the reported defect's root
130
+ // cause: partial conversion read as "done"). `computeBracketPlan` refuses
131
+ // before any write when either check below matches a line that none of
132
+ // BRACKET_PHASE_HEADING_RE / MNN_PHASE_HEADING_BRACKET_RE /
133
+ // LEGACY_PHASE_HEADING_BRACKET_RE accepted.
134
+ //
135
+ // #4144 round 5 Blocker 1 (regression from round 3): the "phase-like" half
136
+ // of that refusal used to be a bare `/^#{2,4}\s*Phase\b/i` — anything
137
+ // starting with the word "Phase" — which also caught
138
+ // gsd-core/templates/roadmap.md's own shipped `## Phase Details` section
139
+ // heading (no phase number, no colon) and aborted migration of every
140
+ // roadmap built from that template. The refusal may only fire for a heading
141
+ // the READERS' OWN phase-heading grammar (roadmap-parser.cts's
142
+ // hasPhaseEntries, exported as `isPhaseHeadingText`) would itself treat as a
143
+ // phase heading — reusing that single owner rather than a second, looser
144
+ // grammar here. `isPhaseLikeHeadingLine` strips the `#{2,4}` markers a raw
145
+ // source line still carries (tokenizeHeadings' own `h.text` already has
146
+ // them stripped; a source line handed to this scanner does not) before
147
+ // testing.
148
+ const HEADING_HASH_PREFIX_RE = /^#{2,4}[ \t]*/;
149
+ const isPhaseLikeHeadingLine = (line) => {
150
+ const match = HEADING_HASH_PREFIX_RE.exec(line);
151
+ if (!match)
152
+ return false;
153
+ return isPhaseHeadingText(line.slice(match[0].length));
154
+ };
155
+ const BRACKET_HEADING_LIKE_RE = /^#{2,4}\s*\[[^\]]{1,200}\]/;
32
156
  // ─── Pure computation helpers ─────────────────────────────────────────────────
33
157
  /**
34
158
  * Parse the ROADMAP.md content and build a list of phase entries with their
@@ -128,12 +252,1347 @@ function buildNewDirName(oldDirName, newId, projectCode) {
128
252
  const newBase = slug ? `${paddedMilestone}-${subStr}-${slug}` : `${paddedMilestone}-${subStr}`;
129
253
  return projectCode ? `${projectCode}-${newBase}` : newBase;
130
254
  }
255
+ const pad2 = (value) => String(value).padStart(2, '0');
256
+ /**
257
+ * #4144 round 6 B2/B3: the section-keyed maps below (`sectionLegacyMap`) key
258
+ * on a `milestoneSections` range's own `start` offset, which is always >= 0
259
+ * for a real section. This sentinel covers legacy entries with NO attributed
260
+ * section at all (single-section / STATE.md-fallback repositories), where
261
+ * there is only one bucket to begin with.
262
+ */
263
+ const GLOBAL_SECTION_KEY = -1;
264
+ /**
265
+ * #4144 round 5 Blocker 4 (Warn): the line indices `parseBracketSourcePhases`
266
+ * must never classify as a heading of any kind because a fenced code block
267
+ * covers them — mirrors the READERS' own fence-awareness (tokenizeHeadings,
268
+ * markdown-sectionizer.cts, used at roadmap-parser.cts:689) via the SAME
269
+ * `scanFencedBlocks` engine, rather than a third fence parser. Both fence
270
+ * delimiter lines themselves are included (harmless: neither is a phase
271
+ * heading), and an unterminated trailing fence covers to the end of the
272
+ * document, matching `scanFencedBlocks`' own EOF-still-open semantics.
273
+ */
274
+ function fencedLineIndices(lines) {
275
+ const fenced = new Set();
276
+ for (const block of scanFencedBlocks(lines)) {
277
+ const end = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
278
+ for (let i = block.openLineIdx; i <= end; i++)
279
+ fenced.add(i);
280
+ }
281
+ return fenced;
282
+ }
283
+ function parseBracketSourcePhases(lines) {
284
+ const results = [];
285
+ // #4698 Blocker 3 (round 2), part (b): every phase-like/bracket-like line
286
+ // this loop could not place into any of the three recognized grammars.
287
+ const unparsed = [];
288
+ let currentMilestoneInt = null;
289
+ const fenced = fencedLineIndices(lines);
290
+ for (let i = 0; i < lines.length; i++) {
291
+ if (fenced.has(i))
292
+ continue;
293
+ const line = lines[i];
294
+ const milestoneMatch = line.match(MILESTONE_HEADING_RE);
295
+ if (milestoneMatch) {
296
+ currentMilestoneInt = parseInt(milestoneMatch[1], 10);
297
+ continue;
298
+ }
299
+ if (BRACKET_PHASE_HEADING_RE.test(line)) {
300
+ results.push({ lineIndex: i, alreadyMigrated: true });
301
+ continue;
302
+ }
303
+ // M-NN must be tested before legacy. It is a convertible source under
304
+ // bracket, not the terminal convention it is for the legacy migrator.
305
+ const mnnMatch = line.match(MNN_PHASE_HEADING_BRACKET_RE);
306
+ if (mnnMatch) {
307
+ const segments = mnnMatch[2].split('-');
308
+ results.push({
309
+ lineIndex: i,
310
+ alreadyMigrated: false,
311
+ source: 'mnn',
312
+ milestoneInt: parseInt(segments[0], 10),
313
+ sourceToken: mnnMatch[2],
314
+ headingTag: mnnMatch[3] ?? '',
315
+ headingTail: mnnMatch[4],
316
+ hashes: mnnMatch[1],
317
+ });
318
+ continue;
319
+ }
320
+ const legacyMatch = line.match(LEGACY_PHASE_HEADING_BRACKET_RE);
321
+ if (legacyMatch) {
322
+ results.push({
323
+ lineIndex: i,
324
+ alreadyMigrated: false,
325
+ source: 'legacy',
326
+ milestoneInt: currentMilestoneInt,
327
+ sourceToken: legacyMatch[2],
328
+ headingTag: legacyMatch[3] ?? '',
329
+ headingTail: legacyMatch[4],
330
+ hashes: legacyMatch[1],
331
+ });
332
+ continue;
333
+ }
334
+ if (isPhaseLikeHeadingLine(line) || BRACKET_HEADING_LIKE_RE.test(line)) {
335
+ unparsed.push(line);
336
+ }
337
+ }
338
+ return { entries: results, unparsed };
339
+ }
340
+ /**
341
+ * Resolve bracket target tokens. M-NN sources preserve their integer
342
+ * identity while moving the milestone into the bracket: `2-01` → `01`;
343
+ * `2-04-01` → `04.01`. Legacy phases receive a deterministic per-milestone
344
+ * counter — but #4144 round 5 Blocker 2: a preserved M-NN token is NEVER
345
+ * reassignable, so it must RESERVE its own leading integer segment against
346
+ * that same milestone's legacy counter before any legacy phase is numbered,
347
+ * or the two axes can silently collide (`2-01` preserved as `01` and a
348
+ * later legacy `Phase 3` also counter-assigned `01`, under the same
349
+ * milestone). `lines` is the raw ROADMAP.md source, used only to name each
350
+ * colliding heading verbatim in the refusal below — never to re-derive
351
+ * identity.
352
+ *
353
+ * Two passes, in `entries`' own document order (never re-sorted):
354
+ * 1. Every preserved M-NN entry gets its own deterministic token AND
355
+ * reserves that token's leading integer segment for its milestone.
356
+ * 2. Every legacy entry gets the next per-milestone counter value pass 1
357
+ * did not reserve.
358
+ * A generic post-pass then refuses, before any write, if two entries under
359
+ * the same milestone still resolved to the same final token — covering both
360
+ * "two preserved M-NN spellings of the same integer" (`2-01` and `2-1`, both
361
+ * `01`) and any other collision pass 1/2 failed to keep disjoint.
362
+ */
363
+ function assignBracketTokens(entries, lines) {
364
+ const mappings = new Map();
365
+ // Pass 1: preserved M-NN tokens.
366
+ const reservedByMilestone = new Map();
367
+ for (const entry of entries) {
368
+ if (entry.alreadyMigrated || entry.source !== 'mnn')
369
+ continue;
370
+ if (entry.milestoneInt === null || entry.milestoneInt === undefined)
371
+ continue;
372
+ const segments = entry.sourceToken.split('-');
373
+ const token = segments.slice(1).map((segment) => pad2(parseInt(segment, 10))).join('.');
374
+ const leadingReserved = parseInt(segments[1], 10);
375
+ if (!reservedByMilestone.has(entry.milestoneInt)) {
376
+ reservedByMilestone.set(entry.milestoneInt, new Set());
377
+ }
378
+ reservedByMilestone.get(entry.milestoneInt).add(leadingReserved);
379
+ mappings.set(entry.lineIndex, {
380
+ lineIndex: entry.lineIndex,
381
+ milestoneInt: entry.milestoneInt,
382
+ token,
383
+ source: 'mnn',
384
+ sourceToken: entry.sourceToken,
385
+ phaseName: (entry.headingTail ?? '').trim(),
386
+ });
387
+ }
388
+ // Pass 2: legacy phases, walking the next per-milestone counter value that
389
+ // pass 1 did not reserve.
390
+ const nextCandidateByMilestone = new Map();
391
+ for (const entry of entries) {
392
+ if (entry.alreadyMigrated || entry.source !== 'legacy')
393
+ continue;
394
+ if (entry.milestoneInt === null || entry.milestoneInt === undefined)
395
+ continue;
396
+ const reserved = reservedByMilestone.get(entry.milestoneInt) ?? new Set();
397
+ let candidate = nextCandidateByMilestone.get(entry.milestoneInt) ?? 1;
398
+ while (reserved.has(candidate))
399
+ candidate++;
400
+ nextCandidateByMilestone.set(entry.milestoneInt, candidate + 1);
401
+ mappings.set(entry.lineIndex, {
402
+ lineIndex: entry.lineIndex,
403
+ milestoneInt: entry.milestoneInt,
404
+ token: pad2(candidate),
405
+ source: 'legacy',
406
+ sourceToken: entry.sourceToken,
407
+ phaseName: (entry.headingTail ?? '').trim(),
408
+ });
409
+ }
410
+ const byMilestoneToken = new Map();
411
+ for (const mapping of mappings.values()) {
412
+ const key = `${mapping.milestoneInt}::${mapping.token}`;
413
+ if (!byMilestoneToken.has(key))
414
+ byMilestoneToken.set(key, []);
415
+ byMilestoneToken.get(key).push(mapping);
416
+ }
417
+ const colliding = [...byMilestoneToken.values()]
418
+ .filter((group) => group.length > 1)
419
+ .flat()
420
+ .sort((a, b) => a.lineIndex - b.lineIndex);
421
+ if (colliding.length > 0) {
422
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: two phase headings under the same '
423
+ + 'milestone would resolve to the same bracket token. Refusing rather than colliding distinct phase '
424
+ + 'identities:\n'
425
+ + colliding.map((mapping) => ` ${lines[mapping.lineIndex]}`).join('\n'));
426
+ }
427
+ return mappings;
428
+ }
429
+ /**
430
+ * #4144 round 6 B1: a legacy phase token (raw, as spelled in a `### Phase N:`
431
+ * heading) that the readers' own sentinel classifier (`isSentinelPhaseId`,
432
+ * legacy/bare-leading-int branch — the SAME rule `listAllPhaseDirs` and every
433
+ * phase count already exclude `999.x`/`0.x` sentinels through) treats as a
434
+ * sentinel. Returns the sentinel's own bracket MILESTONE integer (0 or 999)
435
+ * so the caller can attribute the phase to that milestone directly, bypassing
436
+ * whatever `## vN.M` section happens to enclose the heading on disk — under
437
+ * bracket, a sentinel's identity IS the sentinel milestone (`[CODE.999]` /
438
+ * `[CODE.00]`), never the real milestone section it was filed under.
439
+ */
440
+ function legacySentinelMilestone(sourceToken) {
441
+ if (!isSentinelPhaseId(sourceToken))
442
+ return null;
443
+ const match = stripProjectCodePrefix(sourceToken).match(/^0*(\d+)/);
444
+ return match ? parseInt(match[1], 10) : null;
445
+ }
446
+ function asciiInteger(segment) {
447
+ if (segment.length === 0)
448
+ return null;
449
+ for (const character of segment) {
450
+ if (character < '0' || character > '9')
451
+ return null;
452
+ }
453
+ return parseInt(segment, 10);
454
+ }
455
+ function legacyLookupKey(token) {
456
+ return String(normalizePhaseName(token)).toLowerCase();
457
+ }
458
+ /**
459
+ * Return the old directory's slug when it belongs to a mapping, otherwise
460
+ * null. M-NN matching consumes exactly the expected number of numeric fields,
461
+ * so a digit-leading slug remains a slug instead of becoming another identity
462
+ * segment (the ambiguity the bracket convention removes).
463
+ *
464
+ * `matchedToken` is the identity segment(s) exactly AS SPELLED on disk (e.g.
465
+ * "02-04" or "02.1"), distinct from `mapping.sourceToken` (the ROADMAP
466
+ * heading's own spelling, which can differ in zero-padding). #4698 Blocker 3
467
+ * needs this exact on-disk spelling to locate and rename the phase's
468
+ * artifact files, whose filenames are written to match the DIRECTORY, not
469
+ * the heading.
470
+ */
471
+ function matchBracketSourceDir(dirName, mapping) {
472
+ const stripped = stripProjectCodePrefix(dirName);
473
+ if (mapping.source === 'mnn') {
474
+ const expected = mapping.sourceToken.split('-').map((segment) => parseInt(segment, 10));
475
+ const parts = stripped.split('-');
476
+ if (parts.length < expected.length)
477
+ return null;
478
+ for (let i = 0; i < expected.length; i++) {
479
+ const actual = asciiInteger(parts[i]);
480
+ if (actual === null || actual !== expected[i])
481
+ return null;
482
+ }
483
+ return {
484
+ slug: parts.slice(expected.length).join('-'),
485
+ matchedToken: parts.slice(0, expected.length).join('-'),
486
+ };
487
+ }
488
+ const legacyDirMatch = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-(.*))?$`, 'i'));
489
+ if (!legacyDirMatch)
490
+ return null;
491
+ if (legacyLookupKey(legacyDirMatch[1]) !== legacyLookupKey(mapping.sourceToken))
492
+ return null;
493
+ return { slug: legacyDirMatch[2] ?? '', matchedToken: legacyDirMatch[1] };
494
+ }
495
+ /**
496
+ * #4698 Blocker 3: a directory rename changes the phase's on-disk token, but
497
+ * the phase-qualified artifact FILENAMES inside it (`03-VERIFICATION.md`,
498
+ * `03-01-PLAN.md`, `03-CONTEXT.md`, `03-RESEARCH.md`, ...) keep spelling the
499
+ * OLD token unless renamed too. `isPhaseArtifact`'s bracket branch
500
+ * (src/phase-id.cts) compares the new bracket directory's own qualified/bare
501
+ * token against each candidate filename and excludes anything that
502
+ * disagrees — so a stale-prefixed artifact silently drops out of
503
+ * bracket-convention reads (verification, plan/summary scans) the moment its
504
+ * directory is renamed. Fix: ask `phaseArtifactTokenSpan`, the reader-owned
505
+ * membership seam, for the exact token span of every ROOT-LEVEL file (never
506
+ * anything inside a nested `plans/` subdirectory — see the docblock note
507
+ * below), then replace exactly that span with the new bracket artifact token
508
+ * while keeping the rest of the filename unchanged.
509
+ *
510
+ * NESTED `plans/` ARTIFACTS ARE OUT OF SCOPE: the #3139 nested layout writes
511
+ * `plans/PLAN-<n>.md` / `plans/SUMMARY-<n>.md` with NO phase-token prefix at
512
+ * all (phase.cts: "pairs a nested `plans/PLAN-01.md`... layout-agnostic" —
513
+ * the phase identity comes from the CONTAINING directory alone). There is
514
+ * nothing to rename there, so this function only ever reads the phase
515
+ * directory's own root entries (`fs.readdirSync` is not recursive), which
516
+ * naturally excludes the `plans/` subdirectory itself (a directory, not a
517
+ * file) without any extra filtering.
518
+ *
519
+ * Collisions are refused HERE, before `computeBracketPlan` returns — the
520
+ * same "refuse before any write" contract every other hard-refusal in this
521
+ * file already follows, so a colliding fixture is caught on dry-run too, not
522
+ * just on apply.
523
+ */
524
+ function computeArtifactRenames(dirName, oldDirPath, sourceToken, targetToken) {
525
+ if (!sourceToken || sourceToken === targetToken)
526
+ return [];
527
+ let entries;
528
+ try {
529
+ entries = node_fs_1.default.readdirSync(oldDirPath, { withFileTypes: true });
530
+ }
531
+ catch {
532
+ return [];
533
+ }
534
+ const fileNames = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
535
+ const renames = [];
536
+ for (const fileName of fileNames) {
537
+ const tokenSpan = phaseArtifactTokenSpan(fileName, dirName);
538
+ if (tokenSpan === null)
539
+ continue;
540
+ const newName = fileName.slice(0, tokenSpan.start) + targetToken + fileName.slice(tokenSpan.end);
541
+ if (newName === fileName)
542
+ continue;
543
+ renames.push({ oldName: fileName, newName });
544
+ }
545
+ if (renames.length === 0)
546
+ return renames;
547
+ // Refuse before any write: a renamed target must not collide with a file
548
+ // that already has that name and is not itself being renamed away.
549
+ const renamedAway = new Set(renames.map((r) => r.oldName));
550
+ const staticNames = new Set(fileNames.filter((f) => !renamedAway.has(f)));
551
+ const producerByTarget = new Map();
552
+ for (const rename of renames) {
553
+ if (staticNames.has(rename.newName)) {
554
+ throw new Error(`Cannot rename artifact ${JSON.stringify(rename.oldName)} to ${JSON.stringify(rename.newName)} `
555
+ + `in phase directory ${JSON.stringify(dirName)}: a different file already has that name.`);
556
+ }
557
+ const producer = producerByTarget.get(rename.newName);
558
+ if (producer) {
559
+ throw new Error(`Cannot migrate phase artifacts in ${JSON.stringify(dirName)}: both ${JSON.stringify(producer)} `
560
+ + `and ${JSON.stringify(rename.oldName)} would rename to ${JSON.stringify(rename.newName)}.`);
561
+ }
562
+ producerByTarget.set(rename.newName, rename.oldName);
563
+ }
564
+ return renames;
565
+ }
566
+ /**
567
+ * #4698 Blocker 1 (round 2): a directory rename changes the phase's on-disk
568
+ * token, and `computeArtifactRenames` (above) renames that directory's own
569
+ * artifact FILENAMES to match — but a SIBLING plan file's `depends_on`
570
+ * frontmatter that names one of those renamed files by its OLD
571
+ * phase-qualified id (`depends_on: ["03-01"]`, naming `03-01-PLAN.md`) keeps
572
+ * spelling the OLD token. The real dependency resolver
573
+ * (`computeDependencyLevels`, src/phase.cts) resolves `depends_on` tokens
574
+ * against `RawPlan.id`s derived from THIS SAME PHASE DIRECTORY's own
575
+ * filenames only (`cmdPhasePlanIndex` scans one phase directory at a time) —
576
+ * so once the sibling's filename changes, the stale token matches no plan id
577
+ * in the directory at all, the edge is dropped as "unresolved" (#3427), and
578
+ * the dependent plan fails readiness (`missingEvidence`) even after its
579
+ * predecessor completes — reproducing the reported defect exactly.
580
+ *
581
+ * Fix: derive the old and new plan IDs from the exact artifact-renames plan,
582
+ * then compare each `depends_on` value through `normalizeDependencyToken`,
583
+ * the real resolver's exported case-folding seam. This means the migration
584
+ * rewrites exactly those tokens the resolver considers equal to a renamed
585
+ * plan ID. Bare in-phase short forms and tokens naming other plans remain
586
+ * untouched.
587
+ *
588
+ * `depends_on` is the only frontmatter field THIS function rewrites. It is
589
+ * the only field the real DEPENDENCY resolver reads for cross-plan identity:
590
+ * `parsePlanDocument` (src/plan-document.cts) reads `phase`/`plan` as scalar
591
+ * display fields, never assembling a `<phase>-<NN>` token from them, and a
592
+ * `*-SUMMARY.md`'s own `requires:` block is prose (a human-readable "what
593
+ * this phase needed" list) that `computeDependencyLevels` never parses —
594
+ * rewriting either would touch bytes THAT resolver does not read. `phase:`
595
+ * is a different story for a DIFFERENT reader: `history-digest`
596
+ * (src/commands.cts, `cmdHistoryDigest`) keys phases and decisions by that
597
+ * exact scalar, so a stale `phase:` left at the old token after a
598
+ * renumbering migration files a phase's decisions under a different phase's
599
+ * new identity. `computePhaseFrontmatterRewrites` below is the one that
600
+ * corrects it, on the reader-owned `phaseArtifactTokenSpan` membership this
601
+ * function's own `fileRenames` parameter is built from.
602
+ *
603
+ * NESTED `plans/` ARTIFACTS ARE OUT OF SCOPE, for the identical reason
604
+ * `computeArtifactRenames` excludes them (see that function's docblock): a
605
+ * plain, non-recursive `readdirSync` of the phase directory's root already
606
+ * excludes `plans/`'s contents.
607
+ */
608
+ function computeDependsOnRewrites(oldDirPath, sourceToken, targetToken, fileRenames) {
609
+ if (!sourceToken || sourceToken === targetToken)
610
+ return [];
611
+ let entries;
612
+ try {
613
+ entries = node_fs_1.default.readdirSync(oldDirPath, { withFileTypes: true });
614
+ }
615
+ catch {
616
+ return [];
617
+ }
618
+ const renameByOldName = new Map(fileRenames.map((r) => [r.oldName, r.newName]));
619
+ const renamedPlanIdByToken = new Map();
620
+ const registerDependencyAlias = (oldAlias, newAlias) => {
621
+ const comparisonToken = normalizeDependencyToken(oldAlias);
622
+ const existing = renamedPlanIdByToken.get(comparisonToken);
623
+ if (existing !== undefined && existing !== newAlias) {
624
+ throw new Error(`Cannot migrate depends_on references in ${JSON.stringify(node_path_1.default.basename(oldDirPath))}: `
625
+ + `renamed plan IDs collide when compared by the dependency resolver.`);
626
+ }
627
+ renamedPlanIdByToken.set(comparisonToken, newAlias);
628
+ };
629
+ for (const rename of fileRenames) {
630
+ // #4144 round 5 follow-up: the plan-scan owner's own root-plan-file test
631
+ // (src/plan-scan.cts), not a private re-derivation of the `-PLAN.md`
632
+ // filename filter — the owner also accepts bare `PLAN.md` and the
633
+ // legacy delimited slug form (`3-PLAN-01-setup.md`), which the old
634
+ // inline `/-PLAN\.md$/i` regex rejected outright, silently skipping any
635
+ // depends_on alias registration for such a plan.
636
+ if (!isRootPlanFile(rename.oldName) || !isRootPlanFile(rename.newName))
637
+ continue;
638
+ const oldPlanId = rename.oldName.replace(/-PLAN\.md$/i, '');
639
+ const newPlanId = rename.newName.replace(/-PLAN\.md$/i, '');
640
+ registerDependencyAlias(oldPlanId, newPlanId);
641
+ // #4144 round 5 Blocker 3: the real resolver (computeDependencyLevels /
642
+ // resolveDependencyId, src/phase.cts) also resolves a depends_on token
643
+ // against extractCanonicalPlanId's shorter alias (core-utils.cts) — a
644
+ // plan slugged as `03-01-setup-PLAN.md` is reachable as `03-01` as well
645
+ // as its full id. Index that alias too, mapped to the SAME alias of the
646
+ // renamed file — reusing extractCanonicalPlanId rather than re-deriving
647
+ // it — or a depends_on naming a renamed plan by its canonical alias
648
+ // (instead of its full slugged id) silently stops resolving after the
649
+ // rename.
650
+ registerDependencyAlias(extractCanonicalPlanId(rename.oldName), extractCanonicalPlanId(rename.newName));
651
+ }
652
+ const rewrites = [];
653
+ for (const entry of entries) {
654
+ if (!entry.isFile() || !isRootPlanFile(entry.name))
655
+ continue;
656
+ const filePath = node_path_1.default.join(oldDirPath, entry.name);
657
+ let content;
658
+ try {
659
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
660
+ }
661
+ catch {
662
+ continue;
663
+ }
664
+ let fm;
665
+ try {
666
+ fm = extractFrontmatter(content, filePath);
667
+ }
668
+ catch {
669
+ continue;
670
+ }
671
+ // Mirrors parsePlanDocument's own depends_on normalization exactly
672
+ // (src/plan-document.cts) — an array of strings, a single non-empty
673
+ // string coerced to a one-element array, or nothing.
674
+ const rawDeps = fm['depends_on'];
675
+ const deps = Array.isArray(rawDeps)
676
+ ? rawDeps.map(String)
677
+ : typeof rawDeps === 'string' && rawDeps.trim() !== ''
678
+ ? [rawDeps]
679
+ : null;
680
+ if (!deps || deps.length === 0)
681
+ continue;
682
+ let changed = false;
683
+ const newDeps = deps.map((dep) => {
684
+ const replacement = renamedPlanIdByToken.get(normalizeDependencyToken(dep));
685
+ if (replacement === undefined)
686
+ return dep;
687
+ changed = true;
688
+ return replacement;
689
+ });
690
+ if (!changed)
691
+ continue;
692
+ let newContent;
693
+ try {
694
+ newContent = spliceFrontmatter(content, { ...fm, depends_on: newDeps });
695
+ }
696
+ catch (err) {
697
+ // A depends_on value the shared writer cannot faithfully re-serialize
698
+ // must never be silently dropped — refuse before any write, the same
699
+ // contract every other unrepresentable case in this file already
700
+ // follows (e.g. computeArtifactRenames' collision refusal above).
701
+ throw new Error(`Cannot rewrite depends_on in ${JSON.stringify(entry.name)} for phase token change `
702
+ + `${JSON.stringify(sourceToken)} -> ${JSON.stringify(targetToken)}: ${err.message}`);
703
+ }
704
+ rewrites.push({
705
+ oldName: entry.name,
706
+ finalName: renameByOldName.get(entry.name) ?? entry.name,
707
+ from: content,
708
+ to: newContent,
709
+ });
710
+ }
711
+ return rewrites;
712
+ }
713
+ /**
714
+ * #4144 round 6 (W-phase): a renamed artifact's `phase:` frontmatter scalar
715
+ * keeps spelling the OLD legacy/M-NN token, and `history-digest`
716
+ * (src/commands.cts:547, `const phaseNum = fm['phase'] || dir.split('-')[0]`)
717
+ * keys phases and decisions by that exact field — the field IS used as
718
+ * identity by that reader, contrary to `computeDependsOnRewrites`' own
719
+ * docblock claim above (corrected there). After a renumbering migration, a
720
+ * phase's decisions were filed under whatever OTHER phase's new token
721
+ * happened to collide with its own stale value.
722
+ *
723
+ * Scoped to phase-QUALIFIED artifacts (`phaseArtifactTokenSpan`, the same
724
+ * reader-owned membership predicate `computeArtifactRenames` uses), not
725
+ * every file in the directory — a `phase:` value matching `sourceToken` in
726
+ * ANY spelling the readers accept (`03`, `3`, `02.1` — compared through
727
+ * `legacyLookupKey`, the same equivalence `matchBracketSourceDir` already
728
+ * uses) is rewritten to the phase's own new bracket token, via the same
729
+ * `extractFrontmatter`/`spliceFrontmatter` pair every other frontmatter
730
+ * rewrite in this file uses.
731
+ *
732
+ * `dependsOnRewrites` (already computed for this same directory) is reused
733
+ * rather than re-read: a file needing BOTH a depends_on rewrite and a phase
734
+ * rewrite gets its existing entry's `.to` spliced again in place — one
735
+ * combined write — instead of two independent passes racing to overwrite
736
+ * each other's change at apply time. A file needing only the phase rewrite
737
+ * gets a new entry appended. Returns the merged array in the exact shape
738
+ * `PhaseRename.dependsOnRewrites` already carries, so `applyMigration`'s
739
+ * existing apply/rollback loop needs no changes to consume it.
740
+ */
741
+ function computePhaseFrontmatterRewrites(oldDirPath, dirName, sourceToken, targetToken, fileRenames, dependsOnRewrites) {
742
+ if (!sourceToken || sourceToken === targetToken)
743
+ return dependsOnRewrites;
744
+ let entries;
745
+ try {
746
+ entries = node_fs_1.default.readdirSync(oldDirPath, { withFileTypes: true });
747
+ }
748
+ catch {
749
+ return dependsOnRewrites;
750
+ }
751
+ const renameByOldName = new Map(fileRenames.map((r) => [r.oldName, r.newName]));
752
+ const byOldName = new Map(dependsOnRewrites.map((r) => [r.oldName, r]));
753
+ const sourceKey = legacyLookupKey(sourceToken);
754
+ const result = [...dependsOnRewrites];
755
+ for (const entry of entries) {
756
+ if (!entry.isFile())
757
+ continue;
758
+ if (phaseArtifactTokenSpan(entry.name, dirName) === null)
759
+ continue;
760
+ const existing = byOldName.get(entry.name);
761
+ const filePath = node_path_1.default.join(oldDirPath, entry.name);
762
+ let originalContent;
763
+ let baseContent;
764
+ if (existing) {
765
+ originalContent = existing.from;
766
+ baseContent = existing.to;
767
+ }
768
+ else {
769
+ try {
770
+ originalContent = node_fs_1.default.readFileSync(filePath, 'utf8');
771
+ }
772
+ catch {
773
+ continue;
774
+ }
775
+ baseContent = originalContent;
776
+ }
777
+ let fm;
778
+ try {
779
+ fm = extractFrontmatter(originalContent, filePath);
780
+ }
781
+ catch {
782
+ continue;
783
+ }
784
+ const rawPhase = fm['phase'];
785
+ if (typeof rawPhase !== 'string' && typeof rawPhase !== 'number')
786
+ continue;
787
+ if (legacyLookupKey(String(rawPhase)) !== sourceKey)
788
+ continue;
789
+ let baseFm;
790
+ try {
791
+ baseFm = extractFrontmatter(baseContent, filePath);
792
+ }
793
+ catch {
794
+ continue;
795
+ }
796
+ let newContent;
797
+ try {
798
+ newContent = spliceFrontmatter(baseContent, { ...baseFm, phase: targetToken });
799
+ }
800
+ catch (err) {
801
+ // Same refuse-before-any-write contract every other unrepresentable
802
+ // frontmatter rewrite in this file already follows.
803
+ throw new Error(`Cannot rewrite phase frontmatter in ${JSON.stringify(entry.name)} for phase token change `
804
+ + `${JSON.stringify(sourceToken)} -> ${JSON.stringify(targetToken)}: ${err.message}`);
805
+ }
806
+ const finalName = renameByOldName.get(entry.name) ?? entry.name;
807
+ if (existing) {
808
+ existing.to = newContent;
809
+ }
810
+ else {
811
+ const rewrite = { oldName: entry.name, finalName, from: originalContent, to: newContent };
812
+ result.push(rewrite);
813
+ byOldName.set(entry.name, rewrite);
814
+ }
815
+ }
816
+ return result;
817
+ }
818
+ /**
819
+ * #4698 Blocker 2: how many integer identity segments THIS mapping requires
820
+ * a directory to match. An M-NN mapping with more segments (`2-04-01`, a
821
+ * child) is strictly more specific than one with fewer (`2-04`, its parent)
822
+ * — the parent's own match is a textual PREFIX of the child's, so a
823
+ * directory satisfying both must resolve to the more specific (longer)
824
+ * identity, never whichever candidate the resolution loop reaches first.
825
+ * A legacy token is always matched exactly (see `matchBracketSourceDir`'s
826
+ * legacy branch: equality against ONE specific normalized token, never a
827
+ * prefix of another legacy token), so it has no competing specificity of its
828
+ * own to rank — treated as a single segment.
829
+ */
830
+ function bracketMappingSpecificity(mapping) {
831
+ return mapping.source === 'mnn' ? mapping.sourceToken.split('-').length : 1;
832
+ }
833
+ function buildBracketDirName(projectCode, mapping, slug, sourceDir) {
834
+ const milestone = pad2(mapping.milestoneInt);
835
+ const [phase, subphase] = mapping.token.split('.');
836
+ if (!slug)
837
+ return `${projectCode}.${milestone}-${mapping.token}`;
838
+ try {
839
+ return toDir({
840
+ project: projectCode,
841
+ milestone,
842
+ phase,
843
+ ...(subphase ? { subphase } : {}),
844
+ }, slug);
845
+ }
846
+ catch (err) {
847
+ throw new Error(`Cannot build bracket directory for phase ${JSON.stringify(mapping.sourceToken)} `
848
+ + `from source directory ${JSON.stringify(sourceDir)}: ${err.message}`);
849
+ }
850
+ }
851
+ function computeBracketPlan(cwd) {
852
+ const pDir = planningDir(cwd);
853
+ const roadmapPath = node_path_1.default.join(pDir, 'ROADMAP.md');
854
+ const configPath = node_path_1.default.join(pDir, 'config.json');
855
+ const phasesDir = node_path_1.default.join(pDir, 'phases');
856
+ const done = {
857
+ alreadyMigrated: true,
858
+ phases: [],
859
+ roadmapEdits: [],
860
+ crossRefEdits: [],
861
+ targetConvention: 'bracket',
862
+ };
863
+ let configData = {};
864
+ try {
865
+ configData = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
866
+ }
867
+ catch { /* config may not exist */ }
868
+ const projectCode = typeof configData['project_code'] === 'string' && configData['project_code'].length > 0
869
+ ? configData['project_code']
870
+ : null;
871
+ let roadmapContent;
872
+ try {
873
+ roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf8');
874
+ }
875
+ catch {
876
+ throw new Error(`ROADMAP.md not found at ${roadmapPath}`);
877
+ }
878
+ const lines = roadmapContent.split('\n');
879
+ const { entries: parsed, unparsed } = parseBracketSourcePhases(lines);
880
+ // #4698 Blocker 3 (round 2), part (b): a heading that starts like a phase
881
+ // heading (or a bracket-shaped heading) but matched none of the three
882
+ // recognized grammars is never silently skipped — refuse before any write
883
+ // and list every offender verbatim, exactly like refuseMixedRoadmap below
884
+ // does for a textual mix. Checked before the "zero recognized headings"
885
+ // guard: a roadmap with some genuinely recognized headings AND one
886
+ // unparseable phase-like heading is the reported defect's exact shape, and
887
+ // this message is the more actionable of the two for that case.
888
+ if (unparsed.length > 0) {
889
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: found heading(s) that start like a '
890
+ + 'phase heading but do not match any recognized grammar (legacy "### Phase N: Name", M-NN '
891
+ + '"### Phase M-NN: Name", or bracket "### [CODE.MM] NN: Name" — each optionally followed by a '
892
+ + '"(Tag)" before the colon). Refusing rather than silently skipping them. Unrecognized heading(s):\n'
893
+ + unparsed.map((l) => ` ${l}`).join('\n'));
894
+ }
895
+ // #4698 Blocker 2: an unrecognized or partially-migrated roadmap must
896
+ // refuse outright, never silently return a "successful" empty/partial plan.
897
+ // Bracket is the terminal convention — once config says "bracket", the
898
+ // guard below (`unconverted.length === 0`) short-circuits every later run,
899
+ // so a roadmap this planner failed to fully convert can never be revisited
900
+ // by a corrected re-run unless it is refused now, before any write.
901
+ const alreadyBracket = parsed.filter((entry) => entry.alreadyMigrated);
902
+ const unconverted = parsed.filter((entry) => !entry.alreadyMigrated);
903
+ if (parsed.length === 0) {
904
+ throw new Error('No recognized phase headings found in ROADMAP.md. The bracket migrator recognizes exactly '
905
+ + 'three phase heading shapes: bracket ("### [CODE.MM] NN: Name"), M-NN ("### Phase M-NN: Name"), '
906
+ + 'or legacy ("### Phase N: Name"). Add at least one recognized phase heading, then re-run.');
907
+ }
908
+ const refuseMixedRoadmap = () => {
909
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: it still has unconverted phase '
910
+ + 'headings. Refusing to guess at a partial migration. Unconverted headings:\n'
911
+ + unconverted.map((entry) => ` ${lines[entry.lineIndex]}`).join('\n'));
912
+ };
913
+ // A textual mix (some headings already bracket, others not) is never safe
914
+ // to resume automatically.
915
+ if (alreadyBracket.length > 0 && unconverted.length > 0)
916
+ refuseMixedRoadmap();
917
+ // Every recognized heading is already bracket text: nothing left to
918
+ // convert, regardless of what config.json's own phase_id_convention says.
919
+ if (unconverted.length === 0)
920
+ return done;
921
+ // From here, unconverted.length > 0. If config already claims "bracket",
922
+ // it disagrees with the roadmap's own text — the same refusal as the
923
+ // textual mix above, never `done`: a stale/incorrect config value must
924
+ // never hide un-migrated content or make it permanently unreachable.
925
+ if (configData['phase_id_convention'] === 'bracket')
926
+ refuseMixedRoadmap();
927
+ if (!projectCode) {
928
+ throw new Error('Cannot migrate to the bracket convention without a project_code in .planning/config.json '
929
+ + '(bracket phase IDs are [CODE.MM] NN). Set "project_code" first, then re-run.');
930
+ }
931
+ if (!PROJECT_CODE_RE.test(projectCode)) {
932
+ throw new Error(`Cannot migrate to the bracket convention with invalid project_code ${JSON.stringify(projectCode)}.`);
933
+ }
934
+ const code = projectCode;
935
+ const sourcePhases = unconverted;
936
+ const unrepresentableLegacy = sourcePhases.filter((entry) => entry.source === 'legacy' && !isBracketPhaseTokenRepresentable(entry.sourceToken));
937
+ if (unrepresentableLegacy.length > 0) {
938
+ throw new Error('Cannot migrate source phase token(s) whose identity axis the bracket grammar cannot represent. '
939
+ + 'Refusing rather than collapsing distinct phase identities:\n'
940
+ + unrepresentableLegacy.map((entry) => ` Phase ${entry.sourceToken}`).join('\n'));
941
+ }
942
+ const lineOffsets = [];
943
+ let nextLineOffset = 0;
944
+ for (const line of lines) {
945
+ lineOffsets.push(nextLineOffset);
946
+ nextLineOffset += line.length + 1;
947
+ }
948
+ const sections = milestoneSections(roadmapContent);
949
+ const sectionsForOffset = (offset) => sections
950
+ .filter((section) => section.start <= offset && offset < section.end)
951
+ .sort((a, b) => (a.end - a.start) - (b.end - b.start));
952
+ const phaseBearingSections = new Set(sourcePhases.flatMap((entry) => sectionsForOffset(lineOffsets[entry.lineIndex] ?? -1)));
953
+ const unattributedLegacy = [];
954
+ for (const entry of sourcePhases) {
955
+ if (entry.source !== 'legacy')
956
+ continue;
957
+ // #4144 round 6 B1: a legacy sentinel (999.x icebox / 0.x backlog) is
958
+ // NEVER attributed to the enclosing `## vN.M` section — it belongs to
959
+ // its own sentinel milestone regardless of which real milestone section
960
+ // it happens to be filed under on disk.
961
+ const sentinelMilestone = legacySentinelMilestone(entry.sourceToken);
962
+ if (sentinelMilestone !== null) {
963
+ entry.milestoneInt = sentinelMilestone;
964
+ continue;
965
+ }
966
+ const containing = sectionsForOffset(lineOffsets[entry.lineIndex] ?? -1);
967
+ const attributed = containing.find((section) => section.milestoneInt !== null);
968
+ if (attributed?.milestoneInt !== null && attributed?.milestoneInt !== undefined) {
969
+ entry.milestoneInt = attributed.milestoneInt;
970
+ entry.attributedSection = attributed;
971
+ }
972
+ else if (phaseBearingSections.size > 1) {
973
+ unattributedLegacy.push(entry);
974
+ }
975
+ }
976
+ if (unattributedLegacy.length > 0) {
977
+ throw new Error('Cannot attribute legacy phase heading(s) to a readable milestone section in a multi-milestone '
978
+ + 'ROADMAP. Refusing rather than applying the current STATE milestone to ambiguous history:\n'
979
+ + unattributedLegacy.map((entry) => ` Phase ${entry.sourceToken}`).join('\n'));
980
+ }
981
+ // Real single-milestone repositories may omit a `## vN.M` heading while
982
+ // carrying project-prefixed phase directories. Resolve those legacy entries
983
+ // from STATE.md instead of silently producing an empty migration plan.
984
+ if (phaseBearingSections.size <= 1
985
+ && sourcePhases.some((entry) => entry.source === 'legacy' && entry.milestoneInt == null)) {
986
+ let fallbackMilestone = null;
987
+ try {
988
+ const state = node_fs_1.default.readFileSync(node_path_1.default.join(pDir, 'STATE.md'), 'utf8');
989
+ const stateMilestone = state.match(/^milestone:\s*v?(\d+)/im);
990
+ if (stateMilestone)
991
+ fallbackMilestone = parseInt(stateMilestone[1], 10);
992
+ }
993
+ catch { /* STATE.md may not exist */ }
994
+ if (fallbackMilestone !== null) {
995
+ for (const entry of sourcePhases) {
996
+ if (entry.source === 'legacy' && entry.milestoneInt == null) {
997
+ entry.milestoneInt = fallbackMilestone;
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+ const unresolvedLegacy = sourcePhases.filter((entry) => entry.source === 'legacy' && entry.milestoneInt == null);
1003
+ if (unresolvedLegacy.length > 0) {
1004
+ throw new Error('Cannot determine a milestone for legacy phase headings. Add a ## vN.M roadmap heading '
1005
+ + 'or a milestone field in .planning/STATE.md, then re-run.');
1006
+ }
1007
+ // #4144 round 6 B3: the same legacy phase number appearing TWICE within
1008
+ // one milestone SECTION (the same granularity B2's checklist attribution
1009
+ // uses) is a duplicate identity, not two phases — `assignBracketTokens`'s
1010
+ // own per-milestone counter would otherwise silently invent a phantom
1011
+ // phase for the second heading (a distinct bracket token backed by no
1012
+ // second directory). Refuse before any write rather than guess.
1013
+ const legacyBySectionToken = new Map();
1014
+ for (const entry of sourcePhases) {
1015
+ if (entry.source !== 'legacy')
1016
+ continue;
1017
+ if (legacySentinelMilestone(entry.sourceToken) !== null)
1018
+ continue;
1019
+ const sectionKey = entry.attributedSection ? entry.attributedSection.start : GLOBAL_SECTION_KEY;
1020
+ const dedupeKey = `${sectionKey}::${legacyLookupKey(entry.sourceToken)}`;
1021
+ if (!legacyBySectionToken.has(dedupeKey))
1022
+ legacyBySectionToken.set(dedupeKey, []);
1023
+ legacyBySectionToken.get(dedupeKey).push(entry);
1024
+ }
1025
+ const duplicateLegacy = [...legacyBySectionToken.values()].filter((group) => group.length > 1).flat();
1026
+ if (duplicateLegacy.length > 0) {
1027
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: the same legacy phase number appears '
1028
+ + 'more than once in one milestone section. Refusing rather than inventing a phantom phase:\n'
1029
+ + duplicateLegacy.map((entry) => ` ${lines[entry.lineIndex]}`).join('\n'));
1030
+ }
1031
+ const idMapping = assignBracketTokens(sourcePhases, lines);
1032
+ // #4144 round 6 B2: keyed by SECTION identity (a section's own `start`
1033
+ // offset via `milestoneSections` — the SAME attribution headings use, set
1034
+ // on `entry.attributedSection` above), never by `milestoneInt` alone. Two
1035
+ // sections can share the same leading major integer (`## v2.0`, `## v2.1`)
1036
+ // while each restarts its own legacy phase numbering; a milestoneInt-only
1037
+ // key let the later section's token silently overwrite the earlier
1038
+ // section's in this same map. `GLOBAL_SECTION_KEY` covers legacy entries
1039
+ // with no attributed section at all (single-section / STATE.md-fallback
1040
+ // repositories), where there is only one bucket to begin with.
1041
+ const sectionLegacyMap = new Map();
1042
+ for (const entry of sourcePhases) {
1043
+ if (entry.source !== 'legacy')
1044
+ continue;
1045
+ const mapping = idMapping.get(entry.lineIndex);
1046
+ if (!mapping)
1047
+ continue;
1048
+ const sectionKey = entry.attributedSection ? entry.attributedSection.start : GLOBAL_SECTION_KEY;
1049
+ if (!sectionLegacyMap.has(sectionKey))
1050
+ sectionLegacyMap.set(sectionKey, new Map());
1051
+ sectionLegacyMap.get(sectionKey).set(legacyLookupKey(mapping.sourceToken), { token: mapping.token, milestoneInt: mapping.milestoneInt });
1052
+ }
1053
+ const phaseDirListing = listAllPhaseDirs(phasesDir, { includeSentinels: true });
1054
+ if (phaseDirListing.scope === SCOPE.UNREADABLE) {
1055
+ throw new Error(`Cannot read phase directories at ${phasesDir}`);
1056
+ }
1057
+ const existingDirs = phaseDirListing.value;
1058
+ // #4144 round 7 W1: derive the comparison slug the EXACT way the harness
1059
+ // derives a DIRECTORY's own slug — never a bespoke unlimited-length
1060
+ // slugify. `phase insert` writes "(INSERTED)" into the HEADING text
1061
+ // (phase.cts) but never into the directory it creates (its slug comes
1062
+ // from the bare `description`, which never carries the marker) — the
1063
+ // same marker `roadmap.cts:494` strips before slugifying a heading name
1064
+ // for its own comparisons. And `init`/`phase add` truncate at
1065
+ // `generateSlugInternal`'s own default maxLen (60 — init.cts:1190/:2284),
1066
+ // never `null` (unlimited). Comparing under a DIFFERENT rule than the one
1067
+ // that created the directory refused two-milestone roadmaps the
1068
+ // tooling's own commands produced.
1069
+ const slugify = (text) => (coreUtilsMod.generateSlugInternal(text.replace(/\(INSERTED\)/i, '').trim()) ?? '');
1070
+ // #4144 round 7 W2, corrected round 8 B1: a plan-level ambiguity check
1071
+ // that runs BEFORE the resolution loop below and never touches its
1072
+ // (unmodified, round-6) algorithm. For every directory that structurally
1073
+ // ties with more than one candidate at its own best specificity, group
1074
+ // directories by the EXACT set of candidates they tie with — two
1075
+ // directories tying with the identical candidate set are contesting the
1076
+ // identical pool — and refuse ONLY when that group's directory count
1077
+ // EXCEEDS its candidate count. A stale duplicate-number directory sorting
1078
+ // between two real ones (e.g. `01-gamma`, `01-gamma-old`, `01-zeta`, all
1079
+ // three tying with {Zeta, Gamma}) is exactly this: 3 directories for 2
1080
+ // candidates. Left unchecked, the resolution loop's own "only one
1081
+ // candidate left" shortcut (used when a directory's tie has already
1082
+ // shrunk to a single remaining candidate) accepts whichever directory is
1083
+ // visited LAST in that group WITHOUT ever verifying its slug — silently
1084
+ // dropping the true match for the other leftover directory from the plan.
1085
+ //
1086
+ // round 8 B1: the round-7 condition (`dirs.length !== mappingLines.length`)
1087
+ // over-refused — it also caught the far MORE common shape where a tie
1088
+ // group has FEWER directories than candidates: a later milestone that
1089
+ // reuses a legacy number but is only planned (no directory yet), an
1090
+ // archived `<details>` milestone whose directory was already cleaned up,
1091
+ // or a decimal insert whose sibling milestone has no directory at all.
1092
+ // That shape is never the silent-drop hazard above: the depletion loop
1093
+ // below only ever ran out of unused candidates for a directory when a tie
1094
+ // group had MORE directories than candidates (and since #4698 a directory
1095
+ // whose every matching heading is already claimed refuses in that loop
1096
+ // instead of falling through). With fewer (or equal) directories than candidates,
1097
+ // every directory the loop below visits is either resolved by its own
1098
+ // slug comparison or refused loudly on its own — a BALANCED group (equal
1099
+ // counts — e.g. exactly `01-gamma`/`01-zeta` tying with {Zeta, Gamma}) is
1100
+ // one instance of this and is left entirely to the resolution loop below,
1101
+ // unchanged: that loop's own slug-driven, elimination-assisted pairing
1102
+ // already resolves a balanced group correctly (round 6 B3), including a
1103
+ // directory whose own slug is a paraphrase of its phase's name rather than
1104
+ // an exact match. So the safe pre-check condition is `dirs > candidates`,
1105
+ // never `dirs !== candidates` — this check only catches a genuine
1106
+ // directory-count SURPLUS, never a deficit and never re-litigates a
1107
+ // balanced pairing.
1108
+ // #4144 round 8 W2: for every directory that was ever part of a
1109
+ // multi-directory tie group (2+ directories structurally contesting the
1110
+ // identical candidate set — a duplicate/stale legacy-number directory
1111
+ // sharing the tree with the real one), record the group's directory
1112
+ // count here. The resolution loop below consults this to decide whether
1113
+ // its own "single remaining candidate" shortcut may skip the slug check:
1114
+ // it may only do so for a directory that was NEVER part of such a group
1115
+ // (group size 1 — an ordinary, unambiguous structural match, including one
1116
+ // whose own slug paraphrases its phase's name rather than matching it
1117
+ // exactly, round 6 B3's r4e case). A directory that WAS part of a
1118
+ // multi-directory group must still pass the slug check even once
1119
+ // elimination has narrowed it down to a single remaining candidate,
1120
+ // because elimination alone cannot tell a stale duplicate from the real
1121
+ // directory (see W2 below).
1122
+ const dirTieGroupSize = new Map();
1123
+ {
1124
+ const allMappings = [...idMapping.values()];
1125
+ const ambiguityGroups = new Map();
1126
+ for (const dirName of existingDirs) {
1127
+ let bestSpecificity = -1;
1128
+ let tied = [];
1129
+ for (const mapping of allMappings) {
1130
+ if (!matchBracketSourceDir(dirName, mapping))
1131
+ continue;
1132
+ const specificity = bracketMappingSpecificity(mapping);
1133
+ if (specificity > bestSpecificity) {
1134
+ bestSpecificity = specificity;
1135
+ tied = [mapping];
1136
+ }
1137
+ else if (specificity === bestSpecificity) {
1138
+ tied.push(mapping);
1139
+ }
1140
+ }
1141
+ if (tied.length <= 1)
1142
+ continue;
1143
+ const groupKey = tied.map((mapping) => mapping.lineIndex).sort((a, b) => a - b).join(',');
1144
+ if (!ambiguityGroups.has(groupKey)) {
1145
+ ambiguityGroups.set(groupKey, { dirs: [], mappingLines: tied.map((mapping) => mapping.lineIndex) });
1146
+ }
1147
+ ambiguityGroups.get(groupKey).dirs.push(dirName);
1148
+ }
1149
+ for (const group of ambiguityGroups.values()) {
1150
+ // round 8 B1: only a directory SURPLUS is the silent-drop hazard this
1151
+ // check exists for; fewer (or exactly as many) directories than
1152
+ // candidates is left to the slug-driven resolution loop below.
1153
+ if (group.dirs.length > group.mappingLines.length) {
1154
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: '
1155
+ + `${group.dirs.length} phase director${group.dirs.length === 1 ? 'y' : 'ies'} `
1156
+ + `(${group.dirs.map((dir) => JSON.stringify(dir)).join(', ')}) tie with the same `
1157
+ + `${group.mappingLines.length} candidate phase heading(s) — an unresolvable count mismatch. `
1158
+ + 'Refusing rather than silently dropping a real directory from the plan:\n'
1159
+ + group.mappingLines.map((lineIndex) => ` ${lines[lineIndex]}`).join('\n'));
1160
+ }
1161
+ for (const dir of group.dirs)
1162
+ dirTieGroupSize.set(dir, group.dirs.length);
1163
+ }
1164
+ }
1165
+ const orderedMappings = [...idMapping.values()].map((mapping) => ({ mapping, used: false, claimedBy: null }));
1166
+ const phases = [];
1167
+ for (const dirName of existingDirs) {
1168
+ // #4698 Blocker 2: scan EVERY still-unused candidate and keep the MOST
1169
+ // SPECIFIC match (the one requiring the most integer segments), rather
1170
+ // than stopping at the first one found. A parent M-NN mapping is a
1171
+ // prefix of its own child's mapping and matches the child's directory
1172
+ // just as readily as the child's own mapping does — resolving on
1173
+ // encounter order let a parent heading that happens to precede its
1174
+ // child's heading claim the child's directory, leaving the true parent
1175
+ // unmapped. Specificity is independent of both roadmap-heading order and
1176
+ // directory-listing order, so this resolves correctly regardless of
1177
+ // which directory this loop visits first.
1178
+ //
1179
+ // #4144 round 6 B3: more than one candidate can tie at the SAME best
1180
+ // specificity — e.g. the identical legacy number "1" under two different
1181
+ // milestones (`## v1.0` Zeta, `## v2.0` Gamma) both structurally match a
1182
+ // directory named after either one, since `matchBracketSourceDir`'s
1183
+ // legacy branch does not know about milestones. Collect every tie rather
1184
+ // than keeping only the first-encountered one, then disambiguate by
1185
+ // comparing the directory's OWN slug against each candidate's phase name
1186
+ // slugified the same way `toDir` sanitizes one; exactly one match wins.
1187
+ //
1188
+ // #4144 round 7 W2, corrected round 8 W2: the plan-level check above
1189
+ // already refuses a genuine cardinality mismatch (more directories than
1190
+ // candidates in a shared tie group), so by the time this loop runs, any
1191
+ // directory whose tie shrinks to exactly one still-unused candidate
1192
+ // belongs to a group with dirs <= candidates. That is NOT the same as
1193
+ // saying the remaining candidate is automatically correct: in a
1194
+ // BALANCED group (dirs === candidates, e.g. exactly two real
1195
+ // directories for two real phases) it usually is, because every
1196
+ // directory in that group is claimed by its own true match in turn —
1197
+ // but when a stale duplicate-number directory is among them (`01-alpha`
1198
+ // / `01-alpha-old` both tying with {Alpha, Beta}), the real directory
1199
+ // claims its own phase by slug first and the stale leftover would
1200
+ // otherwise be handed the other phase by elimination alone, with no
1201
+ // slug agreement ever checked. See `dirTieGroupSize` below: a directory
1202
+ // that was never part of a multi-directory tie group may still skip
1203
+ // straight to its sole match with no check at all (there is no sibling
1204
+ // to confuse it with); one that WAS still gets a (prefix-tolerant, not
1205
+ // exact) sanity check against the sole remaining candidate.
1206
+ //
1207
+ // #4698: the scan runs over EVERY candidate, used or not, and only then
1208
+ // narrows to the unused ones. Scanning the unused candidates alone let
1209
+ // two directories that each uniquely match the same single heading
1210
+ // (`01-alpha` / `01-alpha-old` under one `### Phase 1: Alpha`; no tie
1211
+ // group, so the pre-check above never sees them) fall through: the
1212
+ // first visited claimed the heading, the second found nothing unused
1213
+ // and was silently skipped, left on disk under its legacy name after
1214
+ // ROADMAP.md was converted and the convention stamped. In an M-NN tree
1215
+ // the same scan was worse than a skip: a duplicate child directory
1216
+ // whose child heading was already claimed fell back to its still-unused
1217
+ // PARENT heading and was renamed as the parent phase. A directory that
1218
+ // structurally matches at least one heading now either resolves or
1219
+ // refuses; only a directory matching no heading at all is passed over.
1220
+ let bestSpecificity = -1;
1221
+ let matched = [];
1222
+ for (const candidate of orderedMappings) {
1223
+ const match = matchBracketSourceDir(dirName, candidate.mapping);
1224
+ if (!match)
1225
+ continue;
1226
+ const specificity = bracketMappingSpecificity(candidate.mapping);
1227
+ if (specificity > bestSpecificity) {
1228
+ bestSpecificity = specificity;
1229
+ matched = [{ candidate, match }];
1230
+ }
1231
+ else if (specificity === bestSpecificity) {
1232
+ matched.push({ candidate, match });
1233
+ }
1234
+ }
1235
+ if (matched.length === 0)
1236
+ continue;
1237
+ const tied = matched.filter((entry) => !entry.candidate.used);
1238
+ if (tied.length === 0) {
1239
+ const claimants = [...new Set(matched.map((entry) => entry.candidate.claimedBy).filter((name) => name !== null))];
1240
+ throw new Error(`Cannot resolve phase directory ${JSON.stringify(dirName)}: every phase heading it matches was already `
1241
+ + `claimed by another directory sharing its phase number (${claimants.map((name) => JSON.stringify(name)).join(', ')}). `
1242
+ + 'Refusing rather than silently leaving it unrenamed on disk; remove or rename the stale duplicate '
1243
+ + 'and re-run:\n'
1244
+ + matched.map((entry) => ` ${lines[entry.candidate.mapping.lineIndex]}`).join('\n'));
1245
+ }
1246
+ let winner = tied[0];
1247
+ if (tied.length > 1) {
1248
+ const dirSlug = slugify(tied[0].match.slug);
1249
+ const bySlug = tied.filter((entry) => slugify(entry.candidate.mapping.phaseName) === dirSlug);
1250
+ if (bySlug.length !== 1) {
1251
+ throw new Error(`Cannot resolve phase directory ${JSON.stringify(dirName)}: it matches ${tied.length} candidate `
1252
+ + `phase heading(s) with the same specificity and ${bySlug.length === 0 ? 'none of them' : 'more than one of them'} `
1253
+ + 'share its slug. Refusing rather than guessing which phase it identifies:\n'
1254
+ + tied.map((entry) => ` ${lines[entry.candidate.mapping.lineIndex]}`).join('\n'));
1255
+ }
1256
+ winner = bySlug[0];
1257
+ }
1258
+ else if ((dirTieGroupSize.get(dirName) ?? 1) > 1) {
1259
+ // #4144 round 8 W2: exactly one unused candidate remains for this
1260
+ // directory, but it was reached by ELIMINATION out of a
1261
+ // multi-directory tie group (another directory shares its legacy
1262
+ // number) — the round-6 depletion loop accepted whichever directory
1263
+ // is visited LAST in such a group without ever checking its slug,
1264
+ // which is exactly how a stale duplicate-number directory (`01-alpha`
1265
+ // / `01-alpha-old`, both tying with {Alpha, Beta}) ends up claiming
1266
+ // the OTHER real phase once the real directory has already claimed
1267
+ // its own. A directory's own slug is often an abbreviation of the
1268
+ // full phase name rather than an exact match (round 6 B3's r4e: dir
1269
+ // `01-zeta` for phase "Zeta - the return"), so this check is
1270
+ // prefix-tolerant in either direction rather than requiring exact
1271
+ // equality — but a slug that shares NO relation at all to the sole
1272
+ // remaining candidate (c2: "alpha-old" vs "beta") is refused rather
1273
+ // than silently handed a phase it never named.
1274
+ const dirSlug = slugify(tied[0].match.slug);
1275
+ const candidateSlug = slugify(tied[0].candidate.mapping.phaseName);
1276
+ const agrees = candidateSlug.startsWith(dirSlug) || dirSlug.startsWith(candidateSlug);
1277
+ if (!agrees) {
1278
+ throw new Error(`Cannot resolve phase directory ${JSON.stringify(dirName)}: another directory sharing its legacy `
1279
+ + 'number already claimed a different phase, and this directory\'s own slug shares no relation to '
1280
+ + 'the one remaining candidate phase heading. Refusing rather than guessing which phase it '
1281
+ + 'identifies:\n'
1282
+ + ` ${lines[tied[0].candidate.mapping.lineIndex]}`);
1283
+ }
1284
+ }
1285
+ winner.candidate.used = true;
1286
+ winner.candidate.claimedBy = dirName;
1287
+ const hit = winner.candidate;
1288
+ const matchedSlug = winner.match.slug;
1289
+ const matchedToken = winner.match.matchedToken;
1290
+ const newDir = buildBracketDirName(code, hit.mapping, matchedSlug, dirName);
1291
+ if (newDir !== dirName) {
1292
+ const oldDirPath = node_path_1.default.join(phasesDir, dirName);
1293
+ // #4698 Blocker 3: rename this directory's own phase-qualified
1294
+ // artifacts (computed against the OLD path — nothing has moved yet,
1295
+ // this is still plan computation) so they keep matching their
1296
+ // phase's new bracket token after the directory itself is renamed.
1297
+ const fileRenames = computeArtifactRenames(dirName, oldDirPath, matchedToken, hit.mapping.token);
1298
+ // #4698 Blocker 1 (round 2): rewrite depends_on references (in this
1299
+ // same directory's own *-PLAN.md files) that name a sibling by the
1300
+ // OLD token — see computeDependsOnRewrites' docblock. Also computed
1301
+ // against the OLD path; nothing has moved yet.
1302
+ const dependsOnRewrites = computeDependsOnRewrites(oldDirPath, matchedToken, hit.mapping.token, fileRenames);
1303
+ phases.push({
1304
+ oldId: hit.mapping.sourceToken,
1305
+ newId: `${code}.${pad2(hit.mapping.milestoneInt)}-${hit.mapping.token}`,
1306
+ oldDir: dirName,
1307
+ newDir,
1308
+ fileRenames,
1309
+ // #4144 round 6 (W-phase): also rewrite any renamed artifact's
1310
+ // `phase:` frontmatter scalar that still spells the OLD token — see
1311
+ // computePhaseFrontmatterRewrites' docblock. Combined with the
1312
+ // depends_on rewrites above (not a second independent write) so a
1313
+ // file needing both never has one silently clobber the other.
1314
+ dependsOnRewrites: computePhaseFrontmatterRewrites(oldDirPath, dirName, matchedToken, hit.mapping.token, fileRenames, dependsOnRewrites),
1315
+ });
1316
+ }
1317
+ }
1318
+ // #4144 round 6 (W-collision): the plan never checked that a TARGET
1319
+ // directory name was free, so a dry-run reported a clean plan `apply`
1320
+ // could not perform (a pre-existing NON-EMPTY target fails mid-apply with
1321
+ // ENOTEMPTY, rollback restores) or silently REPLACED (a pre-existing EMPTY
1322
+ // target — POSIX rename semantics). Every other collision class in this
1323
+ // file (artifact renames above, token collisions in assignBracketTokens,
1324
+ // depends_on aliases) is refused at PLAN time; this closes the one
1325
+ // remaining seam left to apply. A target occupied by another directory
1326
+ // THIS SAME PLAN is also renaming away is not a collision — that directory
1327
+ // vacates the name as part of this same migration.
1328
+ const renamedOldDirs = new Set(phases.map((rename) => rename.oldDir));
1329
+ for (const rename of phases) {
1330
+ if (renamedOldDirs.has(rename.newDir))
1331
+ continue;
1332
+ if (node_fs_1.default.existsSync(node_path_1.default.join(phasesDir, rename.newDir))) {
1333
+ throw new Error(`Cannot migrate phase directory ${JSON.stringify(rename.oldDir)} to ${JSON.stringify(rename.newDir)}: `
1334
+ + 'a directory already exists at that path and is not itself part of this migration. Refusing '
1335
+ + 'rather than overwriting or colliding with it at apply time.');
1336
+ }
1337
+ }
1338
+ const roadmapEdits = [];
1339
+ for (const entry of sourcePhases) {
1340
+ const mapping = idMapping.get(entry.lineIndex);
1341
+ if (!mapping)
1342
+ continue;
1343
+ const oldLine = lines[entry.lineIndex];
1344
+ // #4698 Blocker 3 (round 2): the tag (if any) is re-inserted right after
1345
+ // the phase token and before the colon — the exact slot the real bracket
1346
+ // heading grammar accepts one in (see LEGACY_PHASE_HEADING_BRACKET_RE's
1347
+ // docblock).
1348
+ const heading = `${entry.hashes} [${code}.${pad2(mapping.milestoneInt)}] ${mapping.token}${entry.headingTag ?? ''}:`;
1349
+ const newLine = heading + (entry.headingTail ?? '');
1350
+ if (newLine !== oldLine) {
1351
+ roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
1352
+ }
1353
+ }
1354
+ // #4144 round 6 B4: the READERS' own checklist grammar
1355
+ // (`src/roadmap.cts`'s `cmdRoadmapAnalyze checklistPattern`, the exact
1356
+ // scan `missing_phase_details` is computed from) requires a bold `**`
1357
+ // immediately before the `Phase` label and — unlike the previous regex
1358
+ // here — never requires a colon anywhere after the token:
1359
+ // `- [ ] **Phase 7** - Eta` and `- [x] **Phase 8**: Theta` are both real
1360
+ // phase references to it. A colon-requiring copy left such bullets
1361
+ // byte-identical while their headings converted, so a "done" migration
1362
+ // reported them missing (`missing_phase_details`). This migrator keeps its
1363
+ // own long-standing OPTIONAL-bold tolerance (`\*{0,2}` — a plain,
1364
+ // non-bold `- [x] Phase 3: Gamma` has converted since round 1 and must
1365
+ // keep doing so), so the actual fix is dropping the colon requirement, not
1366
+ // narrowing to bold-only: the "Phase " intro comes from the shared
1367
+ // `phaseHeadingPrefixSrcFor` selector (never a second copy of that text),
1368
+ // and the bullet/checkbox/bold-open prefix is its own capture group
1369
+ // (group 1) — the "Phase " intro text itself is matched but deliberately
1370
+ // left OUT of any group, since the bracket form drops the word "Phase"
1371
+ // entirely. The token and optional tag are captured here, and the REST OF
1372
+ // THE LINE — bold close, tag, separator, colon, whatever follows — is
1373
+ // preserved verbatim via `line.slice(match[0].length)` below, never
1374
+ // reconstructed.
1375
+ // #4144 round 7 W3: `[-*]`, not a literal `-` — the same list-marker class
1376
+ // `BULLET_PHASE_LINE_PATTERN` (roadmap-parser.cts:1446, the scanner
1377
+ // `scanMilestonePhaseIds` uses) already accepts. A `* [ ] **Phase 1: …**`
1378
+ // bullet stayed legacy after an otherwise-done migration, so the
1379
+ // bracket-mode milestone id set carried the stale legacy token alongside
1380
+ // the bracket ones.
1381
+ const CHECKLIST_BULLET_PREFIX_SRC = '[-*]\\s*\\[[ x]\\]\\s*\\*{0,2}';
1382
+ const CHECKLIST_PHASE_INTRO_SRC = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, undefined, true);
1383
+ const mnnChecklistRe = new RegExp(`^(\\s*${CHECKLIST_BULLET_PREFIX_SRC})${CHECKLIST_PHASE_INTRO_SRC}(${MNN_SOURCE_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})`, 'i');
1384
+ const legacyChecklistRe = new RegExp(`^(\\s*${CHECKLIST_BULLET_PREFIX_SRC})${CHECKLIST_PHASE_INTRO_SRC}(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})`, 'i');
1385
+ // #4144 round 8 I1: `legacyChecklistRe` above requires nothing at all
1386
+ // after the number/tag — deliberately, since Tier 1 (reader-recognized,
1387
+ // `isReaderRecognizedBullet` below) must match exactly what the readers'
1388
+ // own grammar matches, and neither `roadmap.cts:770`'s checklistPattern
1389
+ // nor `phase.cts`'s heading branch require a boundary there either (a
1390
+ // bold `**Phase 1-on-1 meetings**` is read by every reader as naming
1391
+ // phase 1, trailing text and all — narrowing the MATCH itself would
1392
+ // silently un-recognize exactly the reader-recognized bullets round 6 B4
1393
+ // exists to keep converting). Tier 2 (this migrator's own wider,
1394
+ // non-bold tolerance) has no such reader grammar to mirror, so it applies
1395
+ // its own separate, narrower boundary check below: mirroring
1396
+ // `phase.cts:4358-4361`'s checkbox-branch separator (colon/em-dash/
1397
+ // en-dash/hyphen) and `BULLET_PHASE_LINE_PATTERN`'s dash family rather
1398
+ // than inventing a fourth grammar, plus the ordinary "end of a plain
1399
+ // word" case a bare-prose to-do bullet needs (`Phase 2 retrospective`).
1400
+ // Without it, Tier 2 renumbered prose that merely STARTS WITH a phase
1401
+ // number and continues as a hyphenated compound word, e.g. `Phase
1402
+ // 1-on-1 meetings` -> `[GSD.01] 01-on-1 meetings` — the hyphen glued
1403
+ // directly onto the number with no separating whitespace is the one shape
1404
+ // every boundary alternative below rejects; a colon/dash preceded by
1405
+ // whitespace (or attached directly, for colon/em-dash/en-dash only, the
1406
+ // same tolerance the heading and bullet-line grammars already give those
1407
+ // three), a bold-close `**`, end of line, or whitespace before an
1408
+ // ordinary (non-hyphen) word all still pass.
1409
+ const TIER2_TOKEN_BOUNDARY_RE = /^(?:\s*[:—–]|\s+-|\*\*|$|\s+[^\s-])/;
1410
+ // #4144 round 6 (C-fence): fence handling was heading-only —
1411
+ // parseBracketSourcePhases skips fenced lines via `fencedLineIndices`
1412
+ // (round 5 W4), but this checklist loop walked raw `lines` with no fence
1413
+ // awareness at all, so a checklist bullet inside a fenced EXAMPLE block
1414
+ // was still rewritten. Same engine, same "never a phase heading/bullet of
1415
+ // any kind inside a fence" rule the heading parse already follows.
1416
+ const fencedChecklistLines = fencedLineIndices(lines);
1417
+ for (let i = 0; i < lines.length; i++) {
1418
+ if (fencedChecklistLines.has(i))
1419
+ continue;
1420
+ const line = lines[i];
1421
+ if (MILESTONE_HEADING_RE.test(line))
1422
+ continue;
1423
+ if (roadmapEdits.some((edit) => edit.lineIndex === i))
1424
+ continue;
1425
+ const mnnChecklist = line.match(mnnChecklistRe);
1426
+ if (mnnChecklist) {
1427
+ const segments = mnnChecklist[2].split('-');
1428
+ const milestone = parseInt(segments[0], 10);
1429
+ const token = segments.slice(1).map((segment) => pad2(parseInt(segment, 10))).join('.');
1430
+ const replacement = `${mnnChecklist[1]}[${code}.${pad2(milestone)}] ${token}${mnnChecklist[3]}`;
1431
+ roadmapEdits.push({
1432
+ lineIndex: i,
1433
+ from: line,
1434
+ to: replacement + line.slice(mnnChecklist[0].length),
1435
+ });
1436
+ continue;
1437
+ }
1438
+ const legacyChecklist = line.match(legacyChecklistRe);
1439
+ if (!legacyChecklist)
1440
+ continue;
1441
+ const key = legacyLookupKey(legacyChecklist[2]);
1442
+ // #4144 round 7 B2: the READERS' own checklist grammar
1443
+ // (`src/roadmap.cts` checklistPattern, the exact scan
1444
+ // `missing_phase_details` is computed from) requires an EXACT bold `**`
1445
+ // immediately before the `Phase` label. This migrator's own
1446
+ // long-standing tolerance additionally accepts 0 or 1 asterisks
1447
+ // (`\*{0,2}` in CHECKLIST_BULLET_PREFIX_SRC above) — captured group 1
1448
+ // therefore ends in `**` if and only if the bullet is the exact shape a
1449
+ // reader recognizes. A bullet the readers recognize that cannot be
1450
+ // attributed to any converted phase is a genuine partial-conversion
1451
+ // hazard and must refuse; a bullet only THIS migrator's wider tolerance
1452
+ // matches is not a phase reference to any reader — converted when it
1453
+ // resolves (keeps the long-standing non-bold conversion case green),
1454
+ // left byte-identical rather than refused when it does not.
1455
+ const isReaderRecognizedBullet = /\*\*$/.test(legacyChecklist[1]);
1456
+ // #4144 round 8 I1: Tier 2 additionally requires a token boundary right
1457
+ // after the number/tag (see `TIER2_TOKEN_BOUNDARY_RE` above) — Tier 1
1458
+ // never does, since narrowing the shared match itself would silently
1459
+ // un-recognize a bold bullet the readers' own ungated grammar still
1460
+ // reads as naming that phase. A Tier-2 bullet that fails the boundary
1461
+ // (`- [ ] Phase 1-on-1 meetings`) is treated exactly like one no reader
1462
+ // recognizes at all: left byte-identical, never resolved or refused.
1463
+ if (!isReaderRecognizedBullet && !TIER2_TOKEN_BOUNDARY_RE.test(line.slice(legacyChecklist[0].length))) {
1464
+ continue;
1465
+ }
1466
+ let resolved;
1467
+ // #4144 round 7 B1: a checklist bullet naming a SENTINEL phase (999.x
1468
+ // icebox / 0.x backlog) bypasses section attribution entirely — the same
1469
+ // rule the HEADING side already applies via `legacySentinelMilestone`
1470
+ // (consulted before `entry.attributedSection` is ever set, above).
1471
+ // Sentinel entries are always filed under GLOBAL_SECTION_KEY (never
1472
+ // `attributedSection`, since the milestone-attribution loop `continue`s
1473
+ // past that assignment for a sentinel), so a sentinel bullet must be
1474
+ // looked up there directly — never in whichever real `## vN.M` section
1475
+ // its own line happens to sit inside on disk (nothing stops an author
1476
+ // from listing an icebox item next to the real phases it is scheduled
1477
+ // near).
1478
+ const bulletSentinelMilestone = legacySentinelMilestone(legacyChecklist[2]);
1479
+ if (bulletSentinelMilestone !== null) {
1480
+ resolved = sectionLegacyMap.get(GLOBAL_SECTION_KEY)?.get(key);
1481
+ }
1482
+ else {
1483
+ // #4144 round 6 B2: a bullet INSIDE a specific milestone section is
1484
+ // attributed to THAT section alone — never a different section that
1485
+ // happens to share the same leading major integer.
1486
+ const bulletSection = sectionsForOffset(lineOffsets[i] ?? -1)
1487
+ .find((section) => section.milestoneInt !== null) ?? null;
1488
+ if (bulletSection) {
1489
+ resolved = sectionLegacyMap.get(bulletSection.start)?.get(key);
1490
+ }
1491
+ else {
1492
+ // #4144 round 8 B2 (was round 7 B2): outside every section, a
1493
+ // checklist bullet resolves the way round 6 did, for EITHER tier —
1494
+ // first against the no-section bucket itself (an exact,
1495
+ // unambiguous per-key lookup: entries with no attributed section,
1496
+ // such as an entire roadmap derived from a single STATE.md fallback
1497
+ // milestone with no `## vN.M` headings at all, are filed here), and
1498
+ // only when that bucket does not define the token, against the
1499
+ // unique candidate across every bucket. Round 7 gated this whole
1500
+ // lookup on `isReaderRecognizedBullet`, so a non-bold (Tier 2)
1501
+ // bullet outside every section was never resolved at all — on the
1502
+ // committed project-prefixed-single-milestone fixture (no sections,
1503
+ // everything filed under the no-section bucket) that left
1504
+ // `- [ ] Phase 1: Intake` / `- [ ] Phase 2: Delivery` legacy after
1505
+ // an otherwise-"done" migration.
1506
+ resolved = sectionLegacyMap.get(GLOBAL_SECTION_KEY)?.get(key);
1507
+ if (!resolved) {
1508
+ const candidates = [];
1509
+ for (const lookup of sectionLegacyMap.values()) {
1510
+ const candidate = lookup.get(key);
1511
+ if (candidate)
1512
+ candidates.push(candidate);
1513
+ }
1514
+ if (candidates.length > 1) {
1515
+ // #4144 round 8 B2: only a bullet the readers themselves
1516
+ // recognize as a phase reference can turn this ambiguity into a
1517
+ // refusal — Tier 2's own wider tolerance leaves it byte-identical
1518
+ // instead (the `!resolved` branch below), never guessing and
1519
+ // never blocking the whole migration over a to-do bullet no
1520
+ // reader was ever going to check.
1521
+ if (isReaderRecognizedBullet) {
1522
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: a checklist bullet outside every '
1523
+ + 'milestone section names a legacy phase number that more than one milestone section defines. '
1524
+ + 'Refusing rather than guessing which phase it means:\n'
1525
+ + ` ${line}`);
1526
+ }
1527
+ }
1528
+ else {
1529
+ resolved = candidates[0];
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ if (!resolved) {
1535
+ if (bulletSentinelMilestone !== null) {
1536
+ // #4144 round 8 W1: a sentinel bullet (999.x icebox / 0.x backlog)
1537
+ // with no converted sentinel phase to resolve to — e.g. an icebox
1538
+ // item with no matching `### Phase 999.x:` heading anywhere — is
1539
+ // left byte-identical rather than refused. `roadmap analyze`
1540
+ // deliberately excludes sentinel checklist entries from
1541
+ // `missing_phase_details` (roadmap.cts:795-798, `!isSentinelPhase`),
1542
+ // so the "a reader would report it missing" rationale the Tier-1
1543
+ // refusal below exists for does not apply here: every reader still
1544
+ // classifies the unconverted line as sentinel under bracket too
1545
+ // (its checklist grammar captures no bracket id, and
1546
+ // `isSentinelPhaseId` falls back to the same bare-999/0.x rule
1547
+ // whether or not the line converted).
1548
+ continue;
1549
+ }
1550
+ if (!isReaderRecognizedBullet) {
1551
+ // #4144 round 7 B2: no reader treats this bullet as a phase
1552
+ // reference (roadmap.cts:770 requires bold, roadmap-parser.cts:1446
1553
+ // requires `**Phase`, phase.cts's checkbox branch requires a
1554
+ // separator after the token) — there is nothing for a "done"
1555
+ // migration to have missed. Left byte-identical, never refused.
1556
+ continue;
1557
+ }
1558
+ // #4144 round 6 B4: the readers' own grammar recognizes this bullet as
1559
+ // a phase reference (mnnChecklistRe/legacyChecklistRe just matched
1560
+ // it), but this migrator could not attribute it to any converted
1561
+ // phase. Leaving it byte-identical would stamp the migration done
1562
+ // while a reader-recognized checklist entry stays unconverted —
1563
+ // refuse before any write instead, naming the bullet.
1564
+ throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: a checklist bullet the readers '
1565
+ + 'recognize as a phase reference could not be attributed to any converted phase. Refusing rather '
1566
+ + 'than leaving it unconverted in an otherwise-migrated roadmap:\n'
1567
+ + ` ${line}`);
1568
+ }
1569
+ const replacement = `${legacyChecklist[1]}[${code}.${pad2(resolved.milestoneInt)}] ${resolved.token}`
1570
+ + `${legacyChecklist[3]}`;
1571
+ roadmapEdits.push({
1572
+ lineIndex: i,
1573
+ from: line,
1574
+ to: replacement + line.slice(legacyChecklist[0].length),
1575
+ });
1576
+ }
1577
+ // Bare STATE.md / PROJECT.md `Phase N` prose carries no milestone context in
1578
+ // multi-milestone repositories. That emit concern remains deferred; guessing
1579
+ // here would recreate the ambiguity this migration removes.
1580
+ const crossRefEdits = [];
1581
+ return {
1582
+ alreadyMigrated: false,
1583
+ phases,
1584
+ roadmapEdits,
1585
+ crossRefEdits,
1586
+ targetConvention: 'bracket',
1587
+ };
1588
+ }
131
1589
  // ─── computeMigrationPlan ─────────────────────────────────────────────────────
132
1590
  /**
133
1591
  * Compute a migration plan without touching the filesystem.
134
1592
  */
135
1593
  function computeMigrationPlan(cwd, options = {}) {
136
- void options;
1594
+ if (options['convention'] === 'bracket')
1595
+ return computeBracketPlan(cwd);
137
1596
  const pDir = planningDir(cwd);
138
1597
  const roadmapPath = node_path_1.default.join(pDir, 'ROADMAP.md');
139
1598
  const configPath = node_path_1.default.join(pDir, 'config.json');
@@ -446,7 +1905,13 @@ function applyMigration(cwd, plan, options = {}) {
446
1905
  }
447
1906
  };
448
1907
  try {
449
- // 1. Rename phase directories
1908
+ // 1. Rename phase directories, then (#4698 Blocker 3) any phase-qualified
1909
+ // artifact filenames inside them whose names still spell the
1910
+ // pre-migration phase token. File renames are recorded onto the SAME
1911
+ // `performedRenames` list, immediately after their own directory's
1912
+ // entry — rollback below walks this list newest-first, so a file rename
1913
+ // is always reversed before the directory rename that contains it, which
1914
+ // is the only order that can succeed.
450
1915
  for (const phaseEntry of plan.phases) {
451
1916
  const oldPath = node_path_1.default.join(phasesDir, phaseEntry.oldDir);
452
1917
  const newPath = node_path_1.default.join(phasesDir, phaseEntry.newDir);
@@ -454,6 +1919,38 @@ function applyMigration(cwd, plan, options = {}) {
454
1919
  (0, shell_command_projection_cjs_1.retryRenameSync)(oldPath, newPath);
455
1920
  performedRenames.push({ oldPath, newPath });
456
1921
  renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
1922
+ for (const fileRename of phaseEntry.fileRenames ?? []) {
1923
+ const oldFilePath = node_path_1.default.join(newPath, fileRename.oldName);
1924
+ const newFilePath = node_path_1.default.join(newPath, fileRename.newName);
1925
+ if (node_fs_1.default.existsSync(oldFilePath)) {
1926
+ (0, shell_command_projection_cjs_1.retryRenameSync)(oldFilePath, newFilePath);
1927
+ performedRenames.push({ oldPath: oldFilePath, newPath: newFilePath });
1928
+ }
1929
+ }
1930
+ // #4698 Blocker 1 (round 2): rewrite depends_on references, AFTER
1931
+ // the artifact renames above so `rewrite.finalName` already exists
1932
+ // on disk at its final name. The backup is keyed by the file's
1933
+ // ORIGINAL pre-migration absolute path (`oldPath`, this directory's
1934
+ // own pre-rename path — still a valid STRING even though nothing
1935
+ // lives there right now) rather than its current path: the rollback
1936
+ // below reverses every entry in `performedRenames` (this directory's
1937
+ // rename AND its file renames) BEFORE it ever consults
1938
+ // `fileBackups`, so by the time that restore runs, this exact file
1939
+ // is already back at `oldPath`/`rewrite.oldName` — which is the only
1940
+ // path the backup can be keyed by for the restore to land correctly.
1941
+ // Content, unlike a path, is never touched by the rename reversal,
1942
+ // so keying by the post-rollback path is the only order that works.
1943
+ for (const rewrite of phaseEntry.dependsOnRewrites ?? []) {
1944
+ const currentPath = node_path_1.default.join(newPath, rewrite.finalName);
1945
+ if (node_fs_1.default.existsSync(currentPath)) {
1946
+ const originalPath = node_path_1.default.join(oldPath, rewrite.oldName);
1947
+ if (!fileBackups.has(originalPath)) {
1948
+ fileBackups.set(originalPath, { existed: true, content: rewrite.from });
1949
+ }
1950
+ node_fs_1.default.writeFileSync(currentPath, rewrite.to, 'utf8');
1951
+ editedFiles.push(node_path_1.default.join('phases', phaseEntry.newDir, rewrite.finalName));
1952
+ }
1953
+ }
457
1954
  }
458
1955
  }
459
1956
  // 2. Rewrite ROADMAP.md phase headings
@@ -491,16 +1988,38 @@ function applyMigration(cwd, plan, options = {}) {
491
1988
  editedFiles.push(fileName);
492
1989
  }
493
1990
  }
494
- // 4. Update config.json: set phase_id_convention to 'milestone-prefixed'
495
- let configData = {};
496
- try {
497
- configData = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
1991
+ // 4. Update config.json to the convention named by this plan — but only
1992
+ // when the plan actually converted at least one IDENTITY (#4698 Blocker
1993
+ // 1/2). `plan.phases` holds DIRECTORY renames, not converted HEADINGS —
1994
+ // gating on `phases.length` alone left a roadmap with recognizable
1995
+ // headings but zero phase directories on disk (nothing to rename) with
1996
+ // its ROADMAP.md already rewritten to the target convention's text while
1997
+ // config stayed unset, and Blocker 2's own mixed/partial guard then
1998
+ // permanently refuses every retry (headings already read as the target
1999
+ // convention, config does not). An empty plan (e.g. a roadmap this
2000
+ // migrator failed to recognize) must still never stamp
2001
+ // phase_id_convention: computeBracketPlan's own idempotency guard treats
2002
+ // that stamp as proof the migration already finished, so a write here
2003
+ // with nothing converted would make the correct re-run (once the roadmap
2004
+ // is fixed) permanently unreachable. Applies identically to both targets
2005
+ // — legacy (milestone-prefixed) plans omit targetConvention and retain
2006
+ // the historical default.
2007
+ if (plan.phases.length > 0 || plan.roadmapEdits.length > 0) {
2008
+ let configData = {};
2009
+ try {
2010
+ configData = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
2011
+ }
2012
+ catch { /* config may not exist yet */ }
2013
+ configData['phase_id_convention'] = plan.targetConvention ?? 'milestone-prefixed';
2014
+ snapshotFile(configPath);
2015
+ try {
2016
+ node_fs_1.default.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
2017
+ }
2018
+ catch (err) {
2019
+ throw new Error(`config.json write phase failed: ${err.message}`);
2020
+ }
2021
+ editedFiles.push('config.json');
498
2022
  }
499
- catch { /* config may not exist yet */ }
500
- configData['phase_id_convention'] = 'milestone-prefixed';
501
- snapshotFile(configPath);
502
- node_fs_1.default.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
503
- editedFiles.push('config.json');
504
2023
  }
505
2024
  catch (err) {
506
2025
  // Surgical rollback: reverse the renames (newest first) and restore every
@@ -531,4 +2050,11 @@ function applyMigration(cwd, plan, options = {}) {
531
2050
  module.exports = {
532
2051
  computeMigrationPlan,
533
2052
  applyMigration,
2053
+ computeDependsOnRewrites,
2054
+ // #4698 review round 28: exported for the property test that drives the REAL
2055
+ // roadmap transform (the same function applyMigration writes through) rather
2056
+ // than a hand-rolled line-replacer stand-in, per ADR-1508's precedent of
2057
+ // exporting an internal solely to satisfy RULESET.TESTS.property-based-testing.
2058
+ // Not a new production seam: applyMigration remains its only caller in `src/`.
2059
+ applyRoadmapEdits,
534
2060
  };