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