@opengsd/gsd-core 1.18.0 → 1.20.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 (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -42,25 +42,47 @@ const coreUtilsMod = require("./core-utils.cjs");
42
42
  const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
43
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
44
44
  const phaseIdMod = require("./phase-id.cjs");
45
- const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, buildPhaseHeadingScanRegex, } = phaseIdMod;
45
+ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, phaseLookupQuery, phaseNumberForMatch, phaseKeyFromDir, customPhaseDirectoryParts, customDirectoryOwner, phaseScanTokenSource, CUSTOM_PHASE_ID_FILE, isCustomPhaseId, isSentinelPhaseId, scopeToPhase, parsePhaseId, renderPhaseId, renderMilestoneId, toDir, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, buildPhaseDeclarationScans, BRACKET_DIR_PREFIX_SRC, tokenizePhaseDependencyReferences, foldBracketId, } = phaseIdMod;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const phaseIdDisplayMod = require("./phase-id-display.cjs");
48
+ // #4304: reuse the Phase Id Display Module's milestone/phase
49
+ // numeric canonicalization instead of re-deriving it here — see
50
+ // bracketWriteContext/bracketPhaseId below.
51
+ const { milestoneToken, phaseToken } = phaseIdDisplayMod;
52
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
46
53
  const pattern_cjs_1 = require("./pattern.cjs");
47
54
  const gate_exit_cjs_1 = require("./gate-exit.cjs");
48
55
  const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
49
56
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
50
57
  const phaseLocatorMod = require("./phase-locator.cjs");
51
- const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
58
+ const { phaseDirectoryOptions, findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup, } = phaseLocatorMod;
52
59
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
53
60
  const planningScopeMod = require("./planning-scope.cjs");
54
61
  const { SCOPE } = planningScopeMod;
55
62
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
56
63
  const roadmapParserMod = require("./roadmap-parser.cjs");
57
- const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
64
+ const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo, scanMilestonePhaseIds,
65
+ // #4304: every recognized milestone heading in the document,
66
+ // not just the active one — see bracketProgressSectionOwnedByOtherMilestone.
67
+ listMilestoneHeadings, selectMilestoneHeading,
68
+ // #4304: the SAME bracket milestone-boundary grammar the
69
+ // window locator uses (bracketAwareMilestoneSection), reused so the
70
+ // marker set below recognizes a version-less bracket milestone heading
71
+ // too, not only a version-token one.
72
+ isBracketMilestoneBoundary,
73
+ // #4304: the SAME closed/shipped-heading predicate
74
+ // currentMilestoneRawRanges uses, reused so the pre-mutation window guard
75
+ // (bracketOwnedLineOutsideActiveWindow) can recognize an archived section
76
+ // instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
77
+ isClosedMilestoneHeading, isClosedMilestoneDetails, isRecognizedMilestoneHeading, isPhaseEntryHeading, activeScopeStart, } = roadmapParserMod;
58
78
  // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
59
79
  // (pure computation, no I/O — no cycle on this path) for the intent-first
60
80
  // progress counters the phase-complete transaction passes downstream.
61
81
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
62
82
  const phaseLifecycleMod = require("./phase-lifecycle.cjs");
63
83
  const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
84
+ // #5761: the Progress table locator and milestone-identity helpers live in their one owner.
85
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
64
86
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
65
87
  const planningWorkspace = require("./planning-workspace.cjs");
66
88
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -73,12 +95,13 @@ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
73
95
  const clock_cjs_1 = require("./clock.cjs");
74
96
  const state_transition_cjs_1 = require("./state-transition.cjs");
75
97
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
98
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
99
+ const security_cjs_1 = require("./security.cjs");
76
100
  // #5060: the Phase Status Module owns the ROADMAP Status-cell token vocabulary.
77
101
  const phase_status_cjs_1 = require("./phase-status.cjs");
78
- const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
79
102
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap.cjs is an export= CommonJS module
80
103
  const roadmapMod = require("./roadmap.cjs");
81
- const { buildPhaseHeadingRegex } = roadmapMod;
104
+ const { buildPhaseHeadingRegex, findPhaseTableHeader, editProgressTableSlice, foldPhaseId, insideOpenDetails, checklistEntrySrc } = roadmapMod;
82
105
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
83
106
  const uatPredicate = require("./uat-predicate.cjs");
84
107
  const { evaluateUatPassed } = uatPredicate;
@@ -102,6 +125,10 @@ const milestoneLockMod = require("./milestone-lock.cjs");
102
125
  const planDocumentMod = require("./plan-document.cjs");
103
126
  const { parsePlanDocument, planIdFromFile } = planDocumentMod;
104
127
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, assertVerificationReportsReadable, withStateLock, updatePerformanceMetricsSection, } = stateMod;
128
+ // #4304: matchPhaseDirs admits legacy directories under bracket mode for
129
+ // migration compatibility. Only a directory carrying the owner grammar's
130
+ // bracket prefix may enter bracket-only result parsing.
131
+ const BRACKET_DIRECTORY_PREFIX_RE = new RegExp(`^${BRACKET_DIR_PREFIX_SRC}`);
105
132
  // Any .md file with PLAN anywhere in the basename — diagnostic net
106
133
  const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
107
134
  const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
@@ -109,43 +136,6 @@ const looksLikePlanFile = (f) => /\.md$/i.test(f) &&
109
136
  /PLAN/i.test(f) &&
110
137
  !PLAN_OUTLINE_RE.test(f) &&
111
138
  !PLAN_PRE_BOUNCE_RE.test(f);
112
- /**
113
- * Scope an `updateTableCell` call to the `## Traceability` (or
114
- * `## Traceability Status`) heading's own section — up to the next H1/H2
115
- * heading — instead of handing it the WHOLE REQUIREMENTS.md content.
116
- *
117
- * F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
118
- * found in whatever text it is given. The shipped requirements template
119
- * (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
120
- * (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
121
- * an unscoped whole-file call targets the Out-of-Scope table instead, fails
122
- * with `{ok:false, reason:'unknown column: Status'}`, and the real
123
- * Traceability row is never flipped, while the checkbox surface still flips
124
- * and the command reports success (the #2140 silent-divergence class one
125
- * level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
126
- * `## Progress` writes to that heading's own slice for the same reason.
127
- *
128
- * Falls back to running `updateTableCell` against the whole `text` when no
129
- * `## Traceability` heading exists — matching the previous (unscoped)
130
- * behaviour for a REQUIREMENTS.md whose traceability table sits under some
131
- * other heading, or with no heading at all (never worse than before this fix).
132
- */
133
- function updateTraceabilityCell(text, match, column, newValue) {
134
- const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
135
- if (!headingMatch || headingMatch.index === undefined) {
136
- return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
137
- }
138
- const headingOffset = headingMatch.index;
139
- const before = text.slice(0, headingOffset);
140
- const fromHeading = text.slice(headingOffset);
141
- const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
142
- const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
143
- const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
144
- const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
145
- if (!result.ok)
146
- return result;
147
- return { ok: true, value: before + result.value + after };
148
- }
149
139
  /**
150
140
  * Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
151
141
  * "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
@@ -213,11 +203,14 @@ function cmdPhasesList(cwd, options, raw) {
213
203
  if (phase) {
214
204
  // LOOKUP (b): search the physical set, plus archived when asked.
215
205
  const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
216
- const normalized = normalizePhaseName(phase);
206
+ const directoryContext = phaseDirectoryOptions(cwd);
207
+ const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
217
208
  // The pool is #3185's (physical set + archived); the matcher is this
218
209
  // PR's. `dirs` is deliberately not read here: on this base it is not
219
- // assigned until the branch below picks a match.
220
- const { matches } = matchPhaseDirs(lookupPool, normalized);
210
+ // assigned until the branch below picks a match. Under bracket, the
211
+ // active project+milestone identity wins before migration-window legacy
212
+ // names; differently-qualified historical dirs are never first-picked.
213
+ const { matches } = matchPhaseDirsForLookup(lookupPool, lookup, undefined, directoryContext);
221
214
  const match = matches[0];
222
215
  if (!match) {
223
216
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
@@ -231,7 +224,8 @@ function cmdPhasesList(cwd, options, raw) {
231
224
  const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
232
225
  phaseScope = enumerated.scope;
233
226
  dirs = [...enumerated.value, ...archivedLabels];
234
- dirs.sort((a, b) => comparePhaseNum(a, b));
227
+ const directoryContext = phaseDirectoryOptions(cwd);
228
+ dirs.sort((a, b) => comparePhaseNum(a, b, directoryContext));
235
229
  }
236
230
  if (type) {
237
231
  const files = [];
@@ -296,45 +290,70 @@ function cmdPhasesList(cwd, options, raw) {
296
290
  }
297
291
  function cmdPhaseNextDecimal(cwd, basePhase, raw) {
298
292
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
299
- const normalized = normalizePhaseName(basePhase);
293
+ const lookup = resolvePhaseDirectoryLookup(cwd, basePhase);
294
+ const normalized = lookup.normalized;
295
+ // #4304: base existence and decimal-child inventory must resolve through
296
+ // the same convention. The former was bracket-aware while
297
+ // the latter still called the legacy-only scanner, proposing an occupied
298
+ // bracket sub-phase. Legacy conventions retain their historical call and
299
+ // output spelling byte-for-byte.
300
+ const convention = resolvePhaseIdConvention(cwd) === 'bracket' ? 'bracket' : undefined;
301
+ const bracketContext = convention === 'bracket'
302
+ ? bracketWriteContext(cwd, loadConfig(cwd))
303
+ : null;
300
304
  try {
301
305
  let baseExists = false;
302
306
  const decimalSet = new Set();
307
+ const bracketScanMeta = { bracketSpellingFound: false };
308
+ const scanDecimals = (roadmapContent) => {
309
+ const found = bracketContext
310
+ ? scanExistingBracketDecimalPhaseNumbers(phasesDir, roadmapContent ? extractCurrentMilestone(roadmapContent, cwd) : '', normalized, bracketContext, bracketScanMeta)
311
+ : scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized);
312
+ for (const n of found)
313
+ decimalSet.add(n);
314
+ };
303
315
  if (node_fs_1.default.existsSync(phasesDir)) {
304
316
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
305
317
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
306
- baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
318
+ baseExists = matchPhaseDirs(dirs, normalized, convention, lookup.bracketContext, phaseDirectoryOptions(cwd)).matches.length > 0;
307
319
  }
308
320
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
309
321
  if (node_fs_1.default.existsSync(roadmapPath)) {
310
322
  try {
311
323
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
312
- for (const n of scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized)) {
313
- decimalSet.add(n);
314
- }
324
+ scanDecimals(roadmapContent);
315
325
  }
316
326
  catch {
317
327
  // ROADMAP.md read failure is non-fatal — fall back to the directory-only
318
328
  // scan (empty rawContent) so on-disk decimal directories are still counted.
319
- for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
320
- decimalSet.add(n);
321
- }
329
+ scanDecimals('');
322
330
  }
323
331
  }
324
332
  else {
325
- for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
326
- decimalSet.add(n);
327
- }
333
+ scanDecimals('');
328
334
  }
335
+ // add-backlog still asks for parent 999 and writes legacy CK-999.x
336
+ // directories. Preserve that upstream output only when the sentinel
337
+ // parent is legacy-only; a real bracket-spelled parent keeps canonical
338
+ // two-digit sub-tokens like every other bracket parent.
339
+ const renderCanonicalBracket = bracketContext !== null
340
+ && !(Number(normalized) === 999 && !bracketScanMeta.bracketSpellingFound);
329
341
  const existingDecimals = Array.from(decimalSet)
330
342
  .sort((a, b) => a - b)
331
- .map((n) => `${normalized}.${n}`);
343
+ .map((n) => renderCanonicalBracket
344
+ ? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, n))
345
+ : `${normalized}.${n}`);
332
346
  let nextDecimal;
333
347
  if (decimalSet.size === 0) {
334
- nextDecimal = `${normalized}.1`;
348
+ nextDecimal = renderCanonicalBracket
349
+ ? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, 1))
350
+ : `${normalized}.1`;
335
351
  }
336
352
  else {
337
- nextDecimal = `${normalized}.${Math.max(...decimalSet) + 1}`;
353
+ const next = maxPhaseNum(decimalSet) + 1;
354
+ nextDecimal = renderCanonicalBracket
355
+ ? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, next))
356
+ : `${normalized}.${next}`;
338
357
  }
339
358
  output({
340
359
  found: baseExists,
@@ -355,6 +374,10 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
355
374
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
356
375
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
357
376
  const fullContent = stripShippedMilestones(rawContent);
377
+ const convention = resolvePhaseIdConvention(cwd);
378
+ // LABEL_ONLY preserves the legacy `Phase N:` reader while adding the
379
+ // opted-in bracket heading alternative for both the target and its boundary.
380
+ const headingIntro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
358
381
  const escapedPhase = phaseMarkdownRegexSource(phaseNum);
359
382
  // #5007 (Phase 6 / ADR-4910 §8): buildPhaseHeadingRegex (src/roadmap.cts) is
360
383
  // anchored `^...$` with no 'm' flag — it is designed to test ONE
@@ -363,7 +386,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
363
386
  // through tokenizeHeadings first is therefore required here, not a pure
364
387
  // regex-literal swap (the design doc's "direct swap" plan does not hold
365
388
  // once the anchor is accounted for).
366
- const phaseHeader = buildPhaseHeadingRegex(escapedPhase);
389
+ const phaseHeader = buildPhaseHeadingRegex(escapedPhase, convention);
367
390
  for (const content of [milestoneContent, fullContent]) {
368
391
  const heading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).find((h) => phaseHeader.test(h.text));
369
392
  if (!heading)
@@ -372,7 +395,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
372
395
  const headerMatchLength = (headingLineEnd === -1 ? content.length : headingLineEnd) - heading.offset;
373
396
  const sectionStart = heading.offset;
374
397
  const rest = content.slice(sectionStart);
375
- const nextHeader = rest.slice(headerMatchLength).match(/\n#{2,4}\s+Phase\s+\S/i);
398
+ const nextHeader = rest.slice(headerMatchLength).match(new RegExp(`\\n#{2,4}\\s+${headingIntro}\\S`, 'i'));
376
399
  const sectionEnd = nextHeader
377
400
  ? sectionStart + headerMatchLength + nextHeader.index
378
401
  : content.length;
@@ -470,7 +493,9 @@ function cmdFindPhase(cwd, phase, raw) {
470
493
  error('phase identifier required');
471
494
  }
472
495
  const planBase = planningDir(cwd);
473
- const normalized = normalizePhaseName(phase);
496
+ const directoryContext = phaseDirectoryOptions(cwd);
497
+ const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
498
+ const { normalized, convention } = lookup;
474
499
  const notFound = {
475
500
  found: false,
476
501
  directory: null,
@@ -506,19 +531,21 @@ function cmdFindPhase(cwd, phase, raw) {
506
531
  }
507
532
  notFound.searched_directories = searchDirs.map((searchDir) => toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir))));
508
533
  for (const searchDir of searchDirs) {
534
+ const searchContext = phaseLocatorMod.phaseDirectoryContextForRoot(directoryContext, searchDir);
535
+ const custom = phaseIdMod.customPhaseQuery(phase, searchContext);
509
536
  try {
510
537
  const entries = node_fs_1.default.readdirSync(searchDir, { withFileTypes: true });
511
538
  const dirs = entries
512
539
  .filter((e) => e.isDirectory())
513
540
  .map((e) => e.name)
514
- .sort((a, b) => comparePhaseNum(a, b));
541
+ .sort((a, b) => comparePhaseNum(a, b, searchContext));
515
542
  // #2237: fail loud when multiple directories match the same bare phase
516
543
  // number — prevents cross-project file writes when unrelated projects
517
544
  // share a .planning/phases/ tree.
518
545
  // #2528: selection delegates to the canonical two-pass matcher (exact
519
546
  // token match, then the bare-integer leading-digit-run fallback) shared
520
547
  // with the locator and the phase-plan-index scan.
521
- const { matches } = matchPhaseDirs(dirs, normalized);
548
+ const { matches, usedBareFallback } = matchPhaseDirsForLookup(dirs, lookup, searchDir === flatPhasesDir ? undefined : null, searchContext);
522
549
  if (matches.length === 0)
523
550
  continue;
524
551
  if (matches.length > 1) {
@@ -530,9 +557,21 @@ function cmdFindPhase(cwd, phase, raw) {
530
557
  return;
531
558
  }
532
559
  const match = matches[0];
533
- const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
534
- const phaseNumber = dirMatch ? dirMatch[1] : normalized;
535
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
560
+ const isBracketDirectory = convention === 'bracket'
561
+ && BRACKET_DIRECTORY_PREFIX_RE.test(foldBracketId(match));
562
+ const dirMatch = isBracketDirectory
563
+ ? null
564
+ : match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
565
+ const customParts = custom ? customPhaseDirectoryParts(match, custom) : null;
566
+ const phaseNumber = isBracketDirectory
567
+ ? phaseNumberForMatch(match, usedBareFallback, convention)
568
+ : customParts?.id || (dirMatch ? dirMatch[1] : normalized);
569
+ let phaseName = customParts?.slug || (dirMatch && dirMatch[2] ? dirMatch[2] : null);
570
+ if (isBracketDirectory) {
571
+ const id = parsePhaseId(match);
572
+ const idToken = `${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
573
+ phaseName = match.slice(`${id.project}.${id.milestone}-${idToken}`.length).replace(/^-/, '') || null;
574
+ }
536
575
  const phaseDir = node_path_1.default.join(searchDir, match);
537
576
  const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
538
577
  // #3183: canonical, live (superseded-excluded) plan/summary sets
@@ -738,7 +777,9 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
738
777
  error('phase required for phase-plan-index');
739
778
  }
740
779
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
741
- const normalized = normalizePhaseName(phase);
780
+ const directoryContext = phaseDirectoryOptions(cwd);
781
+ const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
782
+ const { normalized } = lookup;
742
783
  let phaseDir = null;
743
784
  let phaseDirName = null;
744
785
  let ambiguousMatches = null;
@@ -747,12 +788,12 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
747
788
  const dirs = entries
748
789
  .filter((e) => e.isDirectory())
749
790
  .map((e) => e.name)
750
- .sort((a, b) => comparePhaseNum(a, b));
791
+ .sort((a, b) => comparePhaseNum(a, b, directoryContext));
751
792
  // #2528: selection delegates to the canonical two-pass matcher shared with
752
793
  // the locator and the find-phase scan (this site previously first-matched
753
794
  // with `.find()` and had no multi-match guard — the #2237 fail-loud rule
754
795
  // now applies here too, so the three resolution paths cannot disagree).
755
- const { matches } = matchPhaseDirs(dirs, normalized);
796
+ const { matches } = matchPhaseDirsForLookup(dirs, lookup, undefined, directoryContext);
756
797
  if (matches.length > 1) {
757
798
  ambiguousMatches = matches;
758
799
  }
@@ -1073,37 +1114,58 @@ function describeGoalShapedTitle(description) {
1073
1114
  * and sibling-worktree phases so a new number cannot collide, but a `Depends on:` line has to point
1074
1115
  * at something a reader of THIS roadmap can find (#5113).
1075
1116
  */
1076
- function roadmapDeclaredPhaseNums(rawContent) {
1077
- const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1078
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1117
+ function roadmapDeclaredPhaseNums(rawContent, withBullets = true) {
1079
1118
  const nums = new Set();
1080
- let m;
1081
- while ((m = headerPattern.exec(rawContent)) !== null) {
1082
- const num = parseInt(m[phaseNumGroup], 10);
1083
- if (!isSentinelPhaseId(num))
1084
- nums.add(num);
1085
- }
1086
- while ((m = bulletPattern.exec(rawContent)) !== null) {
1087
- const num = parseInt(m[1], 10);
1088
- if (!isSentinelPhaseId(num))
1119
+ for (const { regex, numGroup } of buildPhaseDeclarationScans(null, withBullets)) {
1120
+ let m;
1121
+ while ((m = regex.exec(rawContent)) !== null) {
1122
+ const num = parseInt(m[numGroup], 10);
1123
+ if (!Number.isSafeInteger(num))
1124
+ continue; // a 24-digit run parses to 1e24; max+1 of it is not a phase number
1125
+ if (isSentinelPhaseId(num))
1126
+ continue;
1089
1127
  nums.add(num);
1128
+ }
1090
1129
  }
1091
1130
  return [...nums];
1092
1131
  }
1132
+ /** The phase number an on-disk `[PROJECT-]NN-slug` directory holds, or null for a sentinel or a non-phase name. */
1133
+ function dirPhaseNum(entry) {
1134
+ const match = entry.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+)-/);
1135
+ if (!match)
1136
+ return null;
1137
+ const num = parseInt(match[1], 10);
1138
+ return !Number.isSafeInteger(num) || isSentinelPhaseId(num) ? null : num;
1139
+ }
1140
+ /** Add the phase number of every phase directory under `phasesDir` to `used` (a missing dir adds nothing). */
1141
+ function collectPhaseDirNums(phasesDir, used, bracketContext) {
1142
+ if (!node_fs_1.default.existsSync(phasesDir))
1143
+ return;
1144
+ for (const entry of node_fs_1.default.readdirSync(phasesDir)) {
1145
+ // A bracket writer reserves the directories its reader resolves (#4304), not the legacy reading.
1146
+ const num = bracketContext ? readerResolvableBracketDirectoryPhaseNumber(entry, bracketContext) : dirPhaseNum(entry);
1147
+ if (num !== null)
1148
+ used.add(num);
1149
+ }
1150
+ }
1093
1151
  /**
1094
1152
  * The `**Depends on:**` line for a freshly added numeric phase: the nearest lower phase the roadmap
1095
1153
  * declares, or `Nothing` when it declares none. It was `newId - 1` regardless, which named a phase
1096
1154
  * that exists only on a sibling branch, as an orphan directory, or not at all when numbering has a
1097
1155
  * gap (#5113). Custom-named phases carry no dependency line.
1098
1156
  */
1099
- function dependsOnLine(newPhaseId, declared) {
1157
+ function dependsOnLine(newPhaseId, declared, renderDependency = (phase) => `Phase ${phase}`) {
1100
1158
  if (typeof newPhaseId !== 'number')
1101
1159
  return '';
1102
1160
  let nearest = null;
1103
1161
  for (const n of declared)
1104
1162
  if (n < newPhaseId && (nearest === null || n > nearest))
1105
1163
  nearest = n;
1106
- return `\n**Depends on:** ${nearest === null ? 'Nothing' : `Phase ${nearest}`}`;
1164
+ return `\n**Depends on:** ${nearest === null ? 'Nothing' : renderDependency(nearest)}`;
1165
+ }
1166
+ /** `dependsOnLine` over the roadmap's declared phases: the whole roadmap, as it was before #5528. */
1167
+ function dependsOnFor(newPhaseId, rawContent) {
1168
+ return dependsOnLine(newPhaseId, roadmapDeclaredPhaseNums(rawContent));
1107
1169
  }
1108
1170
  /**
1109
1171
  * Splice a phase entry into `rawContent` at `at`, changing nothing else: the blank lines around the
@@ -1123,6 +1185,74 @@ function spliceRoadmapBlock(rawContent, atOffset, block) {
1123
1185
  const tail = after === '' || /^\r?\n/.test(after) ? '' : eol;
1124
1186
  return before + lead + body + tail + after;
1125
1187
  }
1188
+ /** Segment-wise numeric order of two folded phase ids; a non-numeric segment never orders after. */
1189
+ function comparePhaseIds(a, b) {
1190
+ const x = a.split('.').map(Number);
1191
+ const y = b.split('.').map(Number);
1192
+ for (let n = 0; n < Math.max(x.length, y.length); n++) {
1193
+ const d = (x[n] ?? -1) - (y[n] ?? -1);
1194
+ if (Number.isNaN(d))
1195
+ return -1;
1196
+ if (d)
1197
+ return d;
1198
+ }
1199
+ return 0;
1200
+ }
1201
+ const spliceAfter = (raw, at, eol, line) => `${raw.slice(0, at)}${eol}${line}${raw.slice(at)}`;
1202
+ /**
1203
+ * #5221: give a tool-created phase a ROADMAP record, so ship's fail-closed gate can read it.
1204
+ * Writes `- [ ] **Phase N: desc**` after the active milestone's checklist (for an insert: after
1205
+ * phase `after`'s own entries, else before the first later entry); with no checklist, a `N. desc | Not started` progress-table row;
1206
+ * with neither, nothing. Entries inside an open <details> (shipped blocks) are not the checklist.
1207
+ */
1208
+ function recordNewPhase(raw, cwd, id, description, after, label) {
1209
+ const start = activeScopeStart(raw, cwd);
1210
+ // End at the active milestone's section so a later planned milestone's checklist is never the target.
1211
+ const end = currentMilestoneRawRanges(raw, cwd)?.primary.end;
1212
+ const scope = raw.slice(start, end !== undefined && end > start ? end : undefined);
1213
+ let custom = false;
1214
+ try {
1215
+ custom = loadConfig(cwd).phase_naming === 'custom';
1216
+ }
1217
+ catch { /* unreadable config: numeric ids only */ }
1218
+ const entrySrc = checklistEntrySrc(resolvePhaseIdConvention(cwd), false, custom);
1219
+ const entryRe = new RegExp(`^[ \\t]*${entrySrc}[^\\r\\n]*`, 'gim');
1220
+ const bullets = [...scope.matchAll(entryRe)].filter((m) => !insideOpenDetails(scope, m.index));
1221
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
1222
+ // A bracket writer records its entry in the bracket spelling it used for the heading (`[CK.02] 02`).
1223
+ const entry = `- [ ] **${label ?? `Phase ${id}`}: ${description}**`;
1224
+ // An id past CUSTOM_PHASE_ID_SRC's cap is written but not readable by analyze: report that, not 'checklist'.
1225
+ const kind = new RegExp(`^${entrySrc}`).test(entry) ? 'checklist' : 'none';
1226
+ if (after !== undefined) {
1227
+ const anchor = foldPhaseId(after);
1228
+ const own = bullets.filter((m) => foldPhaseId(m[1]) === anchor || foldPhaseId(m[1]).startsWith(`${anchor}.`)).at(-1);
1229
+ // Anchor phase has no entry: go before the first numerically later entry, so the checklist stays ordered.
1230
+ const later = own ? undefined : bullets.find((m) => comparePhaseIds(foldPhaseId(m[1]), anchor) > 0);
1231
+ if (later) {
1232
+ const at = start + later.index;
1233
+ return { content: `${raw.slice(0, at)}${entry}${eol}${raw.slice(at)}`, entry: kind };
1234
+ }
1235
+ if (own)
1236
+ return { content: spliceAfter(raw, start + own.index + own[0].length, eol, entry), entry: kind };
1237
+ }
1238
+ const last = bullets.at(-1);
1239
+ if (last)
1240
+ return { content: spliceAfter(raw, start + last.index + last[0].length, eol, entry), entry: kind };
1241
+ let wrote = false;
1242
+ const archived = roadmapParserMod.maskArchivedDetails(raw, cwd);
1243
+ const edited = editProgressTableSlice(archived.visible, (scoped) => {
1244
+ if (findPhaseTableHeader(scoped) === -1)
1245
+ return scoped;
1246
+ const cell = { Phase: `${id}. ${description}`, 'Plans Complete': '0/0', Status: 'Not started' };
1247
+ const r = (0, markdown_table_cjs_1.insertTableRow)(scoped, (col) => cell[col]);
1248
+ if (!r.ok)
1249
+ return scoped;
1250
+ wrote = true;
1251
+ return r.value;
1252
+ }, archived.activeRange);
1253
+ const content = wrote && !archived.ambiguous ? archived.restore(edited) : null;
1254
+ return content !== null ? { content, entry: 'progress_table' } : { content: raw, entry: 'none' };
1255
+ }
1126
1256
  /**
1127
1257
  * #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
1128
1258
  * entry should be inserted — at the end of the active phase list, scoped to the
@@ -1132,9 +1262,17 @@ function spliceRoadmapBlock(rawContent, atOffset, block) {
1132
1262
  * STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
1133
1263
  * legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
1134
1264
  * their existing behavior.
1265
+ *
1266
+ * #4304: `phaseIdConvention` is threaded through to
1267
+ * `currentMilestoneRawRanges` so a version-less bracket milestone heading
1268
+ * (`## [CK.02] Current`) is still offset-scoped — omitting it here silently
1269
+ * degraded to the legacy-only search, which finds nothing for that heading
1270
+ * shape and falls back to whole-document insertion, landing `phase add` /
1271
+ * `add-batch` outside the active milestone. Optional and additive: every
1272
+ * pre-existing non-bracket caller passes nothing and compiles byte-identically.
1135
1273
  */
1136
- function phaseEntryInsertOffset(rawContent, cwd) {
1137
- const ranges = currentMilestoneRawRanges(rawContent, cwd);
1274
+ function phaseEntryInsertOffset(rawContent, cwd, phaseIdConvention) {
1275
+ const ranges = currentMilestoneRawRanges(rawContent, cwd, phaseIdConvention);
1138
1276
  if (!ranges) {
1139
1277
  const legacy = rawContent.lastIndexOf('\n---');
1140
1278
  return legacy > 0 ? legacy : rawContent.length;
@@ -1186,14 +1324,142 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1186
1324
  `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
1187
1325
  markerList + `.`);
1188
1326
  }
1327
+ /**
1328
+ * Resolve the write identity shared by add/insert/remove. Convention selection
1329
+ * happens at the caller and is the sole branch gate; project/milestone checks
1330
+ * here validate the identity after that branch has already been selected.
1331
+ */
1332
+ function bracketWriteContext(cwd, config) {
1333
+ const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
1334
+ if (!project) {
1335
+ error('phase_id_convention is "bracket" but project_code is missing in .planning/config.json');
1336
+ }
1337
+ const milestoneInfo = getMilestoneInfo(cwd);
1338
+ const version = milestoneInfo.value?.version ?? '';
1339
+ const milestone = milestoneToken(version);
1340
+ if (milestone === null) {
1341
+ error('phase_id_convention is "bracket" but the active milestone cannot be resolved');
1342
+ }
1343
+ return { project, milestone: milestone };
1344
+ }
1345
+ function bracketPhaseId(context, phase, subphase) {
1346
+ const phaseTok = phaseToken(phase);
1347
+ if (phaseTok === null) {
1348
+ error(`phase ${String(phase)} cannot be rendered by the bracket convention`);
1349
+ }
1350
+ const id = {
1351
+ project: context.project,
1352
+ milestone: context.milestone,
1353
+ phase: phaseTok,
1354
+ };
1355
+ if (subphase !== undefined) {
1356
+ const subphaseTok = phaseToken(subphase);
1357
+ if (subphaseTok === null) {
1358
+ error(`phase ${String(phase)} subphase ${JSON.stringify(subphase)} cannot be rendered by the bracket convention`);
1359
+ }
1360
+ id.subphase = subphaseTok;
1361
+ }
1362
+ return id;
1363
+ }
1364
+ /** The owner-rendered bare token used by plan/summary artifact filenames. */
1365
+ function bracketArtifactToken(id) {
1366
+ return renderPhaseId(id).split('] ')[1];
1367
+ }
1368
+ /** The directory-name prefix `toDir` emits for this identity, ahead of the slug. */
1369
+ function bracketDirPrefix(id) {
1370
+ const probeSlug = 'phase-slug-probe';
1371
+ return toDir(id, probeSlug).slice(0, -probeSlug.length);
1372
+ }
1373
+ function bracketDirSlug(dirName, id) {
1374
+ const prefix = bracketDirPrefix(id);
1375
+ return dirName.startsWith(prefix) ? dirName.slice(prefix.length) : null;
1376
+ }
1377
+ function bracketIdsInContext(phasesDir, context) {
1378
+ const found = [];
1379
+ for (const dir of readSubdirectories(phasesDir, true)) {
1380
+ try {
1381
+ const id = parsePhaseId(dir);
1382
+ if (id.project !== context.project || id.milestone !== context.milestone)
1383
+ continue;
1384
+ const slug = bracketDirSlug(dir, id);
1385
+ if (slug)
1386
+ found.push({ dir, id, slug });
1387
+ }
1388
+ catch {
1389
+ // A bracket writer shares the directory with migration-window legacy
1390
+ // names. Names outside the canonical bracket grammar do not allocate an
1391
+ // identity in this branch.
1392
+ }
1393
+ }
1394
+ return found;
1395
+ }
1396
+ /**
1397
+ * Return the top-level phase number when the active bracket reader resolves
1398
+ * this one directory. The reader prefers an exact project+milestone bracket
1399
+ * identity, falls back to migration-window legacy spellings, and excludes a
1400
+ * qualified directory owned by another milestone. Allocation must reserve
1401
+ * that same universe or a newly emitted bracket directory can shadow work the
1402
+ * reader resolved immediately before the write.
1403
+ */
1404
+ function readerResolvableBracketDirectoryPhaseNumber(dir, context) {
1405
+ const phaseKey = phaseKeyFromDir(dir, 'bracket');
1406
+ if (!/^\d+$/.test(phaseKey))
1407
+ return null;
1408
+ const phase = Number(phaseKey);
1409
+ if (!Number.isSafeInteger(phase) || isSentinelPhaseId(phase))
1410
+ return null;
1411
+ const { matches } = matchPhaseDirs([dir], phaseKey, 'bracket', context);
1412
+ return matches.includes(dir) ? phase : null;
1413
+ }
1414
+ function addBracketRoadmapPhaseNumbers(content, used, context) {
1415
+ for (const token of scanMilestonePhaseIds(content, 'bracket')) {
1416
+ const leading = String(token).split('.')[0];
1417
+ if (/^\d+$/.test(leading))
1418
+ used.add(Number(leading));
1419
+ }
1420
+ // #5528: a phase declared only as a checklist bullet (`- [ ] **[GSD.02] 05: x**`, with or without a
1421
+ // checkbox, letter-suffixed or word-first) is invisible to the heading scan above but still owns its
1422
+ // number. The bracket declaration scans see it; an id of another milestone only over-counts.
1423
+ if (!context)
1424
+ return;
1425
+ const active = `${context.project}.${context.milestone}`.toUpperCase();
1426
+ for (const { regex, idGroup, numGroup } of buildPhaseDeclarationScans('bracket')) {
1427
+ if (idGroup === null)
1428
+ continue;
1429
+ let hit;
1430
+ while ((hit = regex.exec(content)) !== null) {
1431
+ const num = parseInt(hit[numGroup], 10);
1432
+ if (!Number.isSafeInteger(num) || isSentinelPhaseId(num))
1433
+ continue;
1434
+ // A legacy `- [x] Phase 5: x` bullet (migration window) carries no bracket id: it still owns its number.
1435
+ if (!hit[idGroup] || hit[idGroup].toUpperCase() === active)
1436
+ used.add(num);
1437
+ }
1438
+ }
1439
+ }
1440
+ function collectBracketPhaseNumbers(roadmapContent, phasesDir, context) {
1441
+ const used = new Set();
1442
+ addBracketRoadmapPhaseNumbers(roadmapContent, used, context);
1443
+ for (const dir of readSubdirectories(phasesDir, true)) {
1444
+ const phase = readerResolvableBracketDirectoryPhaseNumber(dir, context);
1445
+ if (phase !== null)
1446
+ used.add(phase);
1447
+ }
1448
+ used.delete(0);
1449
+ used.delete(999);
1450
+ return used;
1451
+ }
1189
1452
  /**
1190
1453
  * #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
1191
1454
  * worktree carries its own `.planning/` on its own branch, so a phase minted
1192
1455
  * there is invisible to the cwd-scoped sources (headers, bullets, on-disk
1193
- * dirs). Scan each sibling's phase-directory names (cheap — dir names alone
1194
- * caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
1195
- * before any directory does; milestone-scoping is wrong here because a number
1196
- * used under any milestone on another branch is still taken).
1456
+ * dirs). Legacy identities scan each sibling's phase-directory names and whole
1457
+ * ROADMAP because their phase number has no milestone qualifier. Bracket ids
1458
+ * reserve the same reader-resolvable union used locally: exact identities for
1459
+ * the active project+milestone, migration-window legacy directory spellings,
1460
+ * and accepted ROADMAP spellings when the sibling is on that same bracket
1461
+ * identity. Each writer creates its directory in the same planning lock as its
1462
+ * ROADMAP entry.
1197
1463
  *
1198
1464
  * #4225 — the horizon must track the ALLOCATION scope. When the allocation is
1199
1465
  * workstream-scoped (`--ws`/`GSD_WORKSTREAM`, resolved into the env before
@@ -1211,21 +1477,22 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1211
1477
  * allocation then behaves exactly as it did before this horizon existed.
1212
1478
  * A sibling that simply lacks the active workstream's directory is the same
1213
1479
  * fail-open case: it contributes nothing. Sentinels reuse the canonical
1214
- * `isSentinelPhaseId`; the dir pattern is the same one the on-disk scan uses,
1215
- * so decimal sub-phases (`411.1-foo`) are correctly not integers.
1480
+ * `isSentinelPhaseId` (`isSentinelPhaseDir` for bracket dirs); the dir pattern is the same one the
1481
+ * on-disk scan uses, so decimal sub-phases (`411.1-foo`, `GSD.02-05.1-foo`) are correctly not integers.
1216
1482
  */
1217
- function collectSiblingWorktreePhaseNums(cwd, used) {
1483
+ function collectSiblingWorktreePhaseNums(cwd, used, bracketContext) {
1218
1484
  let porcelain;
1219
1485
  try {
1220
1486
  porcelain = (0, node_child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], {
1221
1487
  cwd,
1488
+ env: (0, shell_command_projection_cjs_1.scrubGitLocationEnv)(process.env),
1222
1489
  encoding: 'utf-8',
1223
1490
  // Same subprocess band as the other git call sites (smart-entry, check-command-router):
1224
1491
  // inside the 5-30s git window, hidden console window on Windows, bounded buffer.
1225
1492
  timeout: 10_000,
1226
1493
  windowsHide: true,
1227
1494
  maxBuffer: 4 * 1024 * 1024,
1228
- });
1495
+ }); // allow-index-refresh: lists worktrees from .git metadata, never opens the index
1229
1496
  }
1230
1497
  catch {
1231
1498
  return; // not a git repo / git unavailable — unchanged behavior
@@ -1237,16 +1504,6 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1237
1504
  // try/catch below still keeps any resolution failure fail-open.
1238
1505
  const ws = process.env['GSD_WORKSTREAM'] ?? null;
1239
1506
  const siblingPlanningDir = (wt) => planningDir(wt, ws);
1240
- const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1241
- // Same header shape the allocators scan locally (#1729 tag tolerance).
1242
- // #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
1243
- // (src/phase-id.cjs), the shared "which phases exist in this content" scan
1244
- // owner. LABEL_ONLY baseline with no convention argument reproduces the
1245
- // prior literal `Phase\s+` (no bracket tolerance) byte-for-byte — this is a
1246
- // counter, matching the LABEL_ONLY docstring guidance exactly, so it is
1247
- // deliberately NOT widened onto the bracket grammar the way the real-heading
1248
- // readers (getRoadmapModeForPhase, phaseDisplayNameFromRoadmap) are.
1249
- const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1250
1507
  for (const line of porcelain.split('\n')) {
1251
1508
  if (!line.startsWith('worktree '))
1252
1509
  continue;
@@ -1254,124 +1511,258 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1254
1511
  if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
1255
1512
  continue;
1256
1513
  try {
1257
- for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(siblingPlanningDir(wt), 'phases'))) {
1258
- const match = entry.match(dirNumPattern);
1259
- if (!match)
1260
- continue;
1261
- const num = parseInt(match[1], 10);
1262
- if (!isSentinelPhaseId(num))
1263
- used.add(num);
1264
- }
1514
+ collectPhaseDirNums(node_path_1.default.join(siblingPlanningDir(wt), 'phases'), used, bracketContext);
1265
1515
  }
1266
1516
  catch {
1267
1517
  /* worktree has no .planning (or no copy of this scope) — normal, contributes nothing */
1268
1518
  }
1269
1519
  try {
1270
1520
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(siblingPlanningDir(wt), 'ROADMAP.md'), 'utf-8');
1271
- let m;
1272
- headerPattern.lastIndex = 0;
1273
- while ((m = headerPattern.exec(content)) !== null) {
1274
- const num = parseInt(m[phaseNumGroup], 10);
1275
- if (!isSentinelPhaseId(num))
1276
- used.add(num);
1521
+ if (bracketContext) {
1522
+ // Resolved without `bracketWriteContext`, whose `error()` writes to
1523
+ // stderr before throwing: a sibling with no project code or no
1524
+ // resolvable milestone simply contributes nothing.
1525
+ const siblingProject = loadConfig(wt)['project_code'];
1526
+ const siblingVersion = getMilestoneInfo(wt).value?.version ?? '';
1527
+ if (typeof siblingProject === 'string'
1528
+ && siblingProject.trim() === bracketContext.project
1529
+ && milestoneToken(siblingVersion) === bracketContext.milestone) {
1530
+ addBracketRoadmapPhaseNumbers(extractCurrentMilestone(content, wt), used, bracketContext);
1531
+ }
1532
+ continue;
1277
1533
  }
1534
+ // Headers only for non-bracket repos, as before #5528 (#3849).
1535
+ for (const num of roadmapDeclaredPhaseNums(content, false))
1536
+ used.add(num);
1278
1537
  }
1279
1538
  catch {
1280
1539
  /* no roadmap in that worktree (or scope) — normal, contributes nothing */
1281
1540
  }
1282
1541
  }
1283
1542
  }
1543
+ /** The highest used number (a loop, not `Math.max(...set)`, which overflows the argument limit on a large set). */
1544
+ function maxPhaseNum(used) {
1545
+ let max = 0;
1546
+ for (const n of used)
1547
+ if (n > max)
1548
+ max = n;
1549
+ return max;
1550
+ }
1551
+ /** The number after `max`, stepping past the backlog sentinel (999) so allocation never lands on one. */
1552
+ function nextPhaseNum(max) {
1553
+ let n = max + 1;
1554
+ while (isSentinelPhaseId(n))
1555
+ n += 1;
1556
+ return n;
1557
+ }
1558
+ /** Phase numbers already taken: the current milestone's text, phase directories, sibling worktrees (#1229, #3849, #5528). */
1559
+ function collectUsedPhaseNums(cwd, content) {
1560
+ const used = new Set(roadmapDeclaredPhaseNums(content));
1561
+ collectPhaseDirNums(node_path_1.default.join(planningDir(cwd), 'phases'), used);
1562
+ collectSiblingWorktreePhaseNums(cwd, used);
1563
+ return used;
1564
+ }
1565
+ /**
1566
+ * #4304: `toDir` throws a raw `Error` for a description whose
1567
+ * slug sanitizes to empty (e.g. a description that transliterates to
1568
+ * nothing) or is all-digit. An uncaught throw from inside a mutation loop
1569
+ * is worse than a refusal — it can leave earlier iterations' directories
1570
+ * already created with no clean error surface. This wraps that one call so
1571
+ * every bracket phase-directory allocation refuses through `error(...)`
1572
+ * (clean stderr message, controlled exit) instead of crashing.
1573
+ */
1574
+ function bracketDirNameOrRefuse(id, slug, description) {
1575
+ try {
1576
+ return toDir(id, slug);
1577
+ }
1578
+ catch (e) {
1579
+ const msg = e instanceof Error ? e.message : String(e);
1580
+ return error(`Cannot create a phase directory for "${description}": ${msg}`);
1581
+ }
1582
+ }
1583
+ /**
1584
+ * #4304: fail closed before a bracket writer creates or renames into a phase
1585
+ * directory. Allocation intentionally ignores symlink entries, so the exact
1586
+ * destination must be checked with lstat (including dangling links) before a
1587
+ * recursive mkdir or child write can follow it. The canonical containment
1588
+ * predicate then resolves the existing path, or its nearest existing parent,
1589
+ * so a destination reached through a symlinked ancestor cannot escape the
1590
+ * planning phases directory either. A pre-pass supplies simulated occupancy
1591
+ * after deletion and earlier renames; the rename loop checks the live disk.
1592
+ */
1593
+ function assertPhaseDirectoryDestinationSafe(phasesDir, dirName, operation, occupiedNames) {
1594
+ const destination = node_path_1.default.join(phasesDir, dirName);
1595
+ let stat = null;
1596
+ try {
1597
+ stat = node_fs_1.default.lstatSync(destination);
1598
+ }
1599
+ catch (e) {
1600
+ const code = e.code;
1601
+ if (code !== 'ENOENT') {
1602
+ error(`Cannot ${operation} phase directory "${dirName}": unable to inspect the destination (${code ?? 'unknown error'})`);
1603
+ }
1604
+ }
1605
+ if (stat?.isSymbolicLink()) {
1606
+ error(`Cannot ${operation} phase directory "${dirName}": the destination is a symbolic link`);
1607
+ }
1608
+ const destinationOccupied = occupiedNames
1609
+ ? occupiedNames.has(dirName) || (stat !== null && !stat.isDirectory())
1610
+ : stat !== null;
1611
+ if (operation === 'renumber into' && destinationOccupied) {
1612
+ error(`Cannot ${operation} phase directory "${dirName}": the destination already exists`);
1613
+ }
1614
+ if ((0, security_cjs_1.tryWithinRoot)(destination, phasesDir, security_cjs_1.PathAcceptance.AbsoluteInsideRoot) === null) {
1615
+ error(`Cannot ${operation} phase directory "${dirName}": the destination resolves outside the planning phases directory`);
1616
+ }
1617
+ }
1618
+ /**
1619
+ * #4304: the bracket writers mint every identity from the milestone's own
1620
+ * numbering. A caller-supplied `--id` or `phase_naming: "custom"` has no
1621
+ * bracket write semantics yet: #5067 owns their precedence (a supplied id is
1622
+ * validated against the resolved convention, "custom" keeps bypassing
1623
+ * generation). Until it lands, refuse before any write rather than discard
1624
+ * the id or override the naming mode.
1625
+ */
1626
+ function assertBracketGeneratedIdentity(command, config, customId) {
1627
+ if (customId) {
1628
+ error(`${command}: --id ${JSON.stringify(customId)} is not supported under phase_id_convention "bracket"; ` +
1629
+ 'bracket phase identities are generated from the active milestone. ' +
1630
+ 'Custom id precedence under the bracket convention lands with #5067.');
1631
+ }
1632
+ if (config['phase_naming'] === 'custom') {
1633
+ error(`${command}: phase_naming "custom" is not supported under phase_id_convention "bracket"; ` +
1634
+ 'bracket phase identities are generated from the active milestone. ' +
1635
+ 'Custom naming precedence under the bracket convention lands with #5067.');
1636
+ }
1637
+ }
1638
+ /**
1639
+ * #4304: refuse a bracket `phase add` / `add-batch` whose located milestone
1640
+ * window is a decoy. A non-milestone heading carrying the active version
1641
+ * token ahead of the real milestone heading (`## Goals for v2.0` before
1642
+ * `## [CK.02] v2.0 Current`) is where the read side locates the window, so
1643
+ * the new entry would land in the decoy section, outside every reader's view
1644
+ * of the milestone. The window is accepted when its own heading carries the
1645
+ * active bracket qualifier; otherwise a live (unfenced, not historical)
1646
+ * milestone heading elsewhere that carries both the active qualifier and the
1647
+ * active version token is the real one, and the writer refuses before any
1648
+ * write, naming both lines. Read-side window selection is unchanged.
1649
+ */
1650
+ function assertBracketAddWindowLocated(rawContent, cwd, context, command) {
1651
+ const ranges = currentMilestoneRawRanges(rawContent, cwd, 'bracket');
1652
+ if (!ranges)
1653
+ return;
1654
+ const lineAt = (offset) => {
1655
+ const end = rawContent.indexOf('\n', offset);
1656
+ return rawContent.slice(offset, end === -1 ? rawContent.length : end).replace(/\r$/, '');
1657
+ };
1658
+ const qualifierRe = new RegExp(`^\\[${(0, pattern_cjs_1.escapeRegex)(context.project)}\\.${(0, pattern_cjs_1.escapeRegex)(context.milestone)}\\]`, 'i');
1659
+ const windowHeading = lineAt(ranges.primary.start);
1660
+ if (qualifierRe.test(windowHeading.replace(/^#+[ \t]*/, '')))
1661
+ return;
1662
+ const version = String(getMilestoneInfo(cwd).value?.version ?? '');
1663
+ if (!version)
1664
+ return;
1665
+ const versionRe = new RegExp(`(?<![\\w.])v?${(0, pattern_cjs_1.escapeRegex)(version.replace(/^v/i, ''))}(?![\\w.-])`, 'i');
1666
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(rawContent);
1667
+ const insideWindow = (offset) => (offset >= ranges.primary.start && offset < ranges.primary.end)
1668
+ || Boolean(ranges.details && offset >= ranges.details.start && offset < ranges.details.end);
1669
+ const realHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(rawContent).find((heading) => !insideWindow(heading.offset)
1670
+ && !historicalLineStarts.has(heading.offset)
1671
+ && rawContent[heading.offset] === '#'
1672
+ && isRecognizedMilestoneHeading(heading.text, heading.level)
1673
+ && qualifierRe.test(heading.text.trim())
1674
+ && versionRe.test(heading.text));
1675
+ if (!realHeading)
1676
+ return;
1677
+ const lineNumber = (offset) => rawContent.slice(0, offset).split('\n').length;
1678
+ error(`${command}: the active milestone window was located at ROADMAP.md line ${lineNumber(ranges.primary.start)} `
1679
+ + `(${JSON.stringify(windowHeading.trim())}), but the milestone heading for ${renderMilestoneId(context)} `
1680
+ + `${version} is at line ${lineNumber(realHeading.offset)} (${JSON.stringify(lineAt(realHeading.offset).trim())}). `
1681
+ + 'Writing the new phase there would place it outside the milestone. Rename or remove the earlier heading '
1682
+ + 'so it no longer carries the active version token, then retry.');
1683
+ }
1284
1684
  function cmdPhaseAdd(cwd, description, raw, customId) {
1285
1685
  if (!description) {
1286
1686
  error('description required for phase add');
1287
1687
  }
1288
1688
  assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add');
1689
+ const convention = resolvePhaseIdConvention(cwd);
1690
+ // The naming projection reads config silently, so a corrupt project config is
1691
+ // reported as `config_parse_failed` before the loader's own diagnostics run.
1692
+ const naming = phaseDirectoryOptions(cwd, undefined, '');
1693
+ if (customId != null && convention !== 'bracket') {
1694
+ const usage = phaseLocatorMod.customPhaseNamingUsage(cwd, naming);
1695
+ if (usage)
1696
+ error(usage, naming.configError ? ERROR_REASON.CONFIG_PARSE_FAILED : ERROR_REASON.PHASE_NAMING_NOT_CUSTOM);
1697
+ }
1289
1698
  const config = loadConfig(cwd);
1290
1699
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1291
1700
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1292
1701
  error('ROADMAP.md not found');
1293
1702
  }
1294
1703
  const slug = generateSlugInternal(description) || '';
1295
- const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
1704
+ if (convention === 'bracket')
1705
+ assertBracketGeneratedIdentity('phase add', config, customId);
1706
+ const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1707
+ const { newPhaseId, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
1296
1708
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1709
+ if (bracketContext)
1710
+ assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add');
1297
1711
  const content = extractCurrentMilestone(rawContent, cwd);
1298
- const projectCode = config.project_code || '';
1299
- const prefix = projectCode ? `${projectCode}-` : '';
1300
1712
  let _newPhaseId;
1301
- let _dirName;
1302
- if (customId || config.phase_naming === 'custom') {
1713
+ let _dirName = '';
1714
+ if (bracketContext) {
1715
+ const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1716
+ const usedPhaseNums = collectBracketPhaseNumbers(content, phasesOnDisk, bracketContext);
1717
+ collectSiblingWorktreePhaseNums(cwd, usedPhaseNums, bracketContext);
1718
+ _newPhaseId = nextPhaseNum(maxPhaseNum(usedPhaseNums));
1719
+ _dirName = bracketDirNameOrRefuse(bracketPhaseId(bracketContext, _newPhaseId), slug, description);
1720
+ }
1721
+ else if (naming.phaseNaming === 'custom') {
1303
1722
  _newPhaseId = customId || slug.toUpperCase();
1304
1723
  if (!_newPhaseId)
1305
1724
  error('--id required when phase_naming is "custom"');
1306
- _dirName = `${prefix}${_newPhaseId}-${slug}`;
1307
1725
  }
1308
1726
  else {
1309
- // Collect all phase numbers visible in the current-milestone content.
1310
- // Three sources are scanned so that a phase in ANY representation
1311
- // (section header, roadmap bullet, or on-disk directory) is counted:
1312
- // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
1313
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1314
- // #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
1315
- // (src/phase-id.cjs) with the LABEL_ONLY baseline and no convention
1316
- // argument — a counter site, matching phase-id.cts's own LABEL_ONLY
1317
- // docstring guidance, reproducing the prior `Phase\s+` literal (no
1318
- // bracket tolerance) byte-for-byte. See collectSiblingWorktreePhaseNums
1319
- // (above) for the identical migration on the same regex shape.
1320
- const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1321
- // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
1322
- // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
1323
- // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
1324
- // are counted and cannot collide with a freshly-added phase. (#1229)
1325
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1326
- const usedPhaseNums = new Set();
1327
- let m;
1328
- while ((m = headerPattern.exec(content)) !== null) {
1329
- const num = parseInt(m[phaseNumGroup], 10);
1330
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1331
- if (!isSentinelPhaseId(num))
1332
- usedPhaseNums.add(num);
1333
- }
1334
- while ((m = bulletPattern.exec(content)) !== null) {
1335
- const num = parseInt(m[1], 10);
1336
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1337
- if (!isSentinelPhaseId(num))
1338
- usedPhaseNums.add(num);
1339
- }
1340
- // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
1341
- const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1342
- if (node_fs_1.default.existsSync(phasesOnDisk)) {
1343
- const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1344
- for (const entry of node_fs_1.default.readdirSync(phasesOnDisk)) {
1345
- const match = entry.match(dirNumPattern);
1346
- if (!match)
1347
- continue;
1348
- const num = parseInt(match[1], 10);
1349
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1350
- if (!isSentinelPhaseId(num))
1351
- usedPhaseNums.add(num);
1352
- }
1353
- }
1354
- // phase.add appends after the highest *used* number. Collecting numbers from
1355
- // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
1356
- // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
1357
- // an existing number.
1358
- // 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
1359
- // taken on another branch is still taken.
1360
- collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
1361
- const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
1362
- _newPhaseId = maxUsed + 1;
1363
- const paddedNum = String(_newPhaseId).padStart(2, '0');
1364
- _dirName = `${prefix}${paddedNum}-${slug}`;
1727
+ // Collect all phase numbers visible in the current-milestone content. Three sources are
1728
+ // scanned so a phase in ANY representation (section header, roadmap bullet, or on-disk
1729
+ // directory) is counted: a bullet-only Phase N must not collide with a fresh one (#1229),
1730
+ // and under the bracket convention `[CODE.MM] NN` forms count in all three (#5528).
1731
+ // Header/bullet scans are #1729 pre-colon-tag tolerant and #3185 sentinel-filtered.
1732
+ const usedPhaseNums = collectUsedPhaseNums(cwd, content);
1733
+ _newPhaseId = nextPhaseNum(maxPhaseNum(usedPhaseNums));
1365
1734
  }
1366
- const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
1367
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1368
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1369
- const dependsOn = config.phase_naming === 'custom' ? '' : dependsOnLine(_newPhaseId, roadmapDeclaredPhaseNums(rawContent));
1370
- const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
1371
- const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1372
- const updatedContent = spliceRoadmapBlock(rawContent, insertAt, phaseEntry);
1373
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent, { verbatim: true });
1374
- return { newPhaseId: _newPhaseId, dirName: _dirName };
1735
+ if (bracketContext) {
1736
+ const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
1737
+ assertPhaseDirectoryDestinationSafe(node_path_1.default.dirname(dirPath), _dirName, 'create');
1738
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1739
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1740
+ }
1741
+ else {
1742
+ [{ dirName: _dirName }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
1743
+ id: String(_newPhaseId), slug,
1744
+ directoryId: typeof _newPhaseId === 'number' ? String(_newPhaseId).padStart(2, '0') : _newPhaseId,
1745
+ }], { ...naming, roadmap: content });
1746
+ }
1747
+ let phaseEntry;
1748
+ if (bracketContext && typeof _newPhaseId === 'number') {
1749
+ const id = bracketPhaseId(bracketContext, _newPhaseId);
1750
+ const display = renderPhaseId(id);
1751
+ const declared = new Set();
1752
+ addBracketRoadmapPhaseNumbers(content, declared);
1753
+ const dependsOn = dependsOnLine(_newPhaseId, [...declared], (phase) => renderPhaseId(bracketPhaseId(bracketContext, phase)));
1754
+ phaseEntry =
1755
+ `\n### ${display}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${id.phase} to break down)\n`;
1756
+ }
1757
+ else {
1758
+ const dependsOn = naming.phaseNaming === 'custom' ? '' : dependsOnFor(_newPhaseId, rawContent);
1759
+ phaseEntry =
1760
+ `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
1761
+ }
1762
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd, convention);
1763
+ const recorded = recordNewPhase(spliceRoadmapBlock(rawContent, insertAt, phaseEntry), cwd, _newPhaseId, description, undefined, bracketContext && typeof _newPhaseId === 'number' ? renderPhaseId(bracketPhaseId(bracketContext, _newPhaseId)) : undefined);
1764
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, recorded.content, { verbatim: true });
1765
+ return { newPhaseId: _newPhaseId, dirName: _dirName, roadmapEntry: recorded.entry };
1375
1766
  });
1376
1767
  const titleWarning = describeGoalShapedTitle(description);
1377
1768
  const result = {
@@ -1380,7 +1771,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1380
1771
  name: description,
1381
1772
  slug,
1382
1773
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1383
- naming_mode: config.phase_naming,
1774
+ naming_mode: naming.phaseNaming,
1775
+ roadmap_entry: roadmapEntry,
1384
1776
  };
1385
1777
  if (titleWarning)
1386
1778
  result['warning'] = titleWarning;
@@ -1409,94 +1801,111 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1409
1801
  assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch');
1410
1802
  }
1411
1803
  const config = loadConfig(cwd);
1804
+ const convention = resolvePhaseIdConvention(cwd);
1805
+ const naming = phaseDirectoryOptions(cwd, undefined, '');
1806
+ if (descriptions.includes('--id') && convention !== 'bracket') {
1807
+ error(phaseLocatorMod.customPhaseNamingUsage(cwd, naming) ?? '--id is not supported by phase add-batch', naming.phaseNaming === 'custom' ? ERROR_REASON.USAGE : naming.configError ? ERROR_REASON.CONFIG_PARSE_FAILED : ERROR_REASON.PHASE_NAMING_NOT_CUSTOM);
1808
+ }
1412
1809
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1413
1810
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1414
1811
  error('ROADMAP.md not found');
1415
1812
  }
1416
- const projectCode = config.project_code || '';
1417
- const prefix = projectCode ? `${projectCode}-` : '';
1813
+ if (convention === 'bracket')
1814
+ assertBracketGeneratedIdentity('phase add-batch', config);
1815
+ const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1418
1816
  const results = withPlanningLock(cwd, () => {
1419
1817
  let rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1818
+ if (bracketContext)
1819
+ assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add-batch');
1420
1820
  const content = extractCurrentMilestone(rawContent, cwd);
1421
1821
  let maxPhase = 0;
1422
- if (config.phase_naming !== 'custom') {
1423
- // Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
1424
- // bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
1425
- // `Phase N` row was invisible to batch allocation (#3849 secondary).
1426
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1427
- // #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
1428
- // (src/phase-id.cjs) with the LABEL_ONLY baseline and no convention
1429
- // argument — same counter-site migration as cmdPhaseAdd's identical
1430
- // regex above.
1431
- const { regex: phasePattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1432
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1433
- let m;
1434
- while ((m = phasePattern.exec(content)) !== null) {
1435
- const num = parseInt(m[phaseNumGroup], 10);
1436
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1437
- if (isSentinelPhaseId(num))
1438
- continue;
1439
- if (num > maxPhase)
1440
- maxPhase = num;
1441
- }
1442
- while ((m = bulletPattern.exec(content)) !== null) {
1443
- const num = parseInt(m[1], 10);
1444
- if (isSentinelPhaseId(num))
1445
- continue;
1446
- if (num > maxPhase)
1447
- maxPhase = num;
1448
- }
1449
- const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1450
- if (node_fs_1.default.existsSync(phasesOnDisk)) {
1451
- const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1452
- for (const entry of node_fs_1.default.readdirSync(phasesOnDisk)) {
1453
- const match = entry.match(dirNumPattern);
1454
- if (!match)
1455
- continue;
1456
- const num = parseInt(match[1], 10);
1457
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1458
- if (isSentinelPhaseId(num))
1459
- continue;
1460
- if (num > maxPhase)
1461
- maxPhase = num;
1462
- }
1463
- }
1464
- // 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
1465
- const siblingNums = new Set();
1466
- collectSiblingWorktreePhaseNums(cwd, siblingNums);
1467
- for (const num of siblingNums) {
1468
- if (num > maxPhase)
1469
- maxPhase = num;
1470
- }
1822
+ if (bracketContext) {
1823
+ const used = collectBracketPhaseNumbers(content, node_path_1.default.join(planningDir(cwd), 'phases'), bracketContext);
1824
+ collectSiblingWorktreePhaseNums(cwd, used, bracketContext);
1825
+ maxPhase = maxPhaseNum(used);
1471
1826
  }
1472
- const added = [];
1827
+ else if (naming.phaseNaming !== 'custom') {
1828
+ // Same three cwd-scoped sources as cmdPhaseAdd (#1229, #5528): headers, roadmap bullets,
1829
+ // on-disk dirs, then sibling worktrees (#3849) — same max+1, wider horizon.
1830
+ maxPhase = maxPhaseNum(collectUsedPhaseNums(cwd, content));
1831
+ }
1832
+ // #4304: compute and validate every item's slug/dirName
1833
+ // BEFORE the first `platformEnsureDir` — a bracket `toDir` failure
1834
+ // (e.g. a description that sanitizes to an empty slug) must refuse the
1835
+ // whole batch with zero directories created, not throw mid-loop after
1836
+ // earlier items already created theirs. This first pass touches no
1837
+ // disk.
1838
+ const validated = [];
1473
1839
  for (const description of descriptions) {
1474
1840
  const slug = generateSlugInternal(description) || '';
1475
1841
  let newPhaseId;
1476
1842
  let dirName;
1477
- if (config.phase_naming === 'custom') {
1843
+ if (bracketContext) {
1844
+ maxPhase = nextPhaseNum(maxPhase);
1845
+ newPhaseId = maxPhase;
1846
+ dirName = bracketDirNameOrRefuse(bracketPhaseId(bracketContext, newPhaseId), slug, description);
1847
+ }
1848
+ else if (naming.phaseNaming === 'custom') {
1478
1849
  newPhaseId = slug.toUpperCase();
1479
- dirName = `${prefix}${newPhaseId}-${slug}`;
1850
+ dirName = '';
1480
1851
  }
1481
1852
  else {
1482
- maxPhase += 1;
1853
+ maxPhase = nextPhaseNum(maxPhase);
1483
1854
  newPhaseId = maxPhase;
1484
- dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
1855
+ dirName = '';
1485
1856
  }
1486
- const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', dirName);
1487
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1488
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1489
- const dependsOn = config.phase_naming === 'custom' ? '' : dependsOnLine(newPhaseId, roadmapDeclaredPhaseNums(rawContent));
1490
- const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
1491
- const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1492
- rawContent = spliceRoadmapBlock(rawContent, insertAt, phaseEntry);
1857
+ validated.push({ description, slug, newPhaseId, dirName });
1858
+ }
1859
+ // Batch creation is all-or-nothing: validate every bracket destination
1860
+ // before the first directory or ROADMAP byte is written.
1861
+ if (bracketContext) {
1862
+ const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1863
+ for (const { dirName } of validated) {
1864
+ assertPhaseDirectoryDestinationSafe(phasesDir, dirName, 'create');
1865
+ }
1866
+ }
1867
+ if (!bracketContext) {
1868
+ // One preflight over the whole batch (identity collisions, occupied
1869
+ // targets, containment) before the first directory is created.
1870
+ const prepared = phaseLocatorMod.createPhaseDirectories(cwd, validated.map(item => ({
1871
+ id: String(item.newPhaseId), slug: item.slug,
1872
+ directoryId: typeof item.newPhaseId === 'number' ? String(item.newPhaseId).padStart(2, '0') : item.newPhaseId,
1873
+ })), { ...naming, roadmap: content });
1874
+ prepared.forEach((entry, index) => { validated[index].dirName = entry.dirName; });
1875
+ }
1876
+ const added = [];
1877
+ for (const { description, slug, newPhaseId, dirName } of validated) {
1878
+ if (bracketContext) {
1879
+ const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', dirName);
1880
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1881
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1882
+ }
1883
+ let phaseEntry;
1884
+ if (bracketContext && typeof newPhaseId === 'number') {
1885
+ const id = bracketPhaseId(bracketContext, newPhaseId);
1886
+ const declared = new Set();
1887
+ // rawContent includes earlier batch items, which become prerequisites.
1888
+ addBracketRoadmapPhaseNumbers(extractCurrentMilestone(rawContent, cwd), declared);
1889
+ const dependsOn = dependsOnLine(newPhaseId, [...declared], (phase) => renderPhaseId(bracketPhaseId(bracketContext, phase)));
1890
+ phaseEntry =
1891
+ `\n### ${renderPhaseId(id)}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${id.phase} to break down)\n`;
1892
+ }
1893
+ else {
1894
+ const dependsOn = naming.phaseNaming === 'custom' ? '' : dependsOnFor(newPhaseId, rawContent);
1895
+ phaseEntry =
1896
+ `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
1897
+ }
1898
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd, convention);
1899
+ const recorded = recordNewPhase(spliceRoadmapBlock(rawContent, insertAt, phaseEntry), cwd, newPhaseId, description, undefined, bracketContext && typeof newPhaseId === 'number' ? renderPhaseId(bracketPhaseId(bracketContext, newPhaseId)) : undefined);
1900
+ rawContent = recorded.content;
1493
1901
  added.push({
1902
+ roadmap_entry: recorded.entry,
1494
1903
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
1495
1904
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
1496
1905
  name: description,
1497
1906
  slug,
1498
1907
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1499
- naming_mode: config.phase_naming,
1908
+ naming_mode: naming.phaseNaming,
1500
1909
  });
1501
1910
  }
1502
1911
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, rawContent, { verbatim: true });
@@ -1570,13 +1979,214 @@ function scanExistingDecimalPhaseNumbers(phasesDir, rawContent, base) {
1570
1979
  while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
1571
1980
  decimalSet.add(parseInt(rmMatch[1], 10));
1572
1981
  }
1573
- const checklistDecimalPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
1982
+ const checklistDecimalPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
1574
1983
  let clMatch;
1575
1984
  while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
1576
1985
  decimalSet.add(parseInt(clMatch[1], 10));
1577
1986
  }
1578
1987
  return decimalSet;
1579
1988
  }
1989
+ function scanExistingBracketDecimalPhaseNumbers(phasesDir, roadmapContent, base, context, meta) {
1990
+ // A bracket repository can be mid-migration, and add-backlog deliberately
1991
+ // remains a legacy sentinel writer. Inventory is therefore the UNION of
1992
+ // the legacy spellings and the canonical bracket spellings that readers
1993
+ // accept. Do not route the 999 parent exclusively through
1994
+ // scanMilestonePhaseIds: that reader intentionally excludes the icebox
1995
+ // range for milestone counting, while allocation must include it.
1996
+ const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, base);
1997
+ for (const token of scanMilestonePhaseIds(roadmapContent, 'bracket')) {
1998
+ const [phase, subphase, extra] = String(token).split('.');
1999
+ if (!extra && Number(phase) === Number(base) && /^\d+$/.test(subphase ?? '')) {
2000
+ decimalSet.add(Number(subphase));
2001
+ }
2002
+ }
2003
+ for (const { id } of bracketIdsInContext(phasesDir, context)) {
2004
+ if (Number(id.phase) !== Number(base))
2005
+ continue;
2006
+ if (meta)
2007
+ meta.bracketSpellingFound = true;
2008
+ if (id.subphase)
2009
+ decimalSet.add(Number(id.subphase));
2010
+ }
2011
+ const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, 'bracket', true);
2012
+ const entryPattern = new RegExp(`^${intro}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[ \\t]*:`, 'i');
2013
+ const addRoadmapToken = (bracketId, token) => {
2014
+ const [phase, subphase, extra] = token.split('.');
2015
+ if (extra || Number(phase) !== Number(base) || !/^\d+$/.test(subphase ?? ''))
2016
+ return;
2017
+ if (bracketId) {
2018
+ if (foldBracketId(bracketId) !== foldBracketId(`${context.project}.${context.milestone}`))
2019
+ return;
2020
+ if (meta)
2021
+ meta.bracketSpellingFound = true;
2022
+ }
2023
+ decimalSet.add(Number(subphase));
2024
+ };
2025
+ // tokenizeHeadings is the fence-aware reader surface and, unlike the
2026
+ // milestone membership scanner, retains sentinel headings for this
2027
+ // allocation-specific inventory.
2028
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
2029
+ if (heading.level < 2 || heading.level > 4)
2030
+ continue;
2031
+ const match = entryPattern.exec(heading.text);
2032
+ if (match)
2033
+ addRoadmapToken(match[1], match[2]);
2034
+ }
2035
+ // The manager accepts bracket checklist rows without requiring a detail
2036
+ // heading. Match that same phase-intro grammar here so ROADMAP-only children
2037
+ // still reserve their number. Fenced examples are documentation, not live
2038
+ // inventory.
2039
+ const fencedLines = fencedRoadmapLineNumbers(roadmapContent);
2040
+ for (const [index, line] of roadmapContent.split('\n').entries()) {
2041
+ if (fencedLines.has(index + 1))
2042
+ continue;
2043
+ const checklist = parsePhaseChecklistLine(line.replace(/\r$/, ''), 'bracket');
2044
+ if (checklist)
2045
+ addRoadmapToken(checklist.bracketId, checklist.phaseToken);
2046
+ }
2047
+ return decimalSet;
2048
+ }
2049
+ /**
2050
+ * #4304: the ONE bracket-argument canonicalization `phase
2051
+ * remove` and `phase insert` both need, extracted from cmdPhaseRemove's own
2052
+ * canonicalization so insert accepts every form remove does instead of maintaining a
2053
+ * second, narrower copy that could silently disagree with it. A bare token
2054
+ * (`2`, `02`, `002`) canonicalizes through phase-id-display's `phaseToken`
2055
+ * adapter; a qualified/display token (`CK.02-02`, `[CK.02] 02`) canonicalizes
2056
+ * through phase-id.cts's strict `parsePhaseId`, refusing before any mutation
2057
+ * when it names a different milestone. `actionVerb` (e.g. "remove", "insert
2058
+ * after") only varies the refusal wording.
2059
+ */
2060
+ function canonicalizeBracketPhaseArgument(context, targetPhase, actionVerb) {
2061
+ const isQualified = targetPhase.startsWith('[') || targetPhase.includes('-');
2062
+ if (isQualified) {
2063
+ let qualifiedId = null;
2064
+ try {
2065
+ if (targetPhase.startsWith('[')) {
2066
+ const close = targetPhase.indexOf(']');
2067
+ // The owned-line reader canonicalizes numeric segments via phaseToken
2068
+ // before strict identity parsing; CLI display ids use that same owner.
2069
+ qualifiedId = phaseIdFromOwnedParts(targetPhase.slice(1, close), targetPhase.slice(close + 1).trim());
2070
+ }
2071
+ else {
2072
+ qualifiedId = parsePhaseId(targetPhase);
2073
+ }
2074
+ }
2075
+ catch {
2076
+ return error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`, ERROR_REASON.USAGE);
2077
+ }
2078
+ if (qualifiedId === null) {
2079
+ return error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`, ERROR_REASON.USAGE);
2080
+ }
2081
+ if (qualifiedId.project !== context.project || qualifiedId.milestone !== context.milestone) {
2082
+ error(`Phase ${targetPhase} belongs to milestone [${qualifiedId.project}.${qualifiedId.milestone}], `
2083
+ + `but the active milestone is [${context.project}.${context.milestone}]. `
2084
+ + `Refusing to ${actionVerb} a phase outside the active milestone.`);
2085
+ }
2086
+ const isDecimal = qualifiedId.subphase !== undefined;
2087
+ const prefix = bracketDirPrefix(qualifiedId);
2088
+ const slug = targetPhase.startsWith(prefix) ? targetPhase.slice(prefix.length) : undefined;
2089
+ return { normalized: isDecimal ? `${qualifiedId.phase}.${qualifiedId.subphase}` : qualifiedId.phase, isDecimal, slug };
2090
+ }
2091
+ const canonicalToken = phaseToken(targetPhase);
2092
+ if (canonicalToken === null) {
2093
+ error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`);
2094
+ return { normalized: '', isDecimal: false };
2095
+ }
2096
+ return { normalized: canonicalToken, isDecimal: canonicalToken.includes('.') };
2097
+ }
2098
+ /**
2099
+ * Select `phase insert`'s bracket target from the reader-owned active ranges.
2100
+ * A raw range may still contain a shipped `<details>` archive, so range
2101
+ * membership alone is not evidence that a same-numbered heading is live.
2102
+ * Reuse bracket removal's historical-section classifier and owned-line parser:
2103
+ * the latter derives its bracket intro from phase-id.cts's exported read
2104
+ * grammar, then `sameBracketPhaseId` qualifies project, milestone, and the
2105
+ * canonical phase number together. The insertion boundary comes from the same
2106
+ * fence-aware heading tokens, never a raw next-heading regex. A fenced block
2107
+ * that contains a phase-heading example is kept byte-identical and begins a
2108
+ * protected boundary, so the live inserted heading lands before the example
2109
+ * instead of inside its fence. No bare-number fallback exists here.
2110
+ */
2111
+ function activeBracketInsertHeading(content, targetId, ranges) {
2112
+ if (!ranges)
2113
+ return null;
2114
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
2115
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
2116
+ const searchRanges = [ranges.primary, ...(ranges.details ? [ranges.details] : [])];
2117
+ const lines = splitRoadmapLineRecords(content);
2118
+ const lineByStart = new Map(lines.map((line) => [line.start, line]));
2119
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
2120
+ const rawLines = content.split('\n');
2121
+ const protectedFenceStarts = (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(rawLines)
2122
+ .filter((block) => {
2123
+ const lastIndex = block.closeLineIdx === -1 ? rawLines.length - 1 : block.closeLineIdx;
2124
+ for (let index = block.openLineIdx + 1; index < lastIndex; index++) {
2125
+ if (classifyBracketOwnedLine(rawLines[index].replace(/\r$/, '')).kind === 'heading')
2126
+ return true;
2127
+ }
2128
+ return false;
2129
+ })
2130
+ .map((block) => lines[block.openLineIdx]?.start)
2131
+ .filter((start) => start !== undefined);
2132
+ for (const range of searchRanges) {
2133
+ const liveHeadings = headings.filter((heading) => heading.offset >= range.start
2134
+ && heading.offset < range.end
2135
+ && !historicalLineStarts.has(heading.offset));
2136
+ for (let headingIndex = 0; headingIndex < liveHeadings.length; headingIndex++) {
2137
+ const heading = liveHeadings[headingIndex];
2138
+ const line = lineByStart.get(heading.offset);
2139
+ if (!line || fencedLineNumbers.has(line.lineNumber))
2140
+ continue;
2141
+ const owned = classifyBracketOwnedLine(line.text);
2142
+ if (owned.kind !== 'heading' || !owned.id || !sameBracketPhaseId(owned.id, targetId))
2143
+ continue;
2144
+ let insertAt = range.end;
2145
+ for (const nextHeading of liveHeadings.slice(headingIndex + 1)) {
2146
+ const nextLine = lineByStart.get(nextHeading.offset);
2147
+ if (!nextLine)
2148
+ continue;
2149
+ const nextOwned = classifyBracketOwnedLine(nextLine.text);
2150
+ if (nextOwned.kind !== 'heading')
2151
+ continue;
2152
+ // #5599: bracket ids have one subphase level, so both nested and
2153
+ // sibling allocations append after this phase's existing children.
2154
+ if (nextOwned.id
2155
+ && nextOwned.id.project === targetId.project
2156
+ && nextOwned.id.milestone === targetId.milestone
2157
+ && isPhaseTokenWithin(bracketArtifactToken(nextOwned.id), targetId.phase))
2158
+ continue;
2159
+ insertAt = nextHeading.offset;
2160
+ break;
2161
+ }
2162
+ for (const fenceStart of protectedFenceStarts) {
2163
+ if (fenceStart > heading.offset && fenceStart < insertAt)
2164
+ insertAt = fenceStart;
2165
+ }
2166
+ for (const historicalStart of historicalLineStarts) {
2167
+ if (historicalStart > heading.offset && historicalStart < insertAt)
2168
+ insertAt = historicalStart;
2169
+ }
2170
+ return { start: line.start, length: line.text.length + line.eol.length, insertAt };
2171
+ }
2172
+ }
2173
+ return null;
2174
+ }
2175
+ /**
2176
+ * #5599: true when `token` (a heading/bullet phase number such as "13.1.2") is
2177
+ * a STRICT descendant of `ancestor` ("13"). Compared segment-wise so "130" is
2178
+ * not inside "13", and digit segments compare numerically so a zero-padded
2179
+ * "05.1" matches "5".
2180
+ */
2181
+ function isPhaseTokenWithin(token, ancestor) {
2182
+ const t = token.split('.');
2183
+ const a = ancestor.split('.');
2184
+ if (t.length <= a.length)
2185
+ return false;
2186
+ // Zero padding is not significant ("05" = "5", "03A" = "3A"); case is not either.
2187
+ const canon = (segment) => segment.replace(/^0+(?=\d)/, '').toLowerCase();
2188
+ return a.every((segment, i) => canon(segment) === canon(t[i]));
2189
+ }
1580
2190
  function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
1581
2191
  if (!afterPhase || !description) {
1582
2192
  error('after-phase and description required for phase insert');
@@ -1587,23 +2197,55 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1587
2197
  error('ROADMAP.md not found');
1588
2198
  }
1589
2199
  const slug = generateSlugInternal(description) || '';
1590
- const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
2200
+ const insertConfig = loadConfig(cwd);
2201
+ const convention = resolvePhaseIdConvention(cwd);
2202
+ const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, insertConfig) : null;
2203
+ const { decimalPhase, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
1591
2204
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1592
2205
  const content = extractCurrentMilestone(rawContent, cwd);
1593
- const normalizedAfter = normalizePhaseName(afterPhase);
2206
+ // #4304: canonicalize a bracket argument
2207
+ // through the SAME adapter `phase remove` uses
2208
+ // (canonicalizeBracketPhaseArgument) instead of the legacy
2209
+ // normalizePhaseName, which pads only the phase's FIRST segment ("1.1"
2210
+ // -> "01.1", never matching the bracket-canonical "01.01" heading) and
2211
+ // never accepted a qualified/display form ("CK.02-02", "[CK.02] 02") at
2212
+ // all. Every other convention keeps its untouched normalizePhaseName
2213
+ // behavior.
2214
+ let normalizedAfter;
2215
+ if (bracketContext) {
2216
+ normalizedAfter = canonicalizeBracketPhaseArgument(bracketContext, afterPhase, 'insert after').normalized;
2217
+ }
2218
+ else {
2219
+ normalizedAfter = normalizePhaseName(afterPhase);
2220
+ }
2221
+ // #4304: a bracket id supports at most one decimal level
2222
+ // (phase.subphase). Nesting one level deeper under an already-decimal
2223
+ // afterPhase would produce a three-level id no bracket function can
2224
+ // represent — bracketPhaseId/toDir would otherwise throw uncaught deep
2225
+ // inside directory/heading computation instead of refusing cleanly,
2226
+ // before any mutation. Sibling allocation is unaffected: it joins
2227
+ // afterPhase's PARENT level, which is always representable.
2228
+ if (bracketContext && allocation === 'nested' && normalizedAfter.includes('.')) {
2229
+ error(`Cannot insert a nested sub-phase under phase ${normalizedAfter}: bracket phase ids support at most one decimal level. Use --sibling, or insert after the parent phase instead.`);
2230
+ }
1594
2231
  const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
1595
2232
  const insertConvention = resolvePhaseIdConvention(cwd);
1596
- // #5007 (Phase 6 / ADR-4910 §8): buildPhaseHeadingRegex (src/roadmap.cts)
1597
- // is anchored `^...$` with no 'm' flag — designed to test ONE
1598
- // already-tokenized heading LINE, not to `.test()` against multi-line
1599
- // `content` directly (the design doc's "no anchoring change needed"
1600
- // claim for this site does not hold once the anchor is accounted for —
1601
- // same discrepancy as getRoadmapModeForPhase and phaseDisplayNameFromRoadmap
1602
- // above/below). Routed through tokenizeHeadings, mirroring
1603
- // searchPhaseInContent's own usage.
2233
+ const headingIntro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention);
1604
2234
  const targetPattern = buildPhaseHeadingRegex(afterPhaseEscaped, insertConvention);
1605
- const headingMatch = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).some((h) => targetPattern.test(h.text));
1606
- const bulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
2235
+ const bracketSectionRanges = bracketContext
2236
+ ? currentMilestoneRawRanges(rawContent, cwd, 'bracket')
2237
+ : null;
2238
+ const [targetPhase, targetSubphase] = normalizedAfter.split('.');
2239
+ const bracketTargetId = bracketContext
2240
+ ? bracketPhaseId(bracketContext, targetPhase, targetSubphase)
2241
+ : null;
2242
+ const bracketHeading = bracketTargetId
2243
+ ? activeBracketInsertHeading(rawContent, bracketTargetId, bracketSectionRanges)
2244
+ : null;
2245
+ const headingMatch = bracketContext
2246
+ ? bracketHeading !== null
2247
+ : (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).some((h) => targetPattern.test(h.text));
2248
+ const bulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
1607
2249
  // #5007 (Phase 6 / ADR-4910 §8): a pure "does ANY phase heading exist"
1608
2250
  // boolean — neither shared owner exposes this shape (buildPhaseHeadingRegex
1609
2251
  // is phase-N-specific; buildPhaseHeadingScanRegex always captures a phase
@@ -1614,19 +2256,34 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1614
2256
  // there is no anchor in this source.
1615
2257
  const anyHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}\\d`, 'i');
1616
2258
  const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
1617
- const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
2259
+ // #4304: bracket identities live in headings only, so a bracket ROADMAP never takes the legacy bullet-insertion branch — a bullet-only bracket ROADMAP falls through to the checklist-refusal path below instead.
2260
+ const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
2261
+ if (bracketContext && !bracketHeading) {
2262
+ const searchedRanges = bracketSectionRanges?.details
2263
+ ? 'primary and Phase Details ranges'
2264
+ : 'primary range';
2265
+ error(`Could not find live ${renderPhaseId(bracketTargetId)} heading in the active milestone window (${searchedRanges})`);
2266
+ }
1618
2267
  if (!headingMatch && !isBulletStyle) {
1619
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
2268
+ const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
1620
2269
  if (checklistPattern.test(content)) {
1621
2270
  error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
1622
2271
  }
1623
2272
  error(`Phase ${afterPhase} not found in ROADMAP.md`);
1624
2273
  }
1625
2274
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1626
- const normalizedBase = normalizePhaseName(afterPhase);
1627
- const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
2275
+ // #4304: reuse the SAME canonicalization computed above
2276
+ // (phaseToken on bracket, normalizePhaseName otherwise) rather than a
2277
+ // second, independent normalizePhaseName(afterPhase) call that would
2278
+ // silently disagree with it on bracket.
2279
+ const normalizedBase = normalizedAfter;
2280
+ const decimalSet = bracketContext
2281
+ ? scanExistingBracketDecimalPhaseNumbers(phasesDir, content, normalizedBase, bracketContext)
2282
+ : scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
1628
2283
  const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
1629
- let _decimalPhase = `${normalizedBase}.${nextDecimal}`;
2284
+ let selectedBase = normalizedBase;
2285
+ let selectedNextDecimal = nextDecimal;
2286
+ let _decimalPhase = `${selectedBase}.${selectedNextDecimal}`;
1630
2287
  // #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
1631
2288
  // one level deeper under afterPhase itself. A top-level phase (no existing
1632
2289
  // decimal segment) has no sibling level to join; nested is the only sensible
@@ -1634,20 +2291,48 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1634
2291
  const lastDotIndex = normalizedBase.lastIndexOf('.');
1635
2292
  if (allocation === 'sibling' && lastDotIndex !== -1) {
1636
2293
  const parentBase = normalizedBase.slice(0, lastDotIndex);
1637
- const siblingDecimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
2294
+ const siblingDecimalSet = bracketContext
2295
+ ? scanExistingBracketDecimalPhaseNumbers(phasesDir, content, parentBase, bracketContext)
2296
+ : scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
1638
2297
  const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
2298
+ selectedBase = parentBase;
2299
+ selectedNextDecimal = siblingNextDecimal;
1639
2300
  _decimalPhase = `${parentBase}.${siblingNextDecimal}`;
1640
2301
  }
1641
- const insertConfig = loadConfig(cwd);
1642
- const projectCode = insertConfig.project_code || '';
1643
- const pfx = projectCode ? `${projectCode}-` : '';
1644
- const _dirName = `${pfx}${_decimalPhase}-${slug}`;
2302
+ const bracketId = bracketContext
2303
+ ? bracketPhaseId(bracketContext, selectedBase, selectedNextDecimal)
2304
+ : null;
2305
+ if (bracketId)
2306
+ _decimalPhase = bracketArtifactToken(bracketId);
2307
+ // #5599: the family whose subtree the new entry must land at the end of —
2308
+ // the parent for a sibling allocation, the target itself for a nested one.
2309
+ const subtreeRoot = allocation === 'sibling' && lastDotIndex !== -1
2310
+ ? normalizedBase.slice(0, lastDotIndex)
2311
+ : normalizedBase;
2312
+ const insertNaming = phaseDirectoryOptions(cwd, undefined, rawContent);
2313
+ // #4304: route the bracket directory-name allocation
2314
+ // through the SAME bracketDirNameOrRefuse wrapper `phase add`/`phase
2315
+ // add-batch` already use, instead of a raw `toDir` call —
2316
+ // an empty or all-digit slug now refuses cleanly through error(...)
2317
+ // before any mutation, matching their wording, instead of an uncaught
2318
+ // "toDir: slug sanitizes to empty" throw.
2319
+ let _dirName;
2320
+ if (bracketId) {
2321
+ _dirName = bracketDirNameOrRefuse(bracketId, slug, description);
2322
+ }
2323
+ else {
2324
+ // An inserted child inherits its parent's identity policy: legacy numeric
2325
+ // families must still renumber together when their parent moves.
2326
+ [{ dirName: _dirName }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
2327
+ id: _decimalPhase, slug,
2328
+ recordIdentity: phaseIdMod.customPhaseQuery(afterPhase, insertNaming) !== null,
2329
+ }], insertNaming);
2330
+ }
1645
2331
  const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
1646
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1647
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1648
2332
  let updatedContent;
2333
+ let roadmapEntry;
1649
2334
  if (isBulletStyle) {
1650
- const boldBulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
2335
+ const boldBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i', { bold: 'required' });
1651
2336
  const useBold = boldBulletPattern.test(content);
1652
2337
  const phaseLabel = useBold
1653
2338
  ? `**Phase ${_decimalPhase}: ${description}**`
@@ -1660,6 +2345,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1660
2345
  // templating it via detectEol(rawContent) was inert dead code. '\n'
1661
2346
  // matches what platformWriteSync enforces anyway.
1662
2347
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
2348
+ // A non-bold bullet is not an entry `roadmap analyze` reads (checklistEntrySrc needs `**`).
2349
+ roadmapEntry = new RegExp(checklistEntrySrc(insertConvention, false, true), 'm').test(bulletEntry) ? 'checklist' : 'none';
1663
2350
  // #3413: was `[^\n]*`, which on CRLF content swallows the line's
1664
2351
  // trailing \r into the match, shifting bulletLineEnd to land BETWEEN
1665
2352
  // the \r and \n of the original CRLF pair — a pure splice-POSITION
@@ -1668,26 +2355,36 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1668
2355
  // .md targets regardless). Widening to [^\r\n]* stops the match at the
1669
2356
  // true line-content boundary so bulletLineEnd lands cleanly before the
1670
2357
  // terminator.
1671
- const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
2358
+ const targetBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`, 'i');
1672
2359
  const bulletMatchResult = rawContent.match(targetBulletPattern);
1673
2360
  if (!bulletMatchResult) {
1674
2361
  error(`Could not find Phase ${afterPhase} bullet line`);
1675
2362
  }
1676
2363
  const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
1677
2364
  const afterBullet = rawContent.slice(bulletLineEnd);
1678
- const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
1679
- let insertIdx;
1680
- if (nextBulletMatch) {
1681
- insertIdx = bulletLineEnd + nextBulletMatch.index;
1682
- }
1683
- else {
1684
- insertIdx = bulletLineEnd;
2365
+ // #5599: land after the last bullet inside the allocation family's
2366
+ // subtree, not before the first following bullet of any number (which is
2367
+ // the target's own later decimal child when one exists).
2368
+ const bulletRe = new RegExp(`\\r?\\n${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(?<tok>\\d[^\\s:*]*)[^\\r\\n]*`, 'gim');
2369
+ let insertIdx = bulletLineEnd;
2370
+ for (const m of afterBullet.matchAll(bulletRe)) {
2371
+ if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
2372
+ insertIdx = bulletLineEnd + m.index;
2373
+ break;
2374
+ }
2375
+ insertIdx = bulletLineEnd + m.index + m[0].length;
1685
2376
  }
1686
2377
  updatedContent =
1687
2378
  rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
1688
2379
  }
1689
2380
  else {
1690
- const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
2381
+ const headingId = bracketId ? renderPhaseId(bracketId) : `Phase ${_decimalPhase}`;
2382
+ let dependsId = `Phase ${afterPhase}`;
2383
+ if (bracketContext) {
2384
+ const [dependsPhase, dependsSubphase] = normalizedBase.split('.');
2385
+ dependsId = renderPhaseId(bracketPhaseId(bracketContext, dependsPhase, dependsSubphase));
2386
+ }
2387
+ const phaseEntry = `\n### ${headingId}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** ${dependsId}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
1691
2388
  // #5007 (Phase 6 / ADR-4910 §8): this site needs the WHOLE matched
1692
2389
  // heading line plus its trailing newline (for splicing the new entry
1693
2390
  // in immediately after it) — neither shared owner returns that shape
@@ -1697,45 +2394,79 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1697
2394
  // migration above (same describe block, same afterPhaseEscaped/
1698
2395
  // insertConvention inputs).
1699
2396
  const headerPattern = new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`, 'i');
1700
- const headerMatch = rawContent.match(headerPattern);
1701
- if (!headerMatch) {
1702
- error(`Could not find Phase ${afterPhase} header`);
1703
- }
1704
- const headerIdx = rawContent.indexOf(headerMatch[0]);
1705
- const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
1706
- // #5007 (Phase 6 / ADR-4910 §8): this next-phase-boundary lookup was NOT
1707
- // one of the 15 originally-marked grandfathered sites, but it hand-rolls
1708
- // the identical `#{2,4}...Phase\s+` heading-marker shape and sits right
1709
- // next to headerPattern above, which this same commit series just
1710
- // widened (via insertConvention) to recognize a bracket-tagged target
1711
- // heading. Left bracket-blind, this regex would silently defeat that
1712
- // widening: under the bracket convention a freshly-recognized
1713
- // bracket-tagged target could still fail to find the NEXT heading as a
1714
- // boundary (`[CODE.MM] Phase N+1:` never matches a bare `Phase\s+`
1715
- // literal), so the newly inserted entry falls through to the
1716
- // rawContent.length branch and lands appended at end-of-file instead of
1717
- // directly after the target section. Found while migrating the
1718
- // neighboring sites, not deferred: this is the same defect class the
1719
- // whole Phase 6 effort exists to drain, just an unmarked instance of it.
1720
- // Composed with the SAME baseline/convention as headerPattern above so
1721
- // target-discovery and next-boundary-discovery agree. The base
1722
- // `#{2,4}\s+` (mandatory whitespace after the hashes, not `\s*`) is
1723
- // preserved exactly as the prior literal had it — a pre-existing,
1724
- // deliberately-unchanged asymmetry with the other sites in this file.
1725
- const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
1726
- const nextPhaseMatch = afterHeader.match(new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}\\d[\\d.]*`, 'i'));
1727
- let insertIdx;
1728
- if (nextPhaseMatch) {
1729
- insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
2397
+ // #4304: bracket insertion is located by `activeBracketInsertHeading`
2398
+ // above, which requires all three ownership facts together: the line is
2399
+ // inside the reader-owned active window, is not historical under the
2400
+ // shared remove/guard classifier, and parses to the active bracket
2401
+ // identity plus canonical target number through the exported read
2402
+ // grammar. This closes the raw-range hole where an archived `[CK.01]
2403
+ // 01` inside `[CK.02]`'s primary bytes won a bare-number search. The
2404
+ // legacy path below keeps its whole-document pattern byte-for-behavior.
2405
+ //
2406
+ // #4304: the pre-flight headingMatch check above ran
2407
+ // against extractCurrentMilestone's content, which (for a bracket
2408
+ // repo) merges the primary section with a later "(Phase Details)"
2409
+ // section carrying the same milestone identity — so it can pass even
2410
+ // when the target's own detail heading lives ONLY in that Phase
2411
+ // Details range, separated from primary by an unrelated sibling
2412
+ // milestone. Search the SAME two raw ranges bracket removal
2413
+ // discovers (primary, then details) instead of primary alone, so a
2414
+ // heading the pre-flight check can see is also found here.
2415
+ const headerSearchRanges = bracketSectionRanges
2416
+ ? [
2417
+ bracketSectionRanges.primary,
2418
+ ...(bracketSectionRanges.details ? [bracketSectionRanges.details] : []),
2419
+ ]
2420
+ : [{ start: 0, end: rawContent.length }];
2421
+ let searchStart = bracketHeading?.start ?? headerSearchRanges[0].start;
2422
+ let searchEnd = bracketHeading?.insertAt ?? headerSearchRanges[0].end;
2423
+ let headerLength = bracketHeading?.length ?? 0;
2424
+ if (!bracketHeading) {
2425
+ let headerMatch = null;
2426
+ for (const range of headerSearchRanges) {
2427
+ const searchWindow = rawContent.slice(range.start, range.end);
2428
+ const match = searchWindow.match(headerPattern);
2429
+ if (match) {
2430
+ headerMatch = match;
2431
+ searchStart = range.start + match.index;
2432
+ searchEnd = range.end;
2433
+ headerLength = match[0].length;
2434
+ break;
2435
+ }
2436
+ }
2437
+ if (!headerMatch) {
2438
+ error(`Could not find Phase ${afterPhase} header`);
2439
+ }
1730
2440
  }
1731
- else {
1732
- insertIdx = rawContent.length;
2441
+ const headerIdx = searchStart;
2442
+ let insertIdx = bracketHeading?.insertAt ?? searchEnd;
2443
+ if (!bracketHeading) {
2444
+ const afterHeader = rawContent.slice(headerIdx + headerLength, searchEnd);
2445
+ const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
2446
+ // #5599: legacy insertion lands after the allocation family's entire
2447
+ // subtree; bracket insertion keeps its fence-aware, milestone-scoped boundary.
2448
+ const nextPhaseRe = new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}(?<tok>\\d[^\\s:]*)`, 'gi');
2449
+ for (const m of afterHeader.matchAll(nextPhaseRe)) {
2450
+ if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
2451
+ insertIdx = headerIdx + headerLength + m.index;
2452
+ break;
2453
+ }
2454
+ }
1733
2455
  }
1734
- updatedContent =
1735
- rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
2456
+ ({ content: updatedContent, entry: roadmapEntry } = recordNewPhase(rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx), cwd, _decimalPhase, description, afterPhase, bracketId ? renderPhaseId(bracketId) : undefined));
1736
2457
  }
1737
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
1738
- return { decimalPhase: _decimalPhase, dirName: _dirName };
2458
+ // #4304: every validation above — the pre-flight heading
2459
+ // check, the header/bullet-line search, and computing `updatedContent`
2460
+ // — must succeed (or `error()` out, which never returns) before the new
2461
+ // phase's directory is created. A failing insert now leaves `.planning`
2462
+ // byte-identical.
2463
+ if (bracketId) {
2464
+ assertPhaseDirectoryDestinationSafe(node_path_1.default.dirname(dirPath), _dirName, 'create');
2465
+ }
2466
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
2467
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
2468
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent, bracketContext ? { preserveFencedMarkdownStructure: true } : undefined);
2469
+ return { decimalPhase: _decimalPhase, dirName: _dirName, roadmapEntry };
1739
2470
  });
1740
2471
  const result = {
1741
2472
  phase_number: decimalPhase,
@@ -1743,6 +2474,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1743
2474
  name: description,
1744
2475
  slug,
1745
2476
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
2477
+ roadmap_entry: roadmapEntry,
1746
2478
  };
1747
2479
  output(result, raw, decimalPhase);
1748
2480
  // #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
@@ -1751,12 +2483,28 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1751
2483
  // target bullet/header) exits via `error()`, which terminates the process.
1752
2484
  publishStateContract(cwd);
1753
2485
  }
1754
- function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1755
- const renamedDirs = [];
1756
- const renamedFiles = [];
2486
+ /** Refuse every ambiguous owner before deletion or the first sibling rename. */
2487
+ function assertUnambiguousCustomDirectories(dirs, context) {
2488
+ for (const dir of dirs) {
2489
+ const candidates = phaseIdMod.customDirectoryCandidates(dir, context);
2490
+ if (candidates.length > 1) {
2491
+ error(`Ambiguous custom phase directory ${dir}; candidates: ${candidates.join(', ')}. Refusing removal or rename without ${CUSTOM_PHASE_ID_FILE}. Write the owning ID into ${dir}/${CUSTOM_PHASE_ID_FILE} and retry`, ERROR_REASON.AMBIGUOUS_PHASE_OWNER, { candidates, marker_path: toPosixPath(node_path_1.default.join(dir, CUSTOM_PHASE_ID_FILE)) });
2492
+ }
2493
+ }
2494
+ }
2495
+ /**
2496
+ * Directories a numeric removal renumbers. The renamers and the `phase remove`
2497
+ * preflight both read this one selector, so the set the preflight refuses on is
2498
+ * the set the renamers would touch. A directory a record or a unique known id
2499
+ * owns is a stable custom identity and is never renumbered; an ambiguous
2500
+ * markerless one stays selected so the caller refuses before any write.
2501
+ */
2502
+ function renameEligible(dirs, context) {
2503
+ return dirs.filter(dir => phaseIdMod.customDirectoryCandidates(dir, context).length !== 1);
2504
+ }
2505
+ function selectDecimalRenames(dirs, baseInt, removedDecimal, context) {
1757
2506
  const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
1758
- const dirs = readSubdirectories(phasesDir, true);
1759
- const toRename = dirs
2507
+ return renameEligible(dirs, context)
1760
2508
  .map((dir) => {
1761
2509
  const m = dir.match(decPattern);
1762
2510
  return m
@@ -1765,11 +2513,38 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1765
2513
  })
1766
2514
  .filter((item) => item !== null && item.oldDecimal > removedDecimal)
1767
2515
  .sort((a, b) => b.oldDecimal - a.oldDecimal);
1768
- for (const item of toRename) {
1769
- const newDecimal = item.oldDecimal - 1;
1770
- const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
1771
- const newPhaseId = `${baseInt}.${newDecimal}`;
1772
- const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
2516
+ }
2517
+ function selectIntegerRenames(dirs, removedInt, context) {
2518
+ return renameEligible(dirs, context)
2519
+ .map((dir) => {
2520
+ const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
2521
+ if (!m)
2522
+ return null;
2523
+ const dirInt = parseInt(m[1], 10);
2524
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2525
+ return dirInt > removedInt && !isSentinelPhaseId(dirInt)
2526
+ ? {
2527
+ dir,
2528
+ oldInt: dirInt,
2529
+ letter: m[2] ? m[2].toUpperCase() : '',
2530
+ decimal: m[3] ? parseInt(m[3], 10) : null,
2531
+ slug: m[4],
2532
+ }
2533
+ : null;
2534
+ })
2535
+ .filter((item) => item !== null)
2536
+ .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
2537
+ }
2538
+ function renameDecimalPhases(phasesDir, baseInt, removedDecimal, context) {
2539
+ const renamedDirs = [];
2540
+ const renamedFiles = [];
2541
+ const toRename = selectDecimalRenames(readSubdirectories(phasesDir, true), baseInt, removedDecimal, context);
2542
+ assertUnambiguousCustomDirectories(toRename.map(item => item.dir), context);
2543
+ for (const item of toRename) {
2544
+ const newDecimal = item.oldDecimal - 1;
2545
+ const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
2546
+ const newPhaseId = `${baseInt}.${newDecimal}`;
2547
+ const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
1773
2548
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
1774
2549
  renamedDirs.push({ from: item.dir, to: newDirName });
1775
2550
  for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
@@ -1805,30 +2580,12 @@ function findOrphanedDisplacementName(dir, fileName) {
1805
2580
  }
1806
2581
  return null;
1807
2582
  }
1808
- function renameIntegerPhases(phasesDir, removedInt) {
2583
+ function renameIntegerPhases(phasesDir, removedInt, context) {
1809
2584
  const renamedDirs = [];
1810
2585
  const renamedFiles = [];
1811
2586
  const renamedFileCollisions = [];
1812
- const dirs = readSubdirectories(phasesDir, true);
1813
- const toRename = dirs
1814
- .map((dir) => {
1815
- const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
1816
- if (!m)
1817
- return null;
1818
- const dirInt = parseInt(m[1], 10);
1819
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1820
- return dirInt > removedInt && !isSentinelPhaseId(dirInt)
1821
- ? {
1822
- dir,
1823
- oldInt: dirInt,
1824
- letter: m[2] ? m[2].toUpperCase() : '',
1825
- decimal: m[3] ? parseInt(m[3], 10) : null,
1826
- slug: m[4],
1827
- }
1828
- : null;
1829
- })
1830
- .filter((item) => item !== null)
1831
- .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
2587
+ const toRename = selectIntegerRenames(readSubdirectories(phasesDir, true), removedInt, context);
2588
+ assertUnambiguousCustomDirectories(toRename.map(item => item.dir), context);
1832
2589
  for (const item of toRename) {
1833
2590
  const newInt = item.oldInt - 1;
1834
2591
  const newPadded = String(newInt).padStart(2, '0');
@@ -1970,7 +2727,7 @@ function findDataRowLine(sectionText, dataRowIndex) {
1970
2727
  // the caller report accurately, the same fix #3685 applied to
1971
2728
  // `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
1972
2729
  // sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
1973
- function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
2730
+ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd, customTarget, stableIds) {
1974
2731
  return withPlanningLock(cwd, () => {
1975
2732
  const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1976
2733
  let content = originalContent;
@@ -1982,7 +2739,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1982
2739
  // makes the token padding-insensitive in both directions without widening
1983
2740
  // to other ids: the token stays anchored between `Phase\s+`/line-start and
1984
2741
  // `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
1985
- const padTolerant = `0*${escaped}`;
2742
+ const padTolerant = customTarget ? escaped : `0*${escaped}`;
2743
+ const stableIdentity = (id) => stableIds.has((isCustomPhaseId(id) ? id : normalizePhaseName(id)).toLowerCase());
1986
2744
  // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
1987
2745
  // detail section INCLUDING its own heading line. Migrated onto deleteSection
1988
2746
  // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
@@ -1996,7 +2754,11 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1996
2754
  // its tracking table.
1997
2755
  const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1998
2756
  content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
1999
- content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
2757
+ content = content.replace(
2758
+ // The entry prefix is the phase-entry grammar's (#5764): line-anchored, so a
2759
+ // mid-line mention of the phase is not deleted. `decorated` keeps the tolerance this
2760
+ // delete always had for an entry the readers skip (`- [x] ~~**Phase 2: X**~~`, an emoji).
2761
+ new RegExp(`\\n?${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ decorated: true })}Phase[ \\t]+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gim'), '');
2000
2762
  // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
2001
2763
  // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
2002
2764
  // of updateTableCell). Scoped to the `## Progress` section — mirroring
@@ -2012,229 +2774,1636 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2012
2774
  // `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
2013
2775
  // no whitespace to match (#2245 audit) — and which was also unscoped to
2014
2776
  // any particular table.
2015
- const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
2016
- if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
2017
- const headingOffset = progressHeadingMatch.index;
2018
- const before = content.slice(0, headingOffset);
2019
- const fromHeading = content.slice(headingOffset);
2020
- const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
2021
- const progressSection = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
2022
- const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
2023
- const matchRemovedProgressRow = (row) => {
2024
- const firstCellRaw = (Object.values(row)[0] ?? '').trim();
2025
- if (isDecimal) {
2026
- return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
2027
- }
2028
- const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
2029
- if (!leadingMatch || leadingMatch[2])
2030
- return false;
2031
- return parseInt(leadingMatch[1], 10) === removedInt;
2032
- };
2777
+ // #5762: the slice is the Progress owner's (`editRoadmapProgress`), the write side of the
2778
+ // rule every reader uses; `headingless: 'skip'` keeps the pre-owner "no `## Progress`
2779
+ // heading, no row delete" (a row delete must not guess a table by column names alone).
2780
+ const matchRemovedProgressRow = (row) => {
2781
+ const firstCellRaw = (Object.values(row)[0] ?? '').trim();
2782
+ if (customTarget) {
2783
+ return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
2784
+ }
2785
+ if (isDecimal) {
2786
+ return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
2787
+ }
2788
+ const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
2789
+ if (!leadingMatch || leadingMatch[2])
2790
+ return false;
2791
+ return parseInt(leadingMatch[1], 10) === removedInt;
2792
+ };
2793
+ content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
2033
2794
  const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
2034
- if (deleteResult.ok) {
2035
- content = before + deleteResult.value + rest;
2795
+ return deleteResult.ok ? deleteResult.value : progressSection;
2796
+ }, { headingless: 'skip' });
2797
+ if (!isDecimal && !customTarget) {
2798
+ // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
2799
+ // is re-emitted verbatim — a tagged later phase still gets renumbered.
2800
+ // #5007 (Phase 6 / ADR-4910 §8): composes phaseHeadingPrefixSrcFor
2801
+ // directly (needs prefix/number/suffix as three separate capture groups
2802
+ // for a verbatim in-place rewrite — neither shared owner returns that
2803
+ // shape). Two deliberate departures from the design doc's suggested
2804
+ // composition, both kept for zero behavior change on this
2805
+ // highest-blast-radius site (feeds phase remove's renumber path):
2806
+ // 1. The `#{2,4}\s*` heading marker is kept explicit and folded INTO
2807
+ // the prefix capture group — the design's snippet omitted it
2808
+ // entirely (phaseHeadingPrefixSrcFor never includes it; every
2809
+ // caller adds it, e.g. buildPhaseHeadingScanRegex), and the
2810
+ // original group 1 here captured the marker too.
2811
+ // 2. No convention argument is threaded (LABEL_ONLY with
2812
+ // convention=undefined), even though phaseHeadingPrefixSrcFor
2813
+ // COULD accept one — this keeps the composed source byte-identical
2814
+ // to the prior `Phase\s+` literal, a deliberate non-widening for
2815
+ // this specific site (unlike cmdPhaseInsert's targetPattern).
2816
+ // The number group widens from `\d+(?:\.\d+)?` to the full
2817
+ // PHASE_NUMBER_TOKEN_SOURCE (letter suffix + multi-segment decimals) —
2818
+ // this is SAFE, not a behavior change: decrementRoadmapPhaseToken's own
2819
+ // regex (`^(\d+)(\.\d+)?$`) rejects anything wider and returns the
2820
+ // token unchanged, so a heading this widening newly matches (e.g.
2821
+ // "Phase 12A:") is written back byte-for-byte unchanged, identical to
2822
+ // the pre-migration outcome of the whole regex simply not matching it.
2823
+ content = content.replace(new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)})(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE}\\s*:)`, 'gi'), (_match, prefix, num, suffix) => `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
2824
+ content = content.replace(
2825
+ // `decorated`, as the delete above: a struck-through or emoji entry is renumbered too,
2826
+ // or two entries would share one number after the remove.
2827
+ new RegExp(`(${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ decorated: true })}Phase[ \\t]+)(\\d+)([ \\t]*:|[ \\t]+|(?=\\r?$))`, 'gim'), (_match, prefix, num, suffix) => `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
2828
+ // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
2829
+ // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
2830
+ // directly above). The prior whole-document regex
2831
+ // `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
2832
+ // file — including a same-shaped cell in an UNRELATED, earlier table
2833
+ // (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
2834
+ // preceding `## Progress`; #2245-class scoping defect, same family as
2835
+ // the row-delete fix above). Scoped here to the `## Progress` section
2836
+ // only, mirroring that same section-slice-then-splice-back pattern.
2837
+ //
2838
+ // Loops because updateTableCell only rewrites the FIRST matching row
2839
+ // per call. `processedOrdinalRows` tracks by row INDEX (stable across
2840
+ // iterations — this only edits cell content, it never inserts/deletes
2841
+ // rows) so an already-decremented row's new value — which may still
2842
+ // numerically exceed `removedInt` — is never re-selected and
2843
+ // decremented a second time (matching on the row's CURRENT value alone,
2844
+ // without this guard, would keep re-firing on each pass).
2845
+ //
2846
+ // `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
2847
+ // required: a decimal sub-phase ordinal like `2.5` (no whitespace
2848
+ // between the dot and the next character) never matches it, so it is
2849
+ // left untouched — identical decimal-safety to the prior behaviour.
2850
+ //
2851
+ // updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
2852
+ // only, so the row's original leading/trailing alignment padding is
2853
+ // recovered by a narrow, anchored lookup within that row's OWN raw
2854
+ // line — addressed by ROW INDEX (`matchedRowIndex`, via
2855
+ // `findDataRowLine`), not by searching the whole section for content
2856
+ // matching the trimmed value (F8 #2245 review: two rows with identical
2857
+ // trimmed Phase text, or a row whose already-rewritten new value
2858
+ // coincides with another row's pre-edit text, would otherwise resolve
2859
+ // to the WRONG row's padding — the first/leftmost content match found).
2860
+ // The lookup searches for `escapeCell(current)` (F3 #2245 review: the
2861
+ // ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
2862
+ // escaped form, so searching for the unescaped `current` would
2863
+ // silently fail to find an escaped-pipe cell's own line — preserving
2864
+ // every other byte of the row (ADR-2143 §7 byte-parity) while only the
2865
+ // digits actually change.
2866
+ // #5762: the slice is the Progress owner's; no `## Progress` heading means no renumber.
2867
+ content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
2868
+ let ordinalSection = progressSection;
2869
+ const phaseCellShapeRe = /^(\d+)(\.\s)/;
2870
+ const processedOrdinalRows = new Set();
2871
+ let matchedRowIndex = null;
2872
+ for (;;) {
2873
+ matchedRowIndex = null;
2874
+ const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
2875
+ if (processedOrdinalRows.has(index))
2876
+ return false;
2877
+ const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
2878
+ if (!m || stableIdentity(m[1]))
2879
+ return false;
2880
+ const num = parseInt(m[1], 10);
2881
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2882
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
2883
+ return false;
2884
+ processedOrdinalRows.add(index);
2885
+ matchedRowIndex = index;
2886
+ return true;
2887
+ }, 'Phase', (current) => {
2888
+ const m = phaseCellShapeRe.exec(current);
2889
+ if (!m)
2890
+ return current;
2891
+ const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
2892
+ const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
2893
+ const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
2894
+ const padMatch = targetLine
2895
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
2896
+ : null;
2897
+ const leadPad = padMatch ? padMatch[1] : ' ';
2898
+ const trailPad = padMatch ? padMatch[2] : ' ';
2899
+ return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
2900
+ });
2901
+ if (!cellResult.ok)
2902
+ break;
2903
+ ordinalSection = cellResult.value;
2904
+ }
2905
+ return ordinalSection;
2906
+ }, { headingless: 'skip' });
2907
+ content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${stableIdentity(phaseNum) ? phaseNum : decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
2908
+ // #5007 (Phase 6 / ADR-4910): migrated the "Depends on" bold-field
2909
+ // decrement off two hand-rolled regexes — one per bold-placement
2910
+ // variant, `**Depends on**: Phase N` and `**Depends on:** Phase N` —
2911
+ // onto the PlanningDoc `boldField` read/write seam (`writePlansField`
2912
+ // above is the established precedent for this migration shape).
2913
+ // `BOLD_FIELD_RE` (planning-document.cts) recognizes BOTH placements
2914
+ // as the SAME field label ("Depends on") — `parseBoldFieldLine` strips
2915
+ // the token down to its inner text regardless of which side of `**`
2916
+ // the colon lands on — so this single loop naturally replaces both
2917
+ // regexes with one call site; no variant-specific branching remains.
2918
+ // Unlike `writePlansField`'s single `findField` lookup (scoped to one
2919
+ // already-isolated phase section), a whole ROADMAP.md can carry a
2920
+ // "Depends on" field in EVERY phase's detail section, so this walks
2921
+ // every `boldField` node in the whole parsed document whose label is
2922
+ // "Depends on" and rewrites each one independently — a
2923
+ // representability refusal or absent match on one field leaves that
2924
+ // field untouched and never blocks any other field, mirroring the
2925
+ // prior regexes' per-occurrence independence. A parse failure or an
2926
+ // unreadable-nodes refusal on `serialize` leaves `content` byte-for-
2927
+ // byte untouched, the same silent no-op the prior regexes had for any
2928
+ // input they didn't match (neither ever threw or warned).
2929
+ const dependsOnParsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, 'ROADMAP.md');
2930
+ if (dependsOnParsed.ok) {
2931
+ let dependsOnDoc = dependsOnParsed.value;
2932
+ const dependsOnFieldIds = dependsOnDoc.nodes
2933
+ .filter((n) => n.kind === 'boldField' && n.label === 'Depends on')
2934
+ .map((n) => n.id);
2935
+ for (const fieldId of dependsOnFieldIds) {
2936
+ const currentRead = (0, planning_document_cjs_1.readNode)(dependsOnDoc, fieldId);
2937
+ if (!currentRead.ok)
2938
+ continue;
2939
+ const depMatch = /^(Phase\s+)(\d+(?:\.\d+)?)\b/i.exec(currentRead.value);
2940
+ if (!depMatch)
2941
+ continue;
2942
+ const [wholeMatch, prefix, num] = depMatch;
2943
+ const newValue = `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseToken(num, removedInt)}` + currentRead.value.slice(wholeMatch.length);
2944
+ if (newValue === currentRead.value)
2945
+ continue;
2946
+ const staged = (0, planning_document_cjs_1.setFieldValue)(dependsOnDoc, fieldId, newValue);
2947
+ if (staged.ok)
2948
+ dependsOnDoc = staged.value;
2949
+ }
2950
+ const dependsOnOut = (0, planning_document_cjs_1.serialize)(dependsOnDoc);
2951
+ if (dependsOnOut.ok)
2952
+ content = dependsOnOut.value;
2953
+ }
2954
+ }
2955
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
2956
+ // #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
2957
+ // persists), not the raw pre-normalize `content` string, against the raw
2958
+ // pre-mutation `originalContent` read above — a raw `!==` here reports a
2959
+ // false `true` whenever this transform's regenerated output takes a
2960
+ // different-but-equivalent shape than the already-normalized on-disk
2961
+ // original (same normalization-order artifact #3685 fixed at
2962
+ // cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
2963
+ return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
2964
+ });
2965
+ }
2966
+ /**
2967
+ * #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
2968
+ * the leading frontmatter block's closing `---` fence — so a body field never
2969
+ * lands before the opening fence. The former whole-content prepend
2970
+ * (`field + content`) put the line ABOVE the opening `---`, and
2971
+ * syncStateFrontmatter then treated the scrambled fence structure as TWO
2972
+ * frontmatter blocks, rebuilding a derived one on top of the original
2973
+ * (milestone_name from a ROADMAP heading, total_phases counting the removed
2974
+ * phase, a stray 'Total Phases: 0' between fences). A file with no leading
2975
+ * frontmatter is all body: the field goes to content start, preserving the
2976
+ * former behavior for that shape.
2977
+ *
2978
+ * The block is the one `locateFrontmatterFence` finds (the one fence owner), so this writer
2979
+ * and every STATE.md reader agree on where the body starts. The blank line and the field go
2980
+ * right after the closing fence line, ended by that line's own line ending — so a CRLF file
2981
+ * gains CRLF lines only (#3572 review: a blanket re-join on '\r\n' doubled every carriage
2982
+ * return; joining the new lines on a bare '\n' mixed line endings).
2983
+ */
2984
+ function insertStateBodyFieldAtTop(content, fieldLine) {
2985
+ const fence = (0, frontmatter_fence_cjs_1.locateFrontmatterFence)(content);
2986
+ if (fence?.closed) {
2987
+ const nl = content[fence.closingFenceEnd] === '\r' ? '\r\n' : '\n';
2988
+ return `${content.slice(0, fence.closingFenceEnd)}${nl}${nl}${fieldLine}${content.slice(fence.closingFenceEnd)}`;
2989
+ }
2990
+ // All body: the field is the new first line, ended like the document's first line.
2991
+ const firstNewline = content.indexOf('\n');
2992
+ return fieldLine + (firstNewline > 0 && content[firstNewline - 1] === '\r' ? '\r\n' : '\n') + content;
2993
+ }
2994
+ function renameBracketArtifactFiles(phaseDir, oldId, newId, renamedFiles, renamedFileCollisions) {
2995
+ const oldToken = bracketArtifactToken(oldId);
2996
+ const newToken = bracketArtifactToken(newId);
2997
+ for (const file of node_fs_1.default.readdirSync(phaseDir)) {
2998
+ if (!file.startsWith(oldToken) || !/^(?:[-.]|$)/.test(file.slice(oldToken.length)))
2999
+ continue;
3000
+ const newFileName = newToken + file.slice(oldToken.length);
3001
+ const source = node_path_1.default.join(phaseDir, file);
3002
+ const destination = node_path_1.default.join(phaseDir, newFileName);
3003
+ if (node_fs_1.default.existsSync(destination)) {
3004
+ const displaced = findOrphanedDisplacementName(phaseDir, newFileName);
3005
+ if (!displaced) {
3006
+ renamedFileCollisions.push({ from: file, to: newFileName, displaced_to: null });
3007
+ continue;
2036
3008
  }
3009
+ (0, shell_command_projection_cjs_1.retryRenameSync)(destination, node_path_1.default.join(phaseDir, displaced));
3010
+ renamedFileCollisions.push({ from: file, to: newFileName, displaced_to: displaced });
3011
+ }
3012
+ (0, shell_command_projection_cjs_1.retryRenameSync)(source, destination);
3013
+ renamedFiles.push({ from: file, to: newFileName });
3014
+ }
3015
+ }
3016
+ /**
3017
+ * #4304: the ONE identity mapping shared by the disk rename
3018
+ * (renameBracketPhases, below) and the ROADMAP rewrite
3019
+ * (updateRoadmapAfterBracketPhaseRemoval) — disk and ROADMAP can never
3020
+ * disagree because both consume this same computation instead of each
3021
+ * deriving its own. Identities are collected from the same directory scan
3022
+ * renameBracketPhases uses to find rename candidates, unioned with every
3023
+ * heading/checklist/progress line inside the active milestone's own ranges
3024
+ * (primary + Phase Details): a phase can exist in ROADMAP with no directory
3025
+ * yet, or on disk with no matching ROADMAP line, and either source alone
3026
+ * can miss a decimal sub-phase identity. Both sources are canonical bracket
3027
+ * spellings only; cmdPhaseRemove refuses before any mutation while a legacy
3028
+ * spelling resolves to an identity this mapping deletes, moves, or lands on
3029
+ * (legacySpellingsEntangledWithBracketRemoval). Integer removal maps every phase
3030
+ * N > removed to N-1 (a sub-phase's own number is unchanged); sub-phase
3031
+ * removal maps every sub-phase S > removed within the target phase to S-1.
3032
+ * The result is sorted ascending by phase, then subphase (a bare phase before
3033
+ * its own sub-phases), so applying every entry to the same line in sequence
3034
+ * never re-matches a value an earlier entry just wrote: each entry's new
3035
+ * value is one below its old value, and every entry that could still hold
3036
+ * that value as its old value has already run. Bare and sub-phase entries
3037
+ * are independent of each other because the rewriters never match a bare
3038
+ * id inside a longer sub-phase id (`03` inside `03.01`).
3039
+ */
3040
+ function computeBracketRenumberMapping(phasesDir, roadmapContent, ranges, context, removedInt, removedSubphase) {
3041
+ const identities = new Map();
3042
+ const record = (phase, subphase) => {
3043
+ identities.set(`${phase}.${subphase ?? ''}`, { phase, subphase });
3044
+ };
3045
+ for (const { id } of bracketIdsInContext(phasesDir, context)) {
3046
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3047
+ }
3048
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3049
+ const fencedLineNumbers = fencedRoadmapLineNumbers(roadmapContent);
3050
+ for (const line of splitRoadmapLineRecords(roadmapContent)) {
3051
+ if (!lineStartsInActiveMilestone(line.start, ranges))
3052
+ continue;
3053
+ if (historicalLineStarts.has(line.start) || fencedLineNumbers.has(line.lineNumber))
3054
+ continue;
3055
+ const { id } = classifyBracketOwnedLine(line.text);
3056
+ if (!id || id.project !== context.project || id.milestone !== context.milestone)
3057
+ continue;
3058
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3059
+ }
3060
+ const filtered = [...identities.values()].filter(({ phase, subphase }) => {
3061
+ if (removedSubphase !== undefined) {
3062
+ return phase === removedInt && subphase !== undefined && subphase > removedSubphase;
3063
+ }
3064
+ return phase > removedInt && !isSentinelPhaseId(phase);
3065
+ });
3066
+ filtered.sort((a, b) => (a.phase - b.phase) || ((a.subphase ?? 0) - (b.subphase ?? 0)));
3067
+ return filtered.map(({ phase, subphase }) => ({
3068
+ oldId: bracketPhaseId(context, phase, subphase),
3069
+ newId: removedSubphase !== undefined
3070
+ ? bracketPhaseId(context, phase, subphase - 1)
3071
+ : bracketPhaseId(context, phase - 1, subphase),
3072
+ }));
3073
+ }
3074
+ /**
3075
+ * CommonMark fence exclusion shared by bracket removal's inventory and
3076
+ * pre-mutation ownership guard. A fenced heading/checklist is documentation,
3077
+ * never evidence about the live phase tree. Unclosed fences own through EOF.
3078
+ */
3079
+ function fencedRoadmapLineNumbers(content) {
3080
+ const fenced = new Set();
3081
+ const rawLines = content.split('\n');
3082
+ for (const block of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(rawLines)) {
3083
+ const lastIndex = block.closeLineIdx === -1 ? rawLines.length - 1 : block.closeLineIdx;
3084
+ for (let index = block.openLineIdx; index <= lastIndex; index++) {
3085
+ fenced.add(index + 1);
3086
+ }
3087
+ }
3088
+ return fenced;
3089
+ }
3090
+ /**
3091
+ * #4304: single fence-aware details-container tracker for bracket writers.
3092
+ * Every real tag carries its nesting depth before and after the tag, and every
3093
+ * matched block spans its own opening through the corresponding close. A
3094
+ * nested close therefore cannot terminate its enclosing block. Unclosed
3095
+ * blocks still contribute depth to the tag stream so deletion stays bounded.
3096
+ */
3097
+ function trackRoadmapDetails(content) {
3098
+ const lines = splitRoadmapLineRecords(content);
3099
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
3100
+ const tags = [];
3101
+ const blocks = [];
3102
+ const stack = [];
3103
+ const detailsTagRe = /<\/?details\b[^>]*>/gi;
3104
+ let lineIndex = 0;
3105
+ let match;
3106
+ while ((match = detailsTagRe.exec(content)) !== null) {
3107
+ while (lineIndex + 1 < lines.length && lines[lineIndex + 1].start <= match.index)
3108
+ lineIndex += 1;
3109
+ const line = lines[lineIndex];
3110
+ if (!line || fencedLineNumbers.has(line.lineNumber))
3111
+ continue;
3112
+ const closing = /^<\/details\b/i.test(match[0]);
3113
+ const depthBefore = stack.length;
3114
+ let opening;
3115
+ if (closing)
3116
+ opening = stack.pop();
3117
+ const tag = {
3118
+ offset: match.index,
3119
+ end: match.index + match[0].length,
3120
+ lineStart: line.start,
3121
+ closing,
3122
+ depthBefore,
3123
+ depthAfter: stack.length + (closing ? 0 : 1),
3124
+ };
3125
+ if (!closing)
3126
+ stack.push(tag);
3127
+ tags.push(tag);
3128
+ if (opening) {
3129
+ blocks.push({
3130
+ start: opening.offset,
3131
+ end: tag.end,
3132
+ startLine: opening.lineStart,
3133
+ endLine: tag.lineStart,
3134
+ text: content.slice(opening.offset, tag.end),
3135
+ });
3136
+ }
3137
+ }
3138
+ return { tags, blocks };
3139
+ }
3140
+ /**
3141
+ * #4304: does the bracket phase about to be removed (an
3142
+ * INTEGER phase, never a subphase itself — `phase remove NN.SS` is a
3143
+ * different, unaffected path) still have its own sub-phases?
3144
+ * `computeBracketRenumberMapping`'s own filter only ever maps
3145
+ * `phase > removedInt` — the removed phase's OWN sub-phases (`phase ===
3146
+ * removedInt`) are never mapped, deleted, or reported — so removing an
3147
+ * integer phase that still has sub-phases left them orphaned on disk and
3148
+ * in ROADMAP while the NEXT phase's sub-phases renumbered onto the SAME
3149
+ * identities, manufacturing duplicates. Scans the two sources
3150
+ * computeBracketRenumberMapping unions (the canonical directory scan, and
3151
+ * every heading/checklist/progress line outside CommonMark fences and inside
3152
+ * the active milestone's own ranges), plus the legacy-spelled child
3153
+ * directories the active reader resolves, which the mapping never
3154
+ * inventories. A child spelled either way refuses here; a legacy spelling of
3155
+ * any other identity the removal moves is refused by
3156
+ * legacySpellingsEntangledWithBracketRemoval.
3157
+ */
3158
+ function bracketPhaseOwnSubphases(phasesDir, roadmapContent, ranges, context, targetInt) {
3159
+ const bySubphase = new Map();
3160
+ const record = (phase, subphase) => {
3161
+ if (phase === targetInt && subphase !== undefined) {
3162
+ bySubphase.set(subphase, bracketPhaseId(context, phase, subphase));
3163
+ }
3164
+ };
3165
+ for (const { id } of bracketIdsInContext(phasesDir, context)) {
3166
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3167
+ }
3168
+ // #4304: bracketIdsInContext skips names outside the
3169
+ // canonical bracket grammar, but the reader also resolves migration-window
3170
+ // legacy children such as `CK-02.1-child`, and the allocator already
3171
+ // reserves them (readerResolvableBracketDirectoryPhaseNumber). Inventory
3172
+ // that same universe the same way: take each directory's own key and keep
3173
+ // a sub-phase of the target only when the active bracket reader resolves
3174
+ // that directory for that key. `record` dedupes canonical children.
3175
+ for (const dir of readSubdirectories(phasesDir, true)) {
3176
+ const [phasePart, subphasePart, ...rest] = phaseKeyFromDir(dir, 'bracket').split('.');
3177
+ if (rest.length > 0 || subphasePart === undefined)
3178
+ continue;
3179
+ if (!/^\d+$/.test(phasePart) || !/^\d+$/.test(subphasePart))
3180
+ continue;
3181
+ if (Number(phasePart) !== targetInt)
3182
+ continue;
3183
+ const key = `${phasePart}.${subphasePart}`;
3184
+ if (!matchPhaseDirs([dir], key, 'bracket', context).matches.includes(dir))
3185
+ continue;
3186
+ record(targetInt, Number(subphasePart));
3187
+ }
3188
+ const roadmapLines = splitRoadmapLineRecords(roadmapContent);
3189
+ const fencedLineNumbers = fencedRoadmapLineNumbers(roadmapContent);
3190
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3191
+ for (const line of roadmapLines) {
3192
+ if (!lineStartsInActiveMilestone(line.start, ranges))
3193
+ continue;
3194
+ if (fencedLineNumbers.has(line.lineNumber) || historicalLineStarts.has(line.start))
3195
+ continue;
3196
+ const { id } = classifyBracketOwnedLine(line.text);
3197
+ if (!id || id.project !== context.project || id.milestone !== context.milestone)
3198
+ continue;
3199
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3200
+ }
3201
+ return [...bySubphase.keys()].sort((a, b) => a - b).map((s) => bySubphase.get(s));
3202
+ }
3203
+ /**
3204
+ * #4304: identify ROADMAP lines owned by historical milestone sections.
3205
+ * A `<details>` block is historical only when the roadmap reader's own
3206
+ * closed-summary classifier says it is; an active collapsed phase list stays
3207
+ * live. Outside details, a reader-recognized CLOSED/ARCHIVED/SHIPPED milestone
3208
+ * heading owns the section through the next reader-recognized milestone
3209
+ * heading at the same or shallower level. Headings come from tokenizeHeadings,
3210
+ * so fenced examples are neither historical markers nor section resets.
3211
+ * Recognition is imported from the window locator's milestone-vs-phase
3212
+ * grammar before the marker predicate is applied, so an ordinary phase title
3213
+ * containing FAILED or ✅ never opens or resets a historical section.
3214
+ *
3215
+ * Details membership comes from the shared fence-aware depth tracker, so every
3216
+ * line remains archived while any enclosing details block is a closed
3217
+ * milestone archive. This classifier is consumed by both the active-window
3218
+ * safety guard and bracket removal's rewrite pass. Historical lines are
3219
+ * evidence of neither an active-window mismatch nor a reference that removal
3220
+ * may rewrite or delete.
3221
+ */
3222
+ function archivedOrClosedMilestoneLineStarts(content) {
3223
+ const historical = new Set();
3224
+ const headingsByOffset = new Map((0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).map((heading) => [heading.offset, heading]));
3225
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
3226
+ const lines = splitRoadmapLineRecords(content);
3227
+ const details = trackRoadmapDetails(content);
3228
+ const historicalDetailsLineStarts = new Set();
3229
+ for (const block of details.blocks) {
3230
+ if (!isClosedMilestoneDetails(block.text))
3231
+ continue;
3232
+ for (const line of lines) {
3233
+ if (line.start >= block.startLine && line.start <= block.endLine) {
3234
+ historicalDetailsLineStarts.add(line.start);
3235
+ }
3236
+ }
3237
+ }
3238
+ const detailsTagsByLine = new Map();
3239
+ for (const tag of details.tags) {
3240
+ const lineTags = detailsTagsByLine.get(tag.lineStart) ?? [];
3241
+ lineTags.push(tag);
3242
+ detailsTagsByLine.set(tag.lineStart, lineTags);
3243
+ }
3244
+ let detailsDepth = 0;
3245
+ let closedHeadingLevel = 0;
3246
+ for (const line of lines) {
3247
+ const fenced = fencedLineNumbers.has(line.lineNumber);
3248
+ const lineTags = detailsTagsByLine.get(line.start) ?? [];
3249
+ const insideDetails = detailsDepth > 0 || lineTags.some((tag) => !tag.closing);
3250
+ if (historicalDetailsLineStarts.has(line.start) || closedHeadingLevel > 0)
3251
+ historical.add(line.start);
3252
+ if (lineTags.length > 0)
3253
+ detailsDepth = lineTags[lineTags.length - 1].depthAfter;
3254
+ if (insideDetails) {
3255
+ continue;
3256
+ }
3257
+ if (fenced) {
3258
+ continue;
3259
+ }
3260
+ const heading = headingsByOffset.get(line.start);
3261
+ if (heading) {
3262
+ const level = heading.level;
3263
+ const milestoneHeading = isRecognizedMilestoneHeading(heading.text, level);
3264
+ if (milestoneHeading && closedHeadingLevel && level <= closedHeadingLevel)
3265
+ closedHeadingLevel = 0;
3266
+ if (!closedHeadingLevel && milestoneHeading && isClosedMilestoneHeading(heading.text)) {
3267
+ closedHeadingLevel = level;
3268
+ }
3269
+ }
3270
+ if (closedHeadingLevel > 0)
3271
+ historical.add(line.start);
3272
+ }
3273
+ return historical;
3274
+ }
3275
+ /**
3276
+ * #4304: a pre-mutation SAFETY CHECK, not a fix to the read
3277
+ * side's own window selection (mislocating the active window is PR-6 /
3278
+ * #4751 territory — this function does not touch that). When the read side
3279
+ * mislocates the active milestone window — a non-closed heading carrying
3280
+ * the active version token sits BEFORE the real milestone heading ("##
3281
+ * Goals for v2.0"), a document-level Progress heading placed before the
3282
+ * milestone heading, or no milestone heading exists at all — the checklist/
3283
+ * heading rewrite loop in `updateRoadmapAfterBracketPhaseRemoval` never
3284
+ * reaches the target's REAL heading/checklist lines, because they sit
3285
+ * outside `ranges`. Nothing downstream of that silently-empty rewrite stops
3286
+ * the destructive directory delete/rename that already ran by the time the
3287
+ * ROADMAP rewrite would have reported the mismatch, so the command "succeeds"
3288
+ * with an empty report while the target's heading and checklist survive
3289
+ * beside the sibling renumbered onto its old identity.
3290
+ *
3291
+ * Scans the WHOLE document outside CommonMark fences (not `ranges` — the
3292
+ * located window is exactly
3293
+ * what is in question) for the target's own heading/checklist lines via the
3294
+ * SAME classifier (`classifyBracketOwnedLine`) the rewrite loop uses. If at
3295
+ * least one is found and NONE of them fall inside the located `ranges`
3296
+ * (primary or details) — including when `ranges` is null, i.e. no milestone
3297
+ * window was located at all — the target is misplaced relative to what the
3298
+ * read side thinks is active, and the caller must refuse before touching
3299
+ * disk. A target correctly inside `ranges` (the overwhelmingly common case)
3300
+ * returns null immediately; this never widens or narrows behavior for a
3301
+ * correctly-located window.
3302
+ */
3303
+ function bracketOwnedLineOutsideActiveWindow(content, targetId, ranges) {
3304
+ // Heading deletion (`deleteSection`, scoped to `preDeleteRanges`) and
3305
+ // checklist-row deletion (the per-line loop, gated on `active`) are two
3306
+ // INDEPENDENT scoped operations in `updateRoadmapAfterBracketPhaseRemoval`
3307
+ // — a degenerate window can legitimately contain one kind of owned line
3308
+ // while missing the other (with no milestone heading, the version-less
3309
+ // bracket fallback selects the first PHASE heading as if it were the milestone heading, so
3310
+ // the resulting window happens to span every later phase HEADING to EOF
3311
+ // while the checklist bullets — which sit ABOVE that heading — are still
3312
+ // entirely outside it). Finding the heading safely inside must never mask
3313
+ // a checklist row that is not, or vice versa: track each kind separately.
3314
+ let headingInside = false;
3315
+ let checklistInside = false;
3316
+ let firstOutsideHeading = null;
3317
+ let firstOutsideChecklist = null;
3318
+ // #4304: a line archived inside <details> or sitting under a CLOSED milestone
3319
+ // heading (isClosedMilestoneHeading — the SAME predicate
3320
+ // currentMilestoneRawRanges itself uses to skip a closed heading when
3321
+ // selecting the active one) is a SHIPPED milestone's own line — never
3322
+ // evidence the ACTIVE window is mislocated. Same-code point releases
3323
+ // (milestoneToken folds v2.0/v2.1 to one bracket id) legitimately carry
3324
+ // the same [CODE.MM] NN identity in both the shipped archive/section and
3325
+ // the active phase's heading-only entry (exactly what `phase add` writes,
3326
+ // with no checklist bullet of its own) — this never widens what counts as
3327
+ // OUTSIDE, only narrows which OUTSIDE lines count as evidence.
3328
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
3329
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
3330
+ for (const line of splitRoadmapLineRecords(content)) {
3331
+ if (fencedLineNumbers.has(line.lineNumber))
3332
+ continue;
3333
+ const owned = classifyBracketOwnedLine(line.text);
3334
+ if (!owned.id || (owned.kind !== 'heading' && owned.kind !== 'checklist'))
3335
+ continue;
3336
+ if (!sameBracketPhaseId(owned.id, targetId))
3337
+ continue;
3338
+ const insideActive = Boolean(ranges
3339
+ && ((line.start >= ranges.primary.start && line.start < ranges.primary.end)
3340
+ || (ranges.details !== null
3341
+ && line.start >= ranges.details.start && line.start < ranges.details.end)));
3342
+ if (historicalLineStarts.has(line.start))
3343
+ continue;
3344
+ if (owned.kind === 'heading') {
3345
+ if (insideActive)
3346
+ headingInside = true;
3347
+ else if (!firstOutsideHeading)
3348
+ firstOutsideHeading = { lineNumber: line.lineNumber, text: line.text.trim() };
3349
+ }
3350
+ else {
3351
+ if (insideActive)
3352
+ checklistInside = true;
3353
+ else if (!firstOutsideChecklist)
3354
+ firstOutsideChecklist = { lineNumber: line.lineNumber, text: line.text.trim() };
3355
+ }
3356
+ }
3357
+ const outside = firstOutsideHeading && !headingInside ? firstOutsideHeading
3358
+ : firstOutsideChecklist && !checklistInside ? firstOutsideChecklist : null;
3359
+ return { outside, hasLiveTarget: headingInside || checklistInside };
3360
+ }
3361
+ function bracketRenameCandidates(phasesDir, context, mapping) {
3362
+ const mappingKey = (phase, subphase) => `${Number(phase)}.${subphase === undefined ? '' : Number(subphase)}`;
3363
+ const byKey = new Map();
3364
+ for (const { oldId, newId } of mapping)
3365
+ byKey.set(mappingKey(oldId.phase, oldId.subphase), newId);
3366
+ return bracketIdsInContext(phasesDir, context)
3367
+ .filter(({ id }) => byKey.has(mappingKey(id.phase, id.subphase)))
3368
+ .sort((a, b) => {
3369
+ const phaseDelta = Number(a.id.phase) - Number(b.id.phase);
3370
+ return phaseDelta !== 0
3371
+ ? phaseDelta
3372
+ : Number(a.id.subphase ?? 0) - Number(b.id.subphase ?? 0);
3373
+ })
3374
+ .map((item) => {
3375
+ const newId = byKey.get(mappingKey(item.id.phase, item.id.subphase));
3376
+ return { item, newId, newDirName: toDir(newId, item.slug) };
3377
+ });
3378
+ }
3379
+ function assertBracketRenameDestinationsSafe(phasesDir, context, mapping, targetDir) {
3380
+ const occupied = new Set(readSubdirectories(phasesDir));
3381
+ if (targetDir !== null)
3382
+ occupied.delete(targetDir);
3383
+ for (const { item, newDirName } of bracketRenameCandidates(phasesDir, context, mapping)) {
3384
+ // Simulate the same ascending sequence the rename loop will apply. A
3385
+ // destination is free only after its target or earlier source vacates it.
3386
+ assertPhaseDirectoryDestinationSafe(phasesDir, newDirName, 'renumber into', occupied);
3387
+ occupied.delete(item.dir);
3388
+ occupied.add(newDirName);
3389
+ }
3390
+ }
3391
+ function renameBracketPhases(phasesDir, context, mapping) {
3392
+ const renamedDirs = [];
3393
+ const renamedFiles = [];
3394
+ const renamedFileCollisions = [];
3395
+ for (const { item, newId, newDirName } of bracketRenameCandidates(phasesDir, context, mapping)) {
3396
+ // Recheck immediately before the rename as well as cmdPhaseRemove's
3397
+ // pre-mutation pass, closing the validation-to-use gap within this loop.
3398
+ assertPhaseDirectoryDestinationSafe(phasesDir, newDirName, 'renumber into');
3399
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
3400
+ renamedDirs.push({ from: item.dir, to: newDirName });
3401
+ renameBracketArtifactFiles(node_path_1.default.join(phasesDir, newDirName), item.id, newId, renamedFiles, renamedFileCollisions);
3402
+ }
3403
+ return { renamedDirs, renamedFiles, renamedFileCollisions };
3404
+ }
3405
+ const BRACKET_OWNED_PHASE_INTRO_SRC = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true);
3406
+ const BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC = `(${PHASE_NUMBER_TOKEN_SOURCE})`;
3407
+ const BRACKET_OWNED_TAG_SRC = '(?:[ \\t]*\\([^)\\r\\n]{0,200}\\))?';
3408
+ // #4304: every reader compiles BRACKET_OWNED_PHASE_INTRO_SRC's
3409
+ // own source (phaseHeadingPrefixSrcFor's bracket alternative) with the `i`
3410
+ // flag — `BRACKET_PROJECT_CODE_SRC` is deliberately spelled `[A-Z]...` on the
3411
+ // understanding that recognition folds case at compile time, never in the
3412
+ // source. The owned-line expressions below compile it with that same flag;
3413
+ // compiled with no flags at all, `[ck.02] 02:`,
3414
+ // `[CK.02] phase 02:` and `[CK.02] PHASE 02:` (case variants the read
3415
+ // grammar and `phase insert`/`phase add` accept) would
3416
+ // never classify as owned lines here. Headings and progress cells still compile
3417
+ // this source locally; checklist rows route through parsePhaseChecklistLine,
3418
+ // the same semantic reader used by init manager.
3419
+ const BRACKET_HEADING_LINE_RE = new RegExp(`^ {0,3}#{2,4}[ \\t]*${BRACKET_OWNED_PHASE_INTRO_SRC}`
3420
+ + `${BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC}${BRACKET_OWNED_TAG_SRC}[ \\t]*:`, 'i');
3421
+ const BRACKET_CELL_ID_RE = new RegExp(`^${BRACKET_OWNED_PHASE_INTRO_SRC}${BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC}`
3422
+ + `${BRACKET_OWNED_TAG_SRC}(?:[ \\t]*:|[ \\t]|$)`, 'i');
3423
+ function splitRoadmapLineRecords(content) {
3424
+ const records = [];
3425
+ const lineRe = /([^\r\n]*)(\r\n|\n|$)/g;
3426
+ let match;
3427
+ let lineNumber = 1;
3428
+ while ((match = lineRe.exec(content)) !== null) {
3429
+ if (match[0] === '')
3430
+ break;
3431
+ records.push({
3432
+ text: match[1],
3433
+ eol: match[2],
3434
+ start: match.index,
3435
+ lineNumber,
3436
+ });
3437
+ lineNumber += 1;
3438
+ }
3439
+ return records;
3440
+ }
3441
+ function phaseIdFromOwnedParts(bracketId, phaseNumber) {
3442
+ if (!bracketId || !phaseNumber)
3443
+ return null;
3444
+ try {
3445
+ // #4304: parsePhaseId's own display-form regex requires an
3446
+ // uppercase project code and checks canonicality by requiring the
3447
+ // re-rendered id to be byte-equal to the input, so a lowercase capture
3448
+ // from the case-insensitive owned-line regexes above (`[ck.02]
3449
+ // 02:`) would throw here and classify as "not owned", the
3450
+ // identity-recognition gap `foldBracketId`'s own doc comment in
3451
+ // phase-id.cts warns about ("fold before any identity operation; never
3452
+ // fold for display"). This is an identity operation.
3453
+ //
3454
+ // #4304: the captured NUMBER needs the exact same
3455
+ // treatment. BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC is deliberately
3456
+ // TOLERANT (PHASE_NUMBER_TOKEN_SOURCE, the read side's own grammar) so it
3457
+ // captures "2", "002", and "02.1" — the same non-canonical spellings
3458
+ // `roadmap get-phase`/`analyze`/`validate`, `phase insert` and
3459
+ // `phase add` treat as real phases, but parsePhaseId's
3460
+ // canonicality check (render(parse(x)) === x) throws on every one of
3461
+ // them, so without this step the line would classify as 'other' and its
3462
+ // owning heading/checklist/progress row would never be recognized.
3463
+ // Canonicalize through the SAME adapter the bare-token argument path
3464
+ // uses (phase-id-display's `phaseToken`, per-segment pad2) BEFORE
3465
+ // parsePhaseId, exactly as `canonicalizeBracketPhaseArgument` already
3466
+ // does for a CLI argument. A token phaseToken cannot canonicalize (a
3467
+ // legacy M-NN letter suffix) falls through unchanged, which still fails
3468
+ // parsePhaseId's own grammar precisely as before — this only widens
3469
+ // acceptance to spellings phaseToken itself accepts.
3470
+ const canonicalNumber = phaseToken(phaseNumber) ?? phaseNumber;
3471
+ return parsePhaseId(`[${foldBracketId(bracketId)}] ${canonicalNumber}`);
3472
+ }
3473
+ catch {
3474
+ return null;
3475
+ }
3476
+ }
3477
+ function phaseIdFromOwnedLineMatch(match) {
3478
+ return phaseIdFromOwnedParts(match?.[1], match?.[2]);
3479
+ }
3480
+ function classifyBracketOwnedLine(line) {
3481
+ const headingId = phaseIdFromOwnedLineMatch(BRACKET_HEADING_LINE_RE.exec(line));
3482
+ if (headingId)
3483
+ return { kind: 'heading', id: headingId };
3484
+ const checklist = parsePhaseChecklistLine(line, 'bracket');
3485
+ const checklistId = phaseIdFromOwnedParts(checklist?.bracketId, checklist?.phaseToken);
3486
+ if (checklistId)
3487
+ return { kind: 'checklist', id: checklistId };
3488
+ if (/^[ \t]*\|/.test(line)) {
3489
+ const firstCell = (0, markdown_table_cjs_1.splitTableRow)(line)[0]?.replace(/^\*\*(.*)\*\*$/, '$1') ?? '';
3490
+ const progressId = phaseIdFromOwnedLineMatch(BRACKET_CELL_ID_RE.exec(firstCell));
3491
+ if (progressId)
3492
+ return { kind: 'progress', id: progressId };
3493
+ }
3494
+ return { kind: 'other', id: null };
3495
+ }
3496
+ function sameBracketPhaseId(a, b) {
3497
+ return a.project === b.project
3498
+ && a.milestone === b.milestone
3499
+ && a.phase === b.phase
3500
+ && a.subphase === b.subphase;
3501
+ }
3502
+ /**
3503
+ * A bracket removal may share the live tree with migration-window legacy
3504
+ * spellings, but it must never mutate that legacy identity indirectly by
3505
+ * renumbering later bracket phases onto it. This check covers a target that
3506
+ * resolves only to legacy spellings; legacySpellingsEntangledWithBracketRemoval
3507
+ * covers a legacy spelling of any other identity the removal moves. Resolve both
3508
+ * directory and heading evidence before any write. `matchPhaseDirs` owns legacy directory selection;
3509
+ * `BRACKET_HEADING_LINE_RE` owns the reader-tolerant heading grammar and its
3510
+ * legacy `Phase N:` alternative. A real, non-historical bracket heading for the
3511
+ * target makes this an ordinary bracket removal even when legacy siblings are
3512
+ * also present.
3513
+ */
3514
+ function legacyOnlyBracketRemovalTarget(subdirs, roadmapContent, targetPhase, targetId, hasBracketDirectory) {
3515
+ const legacyNormalized = normalizePhaseName(targetPhase);
3516
+ const legacyDirectories = matchPhaseDirs(subdirs, legacyNormalized).matches.filter((dir) => {
3517
+ try {
3518
+ parsePhaseId(dir);
3519
+ return false;
3520
+ }
3521
+ catch {
3522
+ return true;
3523
+ }
3524
+ });
3525
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3526
+ const legacyHeadings = [];
3527
+ let hasBracketHeading = false;
3528
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
3529
+ if (heading.level < 2 || heading.level > 4)
3530
+ continue;
3531
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
3532
+ const owned = classifyBracketOwnedLine(headingLine);
3533
+ if (owned.kind === 'heading' && owned.id) {
3534
+ if (!historicalLineStarts.has(heading.offset) && sameBracketPhaseId(owned.id, targetId)) {
3535
+ hasBracketHeading = true;
3536
+ }
3537
+ continue;
3538
+ }
3539
+ // The shared bracket selector includes the legacy `Phase N:` alternative.
3540
+ // If classification did not yield a bracket id, capture that alternative's
3541
+ // numeric token and compare it through the same writer canonicalizer.
3542
+ const legacyMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
3543
+ const canonicalNumber = legacyMatch?.[2] ? phaseToken(legacyMatch[2]) : null;
3544
+ const targetNumber = targetId.subphase ? `${targetId.phase}.${targetId.subphase}` : targetId.phase;
3545
+ if (canonicalNumber === targetNumber)
3546
+ legacyHeadings.push(headingLine);
3547
+ }
3548
+ if (hasBracketDirectory || hasBracketHeading)
3549
+ return null;
3550
+ if (legacyDirectories.length === 0 && legacyHeadings.length === 0)
3551
+ return null;
3552
+ return { directories: legacyDirectories, headings: legacyHeadings };
3553
+ }
3554
+ /**
3555
+ * #4304: legacy spellings the active bracket reader resolves to an identity
3556
+ * this removal deletes, renumbers, or renumbers onto. The removal renames only
3557
+ * canonical bracket directories and rewrites only bracket-owned ROADMAP lines
3558
+ * (the inventory computeBracketRenumberMapping unions), so such a spelling
3559
+ * would keep its old number: orphaned, or read as whichever phase lands on
3560
+ * that number. Integer removal moves whole phases, so a spelling counts when
3561
+ * its phase number is the target's or any mapped phase's, old or new, which
3562
+ * also covers a legacy sub-phase under a moving parent. Sub-phase removal
3563
+ * compares full identities. A legacy phase outside that set keeps a number
3564
+ * nothing claims after the removal, so it does not block it.
3565
+ *
3566
+ * Directories are inventoried the way the allocator and the removal guard for
3567
+ * the target's own sub-phases do: each non-canonical directory's
3568
+ * `phaseKeyFromDir` key, kept only when `matchPhaseDirs` resolves the directory
3569
+ * for that key. Headings use legacyOnlyBracketRemovalTarget's recognition,
3570
+ * limited to the active milestone's own non-historical lines.
3571
+ */
3572
+ function legacySpellingsEntangledWithBracketRemoval(phasesDir, roadmapContent, ranges, context, removedInt, removedSubphase, mapping) {
3573
+ const identityKey = (phase, subphase) => `${phase}.${subphase ?? ''}`;
3574
+ const movingPhases = new Set([removedInt]);
3575
+ const movingIdentities = new Set([identityKey(removedInt, removedSubphase)]);
3576
+ for (const { oldId, newId } of mapping) {
3577
+ for (const id of [oldId, newId]) {
3578
+ const subphase = id.subphase === undefined ? undefined : Number(id.subphase);
3579
+ movingPhases.add(Number(id.phase));
3580
+ movingIdentities.add(identityKey(Number(id.phase), subphase));
3581
+ }
3582
+ }
3583
+ const entangled = (phase, subphase) => (removedSubphase === undefined
3584
+ ? movingPhases.has(phase)
3585
+ : subphase !== undefined && movingIdentities.has(identityKey(phase, subphase)));
3586
+ const numericIdentity = (token) => {
3587
+ const [phasePart, subphasePart, ...rest] = token.split('.');
3588
+ if (rest.length > 0 || !/^\d+$/.test(phasePart))
3589
+ return null;
3590
+ if (subphasePart !== undefined && !/^\d+$/.test(subphasePart))
3591
+ return null;
3592
+ const phase = Number(phasePart);
3593
+ if (isSentinelPhaseId(phase))
3594
+ return null;
3595
+ return subphasePart === undefined ? { phase } : { phase, subphase: Number(subphasePart) };
3596
+ };
3597
+ const found = [];
3598
+ for (const dir of readSubdirectories(phasesDir, true)) {
3599
+ try {
3600
+ parsePhaseId(dir);
3601
+ continue;
3602
+ }
3603
+ catch {
3604
+ // Not canonical bracket grammar: a migration-window legacy spelling.
3605
+ }
3606
+ const key = phaseKeyFromDir(dir, 'bracket');
3607
+ const identity = numericIdentity(key);
3608
+ if (!identity || !entangled(identity.phase, identity.subphase))
3609
+ continue;
3610
+ if (!matchPhaseDirs([dir], key, 'bracket', context).matches.includes(dir))
3611
+ continue;
3612
+ found.push({ kind: 'directory', spelling: dir, id: bracketPhaseId(context, identity.phase, identity.subphase) });
3613
+ }
3614
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3615
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
3616
+ if (heading.level < 2 || heading.level > 4)
3617
+ continue;
3618
+ if (!lineStartsInActiveMilestone(heading.offset, ranges) || historicalLineStarts.has(heading.offset))
3619
+ continue;
3620
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
3621
+ if (classifyBracketOwnedLine(headingLine).id)
3622
+ continue;
3623
+ const legacyMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
3624
+ const canonicalNumber = legacyMatch?.[2] ? phaseToken(legacyMatch[2]) : null;
3625
+ const identity = canonicalNumber ? numericIdentity(canonicalNumber) : null;
3626
+ if (!identity || !entangled(identity.phase, identity.subphase))
3627
+ continue;
3628
+ found.push({ kind: 'heading', spelling: headingLine, id: bracketPhaseId(context, identity.phase, identity.subphase) });
3629
+ }
3630
+ return found;
3631
+ }
3632
+ /** Name a legacy spelling, the bracket identity it resolves to, and its bracket form. */
3633
+ function describeEntangledLegacySpelling({ kind, spelling, id }) {
3634
+ return kind === 'directory'
3635
+ ? `directory ${JSON.stringify(spelling)} is ${renderPhaseId(id)} (bracket prefix ${JSON.stringify(bracketDirPrefix(id))})`
3636
+ : `heading ${JSON.stringify(spelling)} is ${renderPhaseId(id)}`;
3637
+ }
3638
+ /**
3639
+ * Classify a fence-excluded heading token through the roadmap reader's shared
3640
+ * bracket-plus-legacy phase-entry predicate, then decide whether it names a
3641
+ * phase other than the selected removal target. Bracket headings retain their
3642
+ * full project+milestone identity; a bare numeric `Phase NN:` heading is
3643
+ * compared in the active target context. A reader-recognized custom legacy id
3644
+ * cannot equal a numeric bracket target and is therefore distinct.
3645
+ */
3646
+ function isDistinctReaderPhaseHeading(heading, targetId) {
3647
+ if (heading.level < 2 || heading.level > 4)
3648
+ return false;
3649
+ if (!isPhaseEntryHeading(heading.text, 'bracket'))
3650
+ return false;
3651
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
3652
+ const owned = classifyBracketOwnedLine(headingLine);
3653
+ if (owned.kind === 'heading' && owned.id)
3654
+ return !sameBracketPhaseId(owned.id, targetId);
3655
+ const numericMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
3656
+ const canonicalNumber = numericMatch?.[2] ? phaseToken(numericMatch[2]) : null;
3657
+ if (!canonicalNumber)
3658
+ return true;
3659
+ const [phase, subphase] = canonicalNumber.split('.');
3660
+ return phase !== targetId.phase || subphase !== targetId.subphase;
3661
+ }
3662
+ function dashBracketPhaseId(id) {
3663
+ return `${id.project}.${id.milestone}-${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
3664
+ }
3665
+ /** The `PP[.SS][-LL]` tail of a rendered bracket phase id — renderPhaseId minus its milestone-bracket prefix. */
3666
+ function bracketPhaseNumberSrc(id) {
3667
+ return renderPhaseId(id).slice(renderMilestoneId(id).length + 1);
3668
+ }
3669
+ function replaceQualifiedBracketReference(line, oldId, newId) {
3670
+ const boundary = 'A-Za-z0-9.-';
3671
+ const newNumber = bracketPhaseNumberSrc(newId);
3672
+ const oldDisplay = renderPhaseId(oldId);
3673
+ const qualified = tokenizePhaseDependencyReferences(line, 'bracket')
3674
+ .filter((token) => {
3675
+ if (token.kind !== 'qualified' || token.token !== oldDisplay)
3676
+ return false;
3677
+ const before = token.referenceStart === undefined ? '' : line[token.referenceStart - 1] ?? '';
3678
+ const after = line[token.end] ?? '';
3679
+ return !new RegExp(`[${boundary}]`).test(before) && !new RegExp(`[${boundary}]`).test(after);
3680
+ })
3681
+ .sort((a, b) => b.start - a.start);
3682
+ let rewritten = line;
3683
+ for (const token of qualified) {
3684
+ rewritten = rewritten.slice(0, token.start) + newNumber + rewritten.slice(token.end);
3685
+ }
3686
+ const oldDash = dashBracketPhaseId(oldId);
3687
+ const newDash = dashBracketPhaseId(newId);
3688
+ return rewritten.replace(new RegExp(`(?<![${boundary}])${(0, pattern_cjs_1.escapeRegex)(oldDash)}(?![${boundary}])`, 'g'), () => newDash);
3689
+ }
3690
+ function replaceBareBracketArtifactReference(line, oldId, newId) {
3691
+ const oldToken = bracketArtifactToken(oldId);
3692
+ const newToken = bracketArtifactToken(newId);
3693
+ const filename = `${(0, pattern_cjs_1.escapeRegex)(oldToken)}-\\d{2}`
3694
+ + '(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\\.md';
3695
+ return line.replace(new RegExp(`(?<![A-Za-z0-9_./-])${filename}(?![A-Za-z0-9_.-])`, 'g'), (match) => newToken + match.slice(oldToken.length));
3696
+ }
3697
+ function lineStartsInActiveMilestone(lineStart, ranges) {
3698
+ if (!ranges)
3699
+ return false;
3700
+ return (lineStart >= ranges.primary.start && lineStart < ranges.primary.end)
3701
+ || Boolean(ranges.details && lineStart >= ranges.details.start && lineStart < ranges.details.end);
3702
+ }
3703
+ /**
3704
+ * #4304: a NARROWER boundary than currentMilestoneRawRanges'
3705
+ * own primary/details ranges. Those stop only at a RECOGNIZED bracket/
3706
+ * version-bearing milestone heading, by design (the read path's own
3707
+ * milestone-scanning use case treats an unrelated heading — a
3708
+ * `## Requirements Traceability`, a `## Notes` — as staying inside the
3709
+ * current milestone's content, not as a boundary). A pipe-table row has no
3710
+ * "this is my milestone's own table" marker beyond ordinary heading
3711
+ * nesting, so deciding whether a progress/table row belongs to THIS
3712
+ * milestone needs the plain Markdown rule instead: stop at the next
3713
+ * heading of any kind at or above the milestone heading's own level —
3714
+ * the SAME rule deleteSection/collectSection already apply elsewhere.
3715
+ */
3716
+ function bracketMilestoneOwnTableEnd(content, sectionStart, headings) {
3717
+ const level = (content.slice(sectionStart).match(/^#{1,4}/) ?? ['#'])[0].length;
3718
+ for (const h of headings) {
3719
+ if (h.offset <= sectionStart)
3720
+ continue;
3721
+ if (h.level <= level)
3722
+ return h.offset;
3723
+ }
3724
+ return content.length;
3725
+ }
3726
+ /**
3727
+ * #4304: does `lineStart` fall inside the active milestone's
3728
+ * OWN progress/table content — its primary or details section, bounded by
3729
+ * `bracketMilestoneOwnTableEnd` rather than the wider currentMilestoneRawRanges
3730
+ * end?
3731
+ */
3732
+ function lineStartsInMilestoneOwnTable(content, lineStart, ranges, headings) {
3733
+ if (!ranges)
3734
+ return false;
3735
+ if (lineStart >= ranges.primary.start
3736
+ && lineStart < bracketMilestoneOwnTableEnd(content, ranges.primary.start, headings)) {
3737
+ return true;
3738
+ }
3739
+ return Boolean(ranges.details
3740
+ && lineStart >= ranges.details.start
3741
+ && lineStart < bracketMilestoneOwnTableEnd(content, ranges.details.start, headings));
3742
+ }
3743
+ // #4304: the ONE textual expression of "this heading is
3744
+ // titled Progress" — the title starts with the word "Progress", any
3745
+ // suffix admitted ("Progress", "Progress (v2.1)", "Progress — current"),
3746
+ // case-insensitive. `bracketProgressSectionRange` below already tolerated a
3747
+ // suffix at its own fixed level-2 anchor; `isProgressHeading` inside
3748
+ // `bracketOwnProgressSectionRanges` required an EXACT "progress" match, so
3749
+ // a suffixed OWN heading ("## Progress (v2.1)", "### Progress (v2.0)")
3750
+ // never engaged the own-section scope even though the document-first scope
3751
+ // already matched the same suffix. Both interpolate this one source now.
3752
+ // #5762: that one source now lives in the Roadmap Progress Module (`isProgressHeadingTitle`);
3753
+ // `bracketProgressSectionRange` below is `findProgressSection` minus archived milestones.
3754
+ /**
3755
+ * #4304: the SAME `## Progress`-section scope legacy's
3756
+ * updateRoadmapAfterPhaseRemoval already uses (#2012, src/phase.cts:2482-2500)
3757
+ * — the first `## Progress` heading (case-insensitive) through the next
3758
+ * `#`/`##` heading or EOF. Lets a DOCUMENT-LEVEL Progress table that sits
3759
+ * textually outside the active milestone's own ranges (e.g. after a later
3760
+ * milestone's own section, the existing "global Progress table" shape) stay
3761
+ * in scope for the target row's own deletion, exactly as it already was.
3762
+ */
3763
+ function bracketProgressSectionRange(content) {
3764
+ // #5762: the Progress owner's section (first level-2 Progress heading to the next level-1/2
3765
+ // heading), minus the archived/closed milestone headings this remover must not touch.
3766
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
3767
+ return (0, roadmap_progress_cjs_1.findProgressSection)(content, { exclude: (offset) => historicalLineStarts.has(offset) });
3768
+ }
3769
+ /**
3770
+ * #4304: every distinct RECOGNIZED milestone
3771
+ * heading in the document, one representative offset per VERSION (one
3772
+ * carrying a version token — `listMilestoneHeadings`' own grammar, via the
3773
+ * SAME selection rule, `selectMilestoneHeading`, `currentMilestoneRawRanges`
3774
+ * already uses to pick "the" heading for a single version) PLUS one marker
3775
+ * per version-LESS bracket milestone heading recognized by the window
3776
+ * locator's own grammar (`isBracketMilestoneBoundary`,
3777
+ * src/roadmap-parser.cts — the SAME predicate `bracketAwareMilestoneSection`
3778
+ * uses to decide a candidate heading is a milestone boundary while walking
3779
+ * to find where the ACTIVE milestone's own section ends).
3780
+ *
3781
+ * A version-less ADR-612 canonical milestone heading
3782
+ * (`## [CK.02] Shipped ✅`, no `vX.Y` anywhere) is a marker because the
3783
+ * window locator recognizes it as a milestone boundary when walking the
3784
+ * document. Without it, a same-code shipped/active pair sharing no version
3785
+ * token would leave the shipped milestone's own `## Progress` heading
3786
+ * invisible to the sandwich test, and its own Complete row would be
3787
+ * deleted as if the table were shared/global.
3788
+ *
3789
+ * Deliberately NOT merged into one "one representative per identity" pass:
3790
+ * a shipped and active milestone commonly share the SAME bracket code
3791
+ * (`milestoneToken` folds `v2.0`/`v2.1` alike) while remaining two SEPARATE
3792
+ * heading positions that each open their own section — collapsing them by
3793
+ * id would silently drop one of the two boundaries the sandwich test needs.
3794
+ * Version-token headings keep their existing one-per-version selection
3795
+ * (unaffected, since every such heading already carries the distinguishing
3796
+ * signal); only headings the version grammar cannot see at all (no `vX.Y`
3797
+ * anywhere on the line) fall through to the boundary-predicate scan, so a
3798
+ * heading already represented by the version loop is never double-counted
3799
+ * merely for also being bracket-shaped (its own "(Phase Details)"
3800
+ * continuation heading carries the SAME version token and is excluded here
3801
+ * exactly as it always was — `selectMilestoneHeading` picks only the first
3802
+ * non-closed occurrence of that version, never the continuation).
3803
+ * `content[h.offset] === '#'` mirrors `bracketFallbackHeadingMatches`'s own
3804
+ * ATX-only guard (a `<summary>` line naming a milestone is never a heading).
3805
+ * Sorted ascending. Used to decide which milestone (if any) a document-first
3806
+ * `## Progress` heading sits immediately after, with nothing of its own kind
3807
+ * in between.
3808
+ */
3809
+ function bracketRecognizedMilestoneMarkers(content) {
3810
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
3811
+ const versions = new Set(listMilestoneHeadings(content).map((h) => h.version));
3812
+ const markers = [];
3813
+ for (const version of versions) {
3814
+ const selected = selectMilestoneHeading(content, version);
3815
+ if (selected?.index !== undefined)
3816
+ markers.push(selected.index);
3817
+ }
3818
+ for (const h of headings) {
3819
+ if (h.level > 3 || content[h.offset] !== '#')
3820
+ continue;
3821
+ // Already represented above: this heading carries a version token the
3822
+ // first loop already enumerated (its own group's selected marker, or —
3823
+ // for a same-version continuation like "(Phase Details)" — the OTHER
3824
+ // occurrence `selectMilestoneHeading` picked instead). Mirrors
3825
+ // `extractMilestoneHeadingName`'s own no-`expectedVersion` grammar
3826
+ // (src/roadmap-parser.cts) literally rather than importing a private
3827
+ // helper across the module boundary for one boolean test.
3828
+ if (/v\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*/i.test(h.text))
3829
+ continue;
3830
+ // #4304: a "(Phase Details)" heading is always a CONTINUATION of
3831
+ // whatever milestone opened before it (`currentMilestoneRawRanges`'s own
3832
+ // `detailsMatch` grammar, src/roadmap-parser.cts:2288-2294), never a
3833
+ // second, separate milestone marker in its own right — the version loop
3834
+ // above already skips a VERSIONED continuation for exactly this reason
3835
+ // (comment above); a version-LESS one (`## [CK.02] Current (Phase
3836
+ // Details)`) fell through to this boundary-predicate scan uncaught,
3837
+ // because `isBracketMilestoneBoundary` is called with `selectedBracketId`
3838
+ // `null` here and so cannot recognize it as a continuation of its own
3839
+ // milestone.
3840
+ if (/\(Phase\s+Details\)/i.test(h.text))
3841
+ continue;
3842
+ if (!isBracketMilestoneBoundary(h.text, h.level, null))
3843
+ continue;
3844
+ markers.push(h.offset);
3845
+ }
3846
+ return Array.from(new Set(markers)).sort((a, b) => a - b);
3847
+ }
3848
+ /**
3849
+ * #4304: does the DOCUMENT-FIRST
3850
+ * `## Progress` heading `bracketProgressSectionRange` found belong to a
3851
+ * DIFFERENT, non-active milestone's OWN dedicated section, rather than
3852
+ * being a genuinely document-level/shared table?
3853
+ *
3854
+ * The answer "the active milestone has a Progress heading of its own, and
3855
+ * this isn't it, so it must be someone else's" would over-claim: a
3856
+ * genuinely global `## Progress` that lists every milestone's rows (before
3857
+ * any milestone heading at all, or trailing after the LAST recognized
3858
+ * milestone heading with nothing bounding it on the far side) is neither
3859
+ * the active milestone's own nor any OTHER milestone's dedicated section.
3860
+ *
3861
+ * The rule asks a POSITIONAL question instead, over every recognized
3862
+ * milestone heading in the document (`bracketRecognizedMilestoneMarkers`),
3863
+ * not just the active one: is this Progress heading SANDWICHED strictly
3864
+ * between two recognized milestone headings — i.e. does it immediately
3865
+ * follow one specific milestone's own heading (nothing else of that kind in
3866
+ * between) AND does some other recognized milestone heading follow it
3867
+ * later in the document? Only then is it unambiguously that earlier
3868
+ * milestone's own trailing section (a shipped milestone's own table, or the
3869
+ * same-code two-versions shape). A Progress heading with NOTHING preceding it (top of document) or
3870
+ * NOTHING following it (trailing after the last recognized milestone
3871
+ * heading) is open, shared territory — never "elsewhere" — because a
3872
+ * milestone's own dedicated section and a document-wide table that merely
3873
+ * happens to sit after the last milestone's content are textually
3874
+ * indistinguishable by position alone once nothing bounds the far side.
3875
+ */
3876
+ function bracketProgressSectionOwnedByOtherMilestone(content, progressStart, activeRanges, ownProgressSectionRanges) {
3877
+ // Fast path: exactly one of the ACTIVE milestone's own recognized
3878
+ // Progress-titled headings (any level) is never "elsewhere".
3879
+ if (ownProgressSectionRanges.some((r) => r.start === progressStart))
3880
+ return false;
3881
+ // #4304: the positional question below applies only when the ACTIVE
3882
+ // milestone owns a Progress heading of its own; only then can a DIFFERENT
3883
+ // Progress heading be "someone else's". Without that precondition, a
3884
+ // document whose ACTIVE milestone has no dedicated Progress heading of its
3885
+ // own (the common single-shared-table layout: one global `## Progress`
3886
+ // table, no per-milestone one) would have its shared table declared
3887
+ // another milestone's the moment ANY version-bearing heading (a
3888
+ // `## Backlog (v4.0 candidates)` line, a changelog entry) followed it in
3889
+ // the document, because the positional scan alone cannot distinguish "this
3890
+ // table is the NEXT milestone's own dedicated section" from "this table is
3891
+ // shared and a later milestone heading simply comes after it in the file".
3892
+ // With no own Progress heading at all, the active milestone has no OWN claim
3893
+ // any Progress heading could be "instead of", so
3894
+ // a shared table can never be misread as belonging to a different one.
3895
+ if (ownProgressSectionRanges.length === 0)
3896
+ return false;
3897
+ // #4304: drop any marker lying STRICTLY inside the ACTIVE milestone's
3898
+ // own ranges — a heading inside the active's own window (a same-id prose
3899
+ // sub-heading like "### [CK.02] Notes", admitted by the boundary-predicate
3900
+ // scan above because it is bracket-shaped and version-less) never opens a
3901
+ // DIFFERENT milestone's section; it is content the active milestone itself
3902
+ // owns. A marker AT the range's own start (the active's own primary/details
3903
+ // heading) is kept — that is the "sandwiched under the ACTIVE milestone's
3904
+ // own heading" case just below, not an interior heading. Same-code SHIPPED
3905
+ // headings are never inside the active's own ranges, so they stay markers.
3906
+ const markers = bracketRecognizedMilestoneMarkers(content).filter((offset) => {
3907
+ if (!activeRanges)
3908
+ return true;
3909
+ const insidePrimary = offset > activeRanges.primary.start && offset < activeRanges.primary.end;
3910
+ const insideDetails = activeRanges.details !== null
3911
+ && offset > activeRanges.details.start && offset < activeRanges.details.end;
3912
+ return !insidePrimary && !insideDetails;
3913
+ });
3914
+ let precedingIndex = -1;
3915
+ for (let i = 0; i < markers.length; i++) {
3916
+ if (markers[i] <= progressStart)
3917
+ precedingIndex = i;
3918
+ else
3919
+ break;
3920
+ }
3921
+ // Nothing precedes it (top-of-document global table): not owned by anyone.
3922
+ if (precedingIndex === -1)
3923
+ return false;
3924
+ // Sandwiched under the ACTIVE milestone's own heading: not "elsewhere".
3925
+ const precedingOffset = markers[precedingIndex];
3926
+ if (activeRanges && precedingOffset === activeRanges.primary.start)
3927
+ return false;
3928
+ // Owned by a DIFFERENT milestone only when something else recognized
3929
+ // follows it — a trailing section after the LAST recognized milestone
3930
+ // heading is open territory, never exclusively that last milestone's own.
3931
+ return precedingIndex < markers.length - 1;
3932
+ }
3933
+ /**
3934
+ * #4304: the active milestone's OWN heading
3935
+ * titled "Progress" (`isProgressHeadingTitle` — any suffix, any
3936
+ * level, case-insensitive), found ANYWHERE inside its primary or details
3937
+ * ranges — regardless of what precedes it within those ranges.
3938
+ * `bracketMilestoneOwnTableEnd`/`lineStartsInMilestoneOwnTable` above anchor
3939
+ * ONLY at the milestone/details heading itself, so an intervening heading of
3940
+ * level <= the milestone's own (a `## Notes` aside, or the milestone's OWN
3941
+ * `## Progress` heading when a shipped sibling sharing the bracket code
3942
+ * sorts its own `## Progress` first in the document) closed that "own
3943
+ * table" window before ever reaching the table it was
3944
+ * meant to include. This is ADDITIVE, never a replacement for it: the
3945
+ * existing bare-table-directly-after-phase-headings scope still
3946
+ * independently keeps an unrelated `## Requirements Traceability` table
3947
+ * out of its deletion scope, because that heading is not titled "Progress".
3948
+ *
3949
+ * The exact-title check required "progress", so a suffixed
3950
+ * title ("## Progress (v2.1)", "### Progress (v2.0)") never engaged this
3951
+ * scope even though the document-first `bracketProgressSectionRange` scope
3952
+ * already matched the same suffix — a same-code two-versions shipped/active
3953
+ * pair each titling their own table "Progress (vX.Y)" left the ACTIVE
3954
+ * milestone's own table unrecognized as its own.
3955
+ *
3956
+ * A second gap the same title-widening exposes: a LEVEL-2 own Progress
3957
+ * heading whose title EMBEDS the active milestone's own version token
3958
+ * ("## Progress (v2.1)", level 2, same level as the milestone heading
3959
+ * itself) is exactly what `currentMilestoneRawRanges`' own section-end scan
3960
+ * mistakes for a NEW milestone's heading (it stops at any heading matching
3961
+ * `v\d+\.\d+|✅|📋|🚧`, not just a real milestone one) — ending `ranges`
3962
+ * immediately BEFORE this heading, so it never falls inside
3963
+ * `ranges.primary`/`.details` even once titled "Progress". A heading is
3964
+ * never titled merely "Progress" AND is a different milestone's own
3965
+ * section-opening heading at once, so a Progress-titled heading sitting
3966
+ * EXACTLY at the wide range's own end boundary is still the active
3967
+ * milestone's own — the range ended there only because this heading's own
3968
+ * suffix looked like a marker, not because a real, different milestone
3969
+ * heading intervened.
3970
+ */
3971
+ function bracketOwnProgressSectionRanges(content, ranges, headings) {
3972
+ if (!ranges)
3973
+ return [];
3974
+ const isProgressHeading = (h) => (0, roadmap_progress_cjs_1.isProgressHeadingTitle)(h.text);
3975
+ const withinActive = (offset) => (offset >= ranges.primary.start && offset < ranges.primary.end)
3976
+ || Boolean(ranges.details && offset >= ranges.details.start && offset < ranges.details.end)
3977
+ || offset === ranges.primary.end
3978
+ || Boolean(ranges.details && offset === ranges.details.end);
3979
+ const out = [];
3980
+ for (const h of headings) {
3981
+ if (!isProgressHeading(h) || !withinActive(h.offset))
3982
+ continue;
3983
+ out.push({ start: h.offset, end: bracketMilestoneOwnTableEnd(content, h.offset, headings) });
3984
+ }
3985
+ return out;
3986
+ }
3987
+ // #4304: shared "tolerant" trailing boundary for the
3988
+ // reporting-only detection regexes below. `(?!\d|\.\d)` blocks extending
3989
+ // into more digits or a ".digit" continuation (so a bare identity never
3990
+ // falsely matches inside a longer number or a different decimal sibling),
3991
+ // but — unlike the rewrite functions' own boundary — permits a following
3992
+ // '.', '-', letter, or end of line, so a genuinely stale mention survives
3993
+ // detection even when it sits before sentence-final punctuation or is
3994
+ // embedded in a directory-name suffix the rewriter deliberately declines
3995
+ // to touch (detection must be allowed to find more than the rewriter is
3996
+ // safe to fix).
3997
+ const BRACKET_REPORT_TOLERANT_BOUNDARY_SRC = '(?!\\d|\\.\\d)';
3998
+ function bracketQualifiedMentionedInLine(line, id) {
3999
+ const targetDisplay = renderPhaseId(id);
4000
+ if (tokenizePhaseDependencyReferences(line, 'bracket')
4001
+ .some((token) => token.kind === 'qualified' && token.token === targetDisplay))
4002
+ return true;
4003
+ const dash = dashBracketPhaseId(id);
4004
+ return new RegExp(`(?<![A-Za-z0-9.-])${(0, pattern_cjs_1.escapeRegex)(dash)}${BRACKET_REPORT_TOLERANT_BOUNDARY_SRC}`).test(line);
4005
+ }
4006
+ function bracketLegacyPhaseMentionedInLine(line, id) {
4007
+ const token = bracketArtifactToken(id);
4008
+ return new RegExp(
4009
+ // #4304: a bracket-QUALIFIED, labeled mention
4010
+ // ("[CK.02] Phase 03") is handled completely by
4011
+ // replaceQualifiedBracketReference now — it is never "the legacy bare
4012
+ // 'Phase NN' spelling this detector exists for. Without the negative
4013
+ // lookbehind, the token search matched INSIDE that qualified mention
4014
+ // too (nothing distinguished "Phase 03" preceded by "[CK.02] " from a
4015
+ // genuinely unqualified "Phase 03:" heading), so a correctly-renumbered
4016
+ // labeled line was reported as a dangling reference to the OLD id it no
4017
+ // longer contains.
4018
+ `(?<!\\][ \\t]{0,10})\\bPhase[ \\t]+${(0, pattern_cjs_1.escapeRegex)(token)}${BRACKET_REPORT_TOLERANT_BOUNDARY_SRC}`, 'i').test(line);
4019
+ }
4020
+ function bracketArtifactMentionedInLine(line, id) {
4021
+ const token = bracketArtifactToken(id);
4022
+ const filename = `${(0, pattern_cjs_1.escapeRegex)(token)}-\\d{2}(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\\.md`;
4023
+ return new RegExp(`(?<![A-Za-z0-9_./-])${filename}(?![A-Za-z0-9_.-])`).test(line);
4024
+ }
4025
+ /**
4026
+ * #4304: computed from the ORIGINAL (pre-rewrite) line, never
4027
+ * the persisted content: re-searching the PERSISTED text for a
4028
+ * pre-renumber id produces false positives whenever two or more phases
4029
+ * shift (a later phase's NEW value collides textually with an earlier
4030
+ * phase's OLD value).
4031
+ *
4032
+ * The removed identity is a dangling reference by construction wherever it
4033
+ * is mentioned — nothing ever rewrites a reference to a deleted phase — so
4034
+ * every spelling is checked directly with the tolerant boundary. A
4035
+ * renumbered (old -> new) identity is "left untouched" only if running the
4036
+ * SAME rewrite this line would actually receive still leaves a mention of
4037
+ * the old identity behind afterward: the qualified-reference and
4038
+ * bare-artifact rewriters have their own, stricter boundaries (e.g. they
4039
+ * decline to rewrite immediately before a sentence-final period, or a
4040
+ * bare token embedded in a directory-path segment), so a mention can be
4041
+ * real and still survive the rewrite. The legacy "Phase NN" spelling is
4042
+ * never rewritten by any bracket rewriter, so it is always checked
4043
+ * directly for a renumbered identity too.
4044
+ */
4045
+ function lineContainsTrackedBracketIdentity(originalLine, removedId, mapping) {
4046
+ if (bracketQualifiedMentionedInLine(originalLine, removedId)
4047
+ || bracketLegacyPhaseMentionedInLine(originalLine, removedId)
4048
+ || bracketArtifactMentionedInLine(originalLine, removedId)) {
4049
+ return true;
4050
+ }
4051
+ for (const { oldId, newId } of mapping) {
4052
+ if (bracketLegacyPhaseMentionedInLine(originalLine, oldId))
4053
+ return true;
4054
+ const afterQualified = replaceQualifiedBracketReference(originalLine, oldId, newId);
4055
+ if (bracketQualifiedMentionedInLine(afterQualified, oldId))
4056
+ return true;
4057
+ const afterArtifact = replaceBareBracketArtifactReference(originalLine, oldId, newId);
4058
+ if (bracketArtifactMentionedInLine(afterArtifact, oldId))
4059
+ return true;
4060
+ }
4061
+ return false;
4062
+ }
4063
+ /**
4064
+ * #4304: maximum deletion boundary for a selected bracket phase heading.
4065
+ * The active milestone range is the outer bound. Inside it, an HTML details
4066
+ * boundary wins sooner. The shared fence-aware depth tracker preserves the
4067
+ * closing tag of the exact container that encloses the target, or the opening
4068
+ * tag of a container that starts after an outside target. Nested details do
4069
+ * not count until their own close has restored the target's original depth.
4070
+ * The deletion extent also stops at the next distinct phase heading recognized
4071
+ * by the reader, whatever its heading depth; ordinary deeper subheadings remain
4072
+ * part of the target section.
4073
+ */
4074
+ function bracketPhaseDeletionContainerBoundary(content, targetOffset) {
4075
+ const tags = trackRoadmapDetails(content).tags;
4076
+ let targetDepth = 0;
4077
+ for (const tag of tags) {
4078
+ if (tag.offset >= targetOffset)
4079
+ break;
4080
+ targetDepth = tag.depthAfter;
4081
+ }
4082
+ for (const tag of tags) {
4083
+ if (tag.offset < targetOffset)
4084
+ continue;
4085
+ if (targetDepth === 0 && !tag.closing)
4086
+ return tag.lineStart;
4087
+ if (targetDepth > 0 && tag.closing && tag.depthBefore === targetDepth)
4088
+ return tag.lineStart;
4089
+ }
4090
+ return null;
4091
+ }
4092
+ /**
4093
+ * #4304: the pure ROADMAP rewrite for a bracket removal, computed from the
4094
+ * content alone so `phase remove` can run it before any mutation and refuse
4095
+ * on what it would leave behind. `survivingTargetRows` lists every live
4096
+ * (unfenced, not historical) pipe-table row keyed by the removed identity
4097
+ * that the rewrite keeps: a shared Progress table the scope rules cannot
4098
+ * attribute, or a non-Progress table such as a phase-keyed traceability
4099
+ * table. Keeping such a row leaves a dangling identity that the renumbered
4100
+ * sibling then duplicates; deleting it could drop data the row carries.
4101
+ */
4102
+ function planBracketRoadmapRemoval(originalContent, targetId, mapping, cwd) {
4103
+ // #4304: scope the section deletion to the active
4104
+ // milestone's own ranges (the SAME primary+details discovery the
4105
+ // checklist-row deletion below already uses), computed from the
4106
+ // content BEFORE deletion. Without this, deleteSection removes the
4107
+ // FIRST matching heading in the whole document: a shipped milestone
4108
+ // and the active one sharing the same bracket code (milestoneToken
4109
+ // folds e.g. v2.0 and v2.1 to one [CK.02]) let the shipped section's
4110
+ // own detail heading be deleted while the active one survives.
4111
+ const preDeleteRanges = currentMilestoneRawRanges(originalContent, cwd, 'bracket');
4112
+ // #4304: historical protection applies while SELECTING the section, not
4113
+ // only during the later per-line rewrite. A closed details archive can
4114
+ // sit inside the raw active-milestone range and carry the same folded
4115
+ // bracket id as the live phase; choosing that heading first deletes
4116
+ // history and leaves the live target behind.
4117
+ const preDeleteHistoricalLineStarts = archivedOrClosedMilestoneLineStarts(originalContent);
4118
+ const isTargetHeading = (heading) => {
4119
+ // #4304: classify the heading through the SAME shared
4120
+ // owned-line grammar (classifyBracketOwnedLine / BRACKET_HEADING_LINE_RE)
4121
+ // the checklist/progress-row deletion below already uses, instead of
4122
+ // a literal `startsWith(targetDisplay)`; that comparison only ever
4123
+ // recognized the display spelling ("[CK.02] 02"), so the read-grammar-
4124
+ // admitted labeled spelling ("[CK.02] Phase 02:", pinned at
4125
+ // tests/adr-612-bracket-grammar.test.cjs:644) was never matched here
4126
+ // and its detail section survived a "removal" that deleted every
4127
+ // other owned line for the same identity.
4128
+ if (heading.level < 2 || heading.level > 4)
4129
+ return false;
4130
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
4131
+ const owned = classifyBracketOwnedLine(headingLine);
4132
+ if (owned.kind !== 'heading' || !owned.id || !sameBracketPhaseId(owned.id, targetId)) {
4133
+ return false;
4134
+ }
4135
+ if (preDeleteHistoricalLineStarts.has(heading.offset))
4136
+ return false;
4137
+ if (!preDeleteRanges)
4138
+ return true;
4139
+ return ((heading.offset >= preDeleteRanges.primary.start && heading.offset < preDeleteRanges.primary.end)
4140
+ || Boolean(preDeleteRanges.details
4141
+ && heading.offset >= preDeleteRanges.details.start
4142
+ && heading.offset < preDeleteRanges.details.end));
4143
+ };
4144
+ const selectedTargetHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(originalContent).find(isTargetHeading);
4145
+ let deletionEndOffset;
4146
+ if (selectedTargetHeading) {
4147
+ const nextDistinctPhaseHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(originalContent).find((heading) => heading.offset > selectedTargetHeading.offset
4148
+ && isDistinctReaderPhaseHeading(heading, targetId));
4149
+ const containingRange = preDeleteRanges
4150
+ ? [preDeleteRanges.primary, ...(preDeleteRanges.details ? [preDeleteRanges.details] : [])]
4151
+ .find((range) => selectedTargetHeading.offset >= range.start && selectedTargetHeading.offset < range.end)
4152
+ : null;
4153
+ const containerBoundary = bracketPhaseDeletionContainerBoundary(originalContent, selectedTargetHeading.offset);
4154
+ const historicalBoundary = [...preDeleteHistoricalLineStarts]
4155
+ .filter((offset) => offset > selectedTargetHeading.offset)
4156
+ .sort((a, b) => a - b)[0];
4157
+ deletionEndOffset = Math.min(containingRange?.end ?? originalContent.length, containerBoundary ?? originalContent.length, historicalBoundary ?? originalContent.length, nextDistinctPhaseHeading?.offset ?? originalContent.length);
4158
+ }
4159
+ let content = (0, markdown_sectionizer_cjs_1.deleteSection)(originalContent, isTargetHeading, { endOffset: deletionEndOffset });
4160
+ let roadmapLinesRewritten = content === originalContent ? 0 : 1;
4161
+ const ranges = currentMilestoneRawRanges(content, cwd, 'bracket');
4162
+ // #4304: progress/table-row deletion is
4163
+ // scoped to the active milestone's OWN table content
4164
+ // (bracketMilestoneOwnTableEnd, narrower than `ranges` itself, see its
4165
+ // own doc comment) plus its own "Progress"-titled heading found ANYWHERE
4166
+ // in its ranges (bracketOwnProgressSectionRanges, additive:
4167
+ // covers a `## Notes` aside or a per-milestone `## Progress` the plain
4168
+ // own-table-end closes over too early) plus a document-level
4169
+ // `## Progress` section that is not itself owned by a DIFFERENT
4170
+ // milestone (legacy's own #2012 scope, plus an ownership gate),
4171
+ // never the whole document, so a same-identity row in another pipe table
4172
+ // (a shipped milestone sharing the same bracket code, an unrelated
4173
+ // Requirements Traceability table) is never deleted.
4174
+ const headingsForOwnTable = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
4175
+ const progressSectionRange = bracketProgressSectionRange(content);
4176
+ const ownProgressSectionRanges = bracketOwnProgressSectionRanges(content, ranges, headingsForOwnTable);
4177
+ const progressSectionOwnedElsewhere = progressSectionRange
4178
+ ? bracketProgressSectionOwnedByOtherMilestone(content, progressSectionRange.start, ranges, ownProgressSectionRanges)
4179
+ : false;
4180
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
4181
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
4182
+ // A row inside a Progress-titled section that the ownership rule assigns
4183
+ // to a different milestone is that milestone's own record: kept, and not
4184
+ // a surviving row of the removed identity.
4185
+ const progressSections = headingsForOwnTable
4186
+ .filter((heading) => (0, roadmap_progress_cjs_1.isProgressHeadingTitle)(heading.text))
4187
+ .map((heading) => ({
4188
+ start: heading.offset,
4189
+ end: bracketMilestoneOwnTableEnd(content, heading.offset, headingsForOwnTable),
4190
+ ownedElsewhere: bracketProgressSectionOwnedByOtherMilestone(content, heading.offset, ranges, ownProgressSectionRanges),
4191
+ }));
4192
+ const inProgressSectionOwnedByOtherMilestone = (lineStart) => {
4193
+ const enclosing = progressSections.filter((section) => lineStart >= section.start && lineStart < section.end);
4194
+ return enclosing.length > 0 && enclosing[enclosing.length - 1].ownedElsewhere;
4195
+ };
4196
+ // A row is a Progress-table row only when the nearest heading above it is
4197
+ // a Progress-titled heading, a phase heading (the bare table that follows
4198
+ // the phase list), or the milestone or details heading that opens the
4199
+ // window. Any other heading, at any level, opens a different table (a
4200
+ // phase-keyed Requirements Traceability table), whose rows are never
4201
+ // deleted: a row keyed by the removed identity there refuses the removal.
4202
+ const windowAnchors = new Set(ranges ? [ranges.primary.start, ...(ranges.details ? [ranges.details.start] : [])] : []);
4203
+ const nearestHeadingKeepsProgressTable = (lineStart) => {
4204
+ let nearest;
4205
+ for (const heading of headingsForOwnTable) {
4206
+ if (heading.offset >= lineStart)
4207
+ break;
4208
+ nearest = heading;
2037
4209
  }
2038
- if (!isDecimal) {
2039
- // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
2040
- // is re-emitted verbatim — a tagged later phase still gets renumbered.
2041
- // #5007 (Phase 6 / ADR-4910 §8): composes phaseHeadingPrefixSrcFor
2042
- // directly (needs prefix/number/suffix as three separate capture groups
2043
- // for a verbatim in-place rewrite — neither shared owner returns that
2044
- // shape). Two deliberate departures from the design doc's suggested
2045
- // composition, both kept for zero behavior change on this
2046
- // highest-blast-radius site (feeds phase remove's renumber path):
2047
- // 1. The `#{2,4}\s*` heading marker is kept explicit and folded INTO
2048
- // the prefix capture group — the design's snippet omitted it
2049
- // entirely (phaseHeadingPrefixSrcFor never includes it; every
2050
- // caller adds it, e.g. buildPhaseHeadingScanRegex), and the
2051
- // original group 1 here captured the marker too.
2052
- // 2. No convention argument is threaded (LABEL_ONLY with
2053
- // convention=undefined), even though phaseHeadingPrefixSrcFor
2054
- // COULD accept one — this keeps the composed source byte-identical
2055
- // to the prior `Phase\s+` literal, a deliberate non-widening for
2056
- // this specific site (unlike cmdPhaseInsert's targetPattern).
2057
- // The number group widens from `\d+(?:\.\d+)?` to the full
2058
- // PHASE_NUMBER_TOKEN_SOURCE (letter suffix + multi-segment decimals) —
2059
- // this is SAFE, not a behavior change: decrementRoadmapPhaseToken's own
2060
- // regex (`^(\d+)(\.\d+)?$`) rejects anything wider and returns the
2061
- // token unchanged, so a heading this widening newly matches (e.g.
2062
- // "Phase 12A:") is written back byte-for-byte unchanged, identical to
2063
- // the pre-migration outcome of the whole regex simply not matching it.
2064
- content = content.replace(new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)})(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE}\\s*:)`, 'gi'), (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
2065
- content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
2066
- // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
2067
- // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
2068
- // directly above). The prior whole-document regex
2069
- // `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
2070
- // file — including a same-shaped cell in an UNRELATED, earlier table
2071
- // (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
2072
- // preceding `## Progress`; #2245-class scoping defect, same family as
2073
- // the row-delete fix above). Scoped here to the `## Progress` section
2074
- // only, mirroring that same section-slice-then-splice-back pattern.
2075
- //
2076
- // Loops because updateTableCell only rewrites the FIRST matching row
2077
- // per call. `processedOrdinalRows` tracks by row INDEX (stable across
2078
- // iterations — this only edits cell content, it never inserts/deletes
2079
- // rows) so an already-decremented row's new value — which may still
2080
- // numerically exceed `removedInt` — is never re-selected and
2081
- // decremented a second time (matching on the row's CURRENT value alone,
2082
- // without this guard, would keep re-firing on each pass).
2083
- //
2084
- // `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
2085
- // required: a decimal sub-phase ordinal like `2.5` (no whitespace
2086
- // between the dot and the next character) never matches it, so it is
2087
- // left untouched — identical decimal-safety to the prior behaviour.
2088
- //
2089
- // updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
2090
- // only, so the row's original leading/trailing alignment padding is
2091
- // recovered by a narrow, anchored lookup within that row's OWN raw
2092
- // line — addressed by ROW INDEX (`matchedRowIndex`, via
2093
- // `findDataRowLine`), not by searching the whole section for content
2094
- // matching the trimmed value (F8 #2245 review: two rows with identical
2095
- // trimmed Phase text, or a row whose already-rewritten new value
2096
- // coincides with another row's pre-edit text, would otherwise resolve
2097
- // to the WRONG row's padding — the first/leftmost content match found).
2098
- // The lookup searches for `escapeCell(current)` (F3 #2245 review: the
2099
- // ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
2100
- // escaped form, so searching for the unescaped `current` would
2101
- // silently fail to find an escaped-pipe cell's own line — preserving
2102
- // every other byte of the row (ADR-2143 §7 byte-parity) while only the
2103
- // digits actually change.
2104
- const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
2105
- if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
2106
- const ordinalHeadingOffset = ordinalHeadingMatch.index;
2107
- const ordinalBefore = content.slice(0, ordinalHeadingOffset);
2108
- const ordinalFromHeading = content.slice(ordinalHeadingOffset);
2109
- const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
2110
- let ordinalSection = ordinalNextHeadingOffset >= 0
2111
- ? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
2112
- : ordinalFromHeading;
2113
- const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
2114
- const phaseCellShapeRe = /^(\d+)(\.\s)/;
2115
- const processedOrdinalRows = new Set();
2116
- let matchedRowIndex = null;
2117
- for (;;) {
2118
- matchedRowIndex = null;
2119
- const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
2120
- if (processedOrdinalRows.has(index))
2121
- return false;
2122
- const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
2123
- if (!m)
2124
- return false;
2125
- const num = parseInt(m[1], 10);
2126
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2127
- if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
2128
- return false;
2129
- processedOrdinalRows.add(index);
2130
- matchedRowIndex = index;
2131
- return true;
2132
- }, 'Phase', (current) => {
2133
- const m = phaseCellShapeRe.exec(current);
2134
- if (!m)
2135
- return current;
2136
- const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
2137
- const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
2138
- const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
2139
- const padMatch = targetLine
2140
- ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
2141
- : null;
2142
- const leadPad = padMatch ? padMatch[1] : ' ';
2143
- const trailPad = padMatch ? padMatch[2] : ' ';
2144
- return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
2145
- });
2146
- if (!cellResult.ok)
2147
- break;
2148
- ordinalSection = cellResult.value;
2149
- }
2150
- content = ordinalBefore + ordinalSection + ordinalRest;
4210
+ if (!nearest || windowAnchors.has(nearest.offset))
4211
+ return true;
4212
+ if ((0, roadmap_progress_cjs_1.isProgressHeadingTitle)(nearest.text))
4213
+ return true;
4214
+ return classifyBracketOwnedLine('#'.repeat(nearest.level) + ' ' + nearest.text).kind === 'heading';
4215
+ };
4216
+ // #4304: the referencesLeftUntouched report is computed
4217
+ // from each KEPT line's ORIGINAL (pre-rewrite) text, never the
4218
+ // persisted (already-rewritten) content: re-searching persisted text
4219
+ // for a pre-renumber id produces false positives whenever two or more
4220
+ // phases shift (a later phase's NEW value collides textually with an
4221
+ // earlier phase's OLD value). A line
4222
+ // that gets DELETED here (the target's own owned heading/checklist/
4223
+ // progress row) can never be "left untouched": it does not exist in
4224
+ // the output at all, so only kept lines are considered.
4225
+ const keptOriginalLines = [];
4226
+ const survivingTargetRows = [];
4227
+ const rewritten = [];
4228
+ for (const line of splitRoadmapLineRecords(content)) {
4229
+ const active = lineStartsInActiveMilestone(line.start, ranges);
4230
+ const historical = historicalLineStarts.has(line.start);
4231
+ const fenced = fencedLineNumbers.has(line.lineNumber);
4232
+ if (fenced) {
4233
+ rewritten.push(line.text + line.eol);
4234
+ keptOriginalLines.push({ text: line.text, active: false });
4235
+ continue;
4236
+ }
4237
+ const owned = classifyBracketOwnedLine(line.text);
4238
+ const underProgressTableHeading = owned.kind === 'progress' && nearestHeadingKeepsProgressTable(line.start);
4239
+ const inMilestoneOwnTable = underProgressTableHeading
4240
+ && (lineStartsInMilestoneOwnTable(content, line.start, ranges, headingsForOwnTable)
4241
+ || ownProgressSectionRanges.some((r) => line.start >= r.start && line.start < r.end));
4242
+ const inProgressSection = underProgressTableHeading && Boolean(progressSectionRange
4243
+ && !progressSectionOwnedElsewhere
4244
+ && line.start >= progressSectionRange.start
4245
+ && line.start < progressSectionRange.end);
4246
+ if (!historical
4247
+ && owned.id
4248
+ && sameBracketPhaseId(owned.id, targetId)
4249
+ && ((active && owned.kind === 'checklist') || inMilestoneOwnTable || inProgressSection)) {
4250
+ roadmapLinesRewritten += 1;
4251
+ continue;
4252
+ }
4253
+ if (!historical
4254
+ && owned.kind === 'progress'
4255
+ && owned.id
4256
+ && sameBracketPhaseId(owned.id, targetId)
4257
+ && !inProgressSectionOwnedByOtherMilestone(line.start)) {
4258
+ survivingTargetRows.push(line.text);
4259
+ }
4260
+ let next = line.text;
4261
+ if (!historical) {
4262
+ for (const { oldId, newId } of mapping) {
4263
+ next = replaceQualifiedBracketReference(next, oldId, newId);
4264
+ if (active)
4265
+ next = replaceBareBracketArtifactReference(next, oldId, newId);
2151
4266
  }
2152
- content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
2153
- // #5007 (Phase 6 / ADR-4910): migrated the "Depends on" bold-field
2154
- // decrement off two hand-rolled regexes — one per bold-placement
2155
- // variant, `**Depends on**: Phase N` and `**Depends on:** Phase N` —
2156
- // onto the PlanningDoc `boldField` read/write seam (`writePlansField`
2157
- // above is the established precedent for this migration shape).
2158
- // `BOLD_FIELD_RE` (planning-document.cts) recognizes BOTH placements
2159
- // as the SAME field label ("Depends on") — `parseBoldFieldLine` strips
2160
- // the token down to its inner text regardless of which side of `**`
2161
- // the colon lands on — so this single loop naturally replaces both
2162
- // regexes with one call site; no variant-specific branching remains.
2163
- // Unlike `writePlansField`'s single `findField` lookup (scoped to one
2164
- // already-isolated phase section), a whole ROADMAP.md can carry a
2165
- // "Depends on" field in EVERY phase's detail section, so this walks
2166
- // every `boldField` node in the whole parsed document whose label is
2167
- // "Depends on" and rewrites each one independently — a
2168
- // representability refusal or absent match on one field leaves that
2169
- // field untouched and never blocks any other field, mirroring the
2170
- // prior regexes' per-occurrence independence. A parse failure or an
2171
- // unreadable-nodes refusal on `serialize` leaves `content` byte-for-
2172
- // byte untouched, the same silent no-op the prior regexes had for any
2173
- // input they didn't match (neither ever threw or warned).
2174
- const dependsOnParsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, 'ROADMAP.md');
2175
- if (dependsOnParsed.ok) {
2176
- let dependsOnDoc = dependsOnParsed.value;
2177
- const dependsOnFieldIds = dependsOnDoc.nodes
2178
- .filter((n) => n.kind === 'boldField' && n.label === 'Depends on')
2179
- .map((n) => n.id);
2180
- for (const fieldId of dependsOnFieldIds) {
2181
- const currentRead = (0, planning_document_cjs_1.readNode)(dependsOnDoc, fieldId);
2182
- if (!currentRead.ok)
2183
- continue;
2184
- const depMatch = /^(Phase\s+)(\d+(?:\.\d+)?)\b/i.exec(currentRead.value);
2185
- if (!depMatch)
2186
- continue;
2187
- const [wholeMatch, prefix, num] = depMatch;
2188
- const newValue = `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}` + currentRead.value.slice(wholeMatch.length);
2189
- if (newValue === currentRead.value)
2190
- continue;
2191
- const staged = (0, planning_document_cjs_1.setFieldValue)(dependsOnDoc, fieldId, newValue);
2192
- if (staged.ok)
2193
- dependsOnDoc = staged.value;
2194
- }
2195
- const dependsOnOut = (0, planning_document_cjs_1.serialize)(dependsOnDoc);
2196
- if (dependsOnOut.ok)
2197
- content = dependsOnOut.value;
4267
+ }
4268
+ if (next !== line.text)
4269
+ roadmapLinesRewritten += 1;
4270
+ rewritten.push(next + line.eol);
4271
+ // A historical line can be physically inside the raw active milestone
4272
+ // range (closed details nested below the live milestone heading). It is
4273
+ // intentionally exempt from this mutation, so it is not an active
4274
+ // dangling reference for the removal report either.
4275
+ keptOriginalLines.push({ text: line.text, active: active && !historical });
4276
+ }
4277
+ content = rewritten.join('');
4278
+ return { content, roadmapLinesRewritten, keptOriginalLines, survivingTargetRows };
4279
+ }
4280
+ /**
4281
+ * Remove the active bracket phase and renumber its later identities. Qualified
4282
+ * references are rewritten roadmap-wide, including global sections and other
4283
+ * project-code milestone sections, except for lines inside archived details or
4284
+ * closed milestone sections. Those historical lines are never rewritten or
4285
+ * deleted. Bare artifact references remain limited to the active milestone.
4286
+ */
4287
+ function updateRoadmapAfterBracketPhaseRemoval(roadmapPath, removedInt, removedSubphase, context, mapping, cwd) {
4288
+ return withPlanningLock(cwd, () => {
4289
+ const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
4290
+ const targetId = bracketPhaseId(context, removedInt, removedSubphase);
4291
+ const { content, roadmapLinesRewritten, keptOriginalLines } = planBracketRoadmapRemoval(originalContent, targetId, mapping, cwd);
4292
+ const bracketNormalization = { preserveFencedMarkdownStructure: true };
4293
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content, bracketNormalization);
4294
+ // platformWriteSync's own markdown normalization (_normalizeMd) inserts
4295
+ // blank lines around headings/fences/lists and collapses runs of 3+
4296
+ // blank lines, so a KEPT line's position here can shift from its
4297
+ // position in `keptOriginalLines`. Normalization only ever adds or
4298
+ // collapses BLANK lines — it never reorders or edits a non-blank
4299
+ // line's text — so the Nth non-blank kept (original) line always
4300
+ // corresponds to the Nth non-blank persisted line; blank kept lines
4301
+ // are skipped entirely since they can never contain a tracked identity.
4302
+ const persistedContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
4303
+ const persistedNonBlankLineNumbers = splitRoadmapLineRecords(persistedContent)
4304
+ .filter((line) => line.text.trim() !== '')
4305
+ .map((line) => line.lineNumber);
4306
+ const referencesLeftUntouched = [];
4307
+ let nonBlankIndex = 0;
4308
+ for (const { text, active } of keptOriginalLines) {
4309
+ if (text.trim() === '')
4310
+ continue;
4311
+ const persistedLineNumber = persistedNonBlankLineNumbers[nonBlankIndex];
4312
+ nonBlankIndex += 1;
4313
+ if (!active || persistedLineNumber === undefined)
4314
+ continue;
4315
+ if (lineContainsTrackedBracketIdentity(text, targetId, mapping)) {
4316
+ referencesLeftUntouched.push(persistedLineNumber);
2198
4317
  }
2199
4318
  }
2200
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
2201
- // #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
2202
- // persists), not the raw pre-normalize `content` string, against the raw
2203
- // pre-mutation `originalContent` read above — a raw `!==` here reports a
2204
- // false `true` whenever this transform's regenerated output takes a
2205
- // different-but-equivalent shape than the already-normalized on-disk
2206
- // original (same normalization-order artifact #3685 fixed at
2207
- // cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
2208
- return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
4319
+ return {
4320
+ updated: (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content, bracketNormalization),
4321
+ roadmapLinesRewritten,
4322
+ referencesLeftUntouched,
4323
+ };
2209
4324
  });
2210
4325
  }
2211
4326
  /**
2212
- * #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
2213
- * the leading frontmatter block's closing `---` fence — so a body field never
2214
- * lands before the opening fence. The former whole-content prepend
2215
- * (`field + content`) put the line ABOVE the opening `---`, and
2216
- * syncStateFrontmatter then treated the scrambled fence structure as TWO
2217
- * frontmatter blocks, rebuilding a derived one on top of the original
2218
- * (milestone_name from a ROADMAP heading, total_phases counting the removed
2219
- * phase, a stray 'Total Phases: 0' between fences). A file with no leading
2220
- * frontmatter is all body: the field goes to content start, preserving the
2221
- * former behavior for that shape.
2222
- *
2223
- * The block is the one `locateFrontmatterFence` finds (the one fence owner), so this writer
2224
- * and every STATE.md reader agree on where the body starts. The blank line and the field go
2225
- * right after the closing fence line, ended by that line's own line ending — so a CRLF file
2226
- * gains CRLF lines only (#3572 review: a blanket re-join on '\r\n' doubled every carriage
2227
- * return; joining the new lines on a bare '\n' mixed line endings).
4327
+ * #4304: phase ids the reader lists but the bracket grammar cannot represent
4328
+ * (a letter suffix such as `02a`, or a three-level `02.01.01`) are invisible
4329
+ * to the renumber mapping and the owned-line rewrite. Removing a phase at or
4330
+ * below them would leave them on their old numbers beside renumbered
4331
+ * siblings, so they are reported here, from the live (unfenced, not
4332
+ * historical) lines of the active milestone, for the caller to refuse.
4333
+ * An integer removal is affected by every such id whose leading number is
4334
+ * at or above the removed one; a sub-phase removal by those sharing its
4335
+ * leading number.
2228
4336
  */
2229
- function insertStateBodyFieldAtTop(content, fieldLine) {
2230
- const fence = (0, frontmatter_fence_cjs_1.locateFrontmatterFence)(content);
2231
- if (fence?.closed) {
2232
- const nl = content[fence.closingFenceEnd] === '\r' ? '\r\n' : '\n';
2233
- return `${content.slice(0, fence.closingFenceEnd)}${nl}${nl}${fieldLine}${content.slice(fence.closingFenceEnd)}`;
4337
+ function unrepresentableBracketIdsAffectedByRemoval(content, ranges, removedInt, removedSubphase) {
4338
+ if (!ranges)
4339
+ return [];
4340
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
4341
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
4342
+ const liveActive = splitRoadmapLineRecords(content)
4343
+ .map((line) => (lineStartsInActiveMilestone(line.start, ranges)
4344
+ && !historicalLineStarts.has(line.start)
4345
+ && !fencedLineNumbers.has(line.lineNumber)
4346
+ ? line.text
4347
+ : ''))
4348
+ .join('\n');
4349
+ const affected = [];
4350
+ for (const id of scanMilestonePhaseIds(liveActive, 'bracket')) {
4351
+ // The owned-line grammar canonicalizes each numeric segment through
4352
+ // `phaseToken` (`002` reads as `02`), so representable means one or two
4353
+ // numeric segments that each canonicalize.
4354
+ const segments = String(id).split('.');
4355
+ if (segments.length <= 2 && segments.every((segment) => /^\d+$/.test(segment) && phaseToken(segment) !== null)) {
4356
+ continue;
4357
+ }
4358
+ const leading = /^(\d+)/.exec(String(id));
4359
+ if (!leading)
4360
+ continue;
4361
+ const phase = Number(leading[1]);
4362
+ if (removedSubphase === undefined ? phase >= removedInt : phase === removedInt)
4363
+ affected.push(String(id));
2234
4364
  }
2235
- // All body: the field is the new first line, ended like the document's first line.
2236
- const firstNewline = content.indexOf('\n');
2237
- return fieldLine + (firstNewline > 0 && content[firstNewline - 1] === '\r' ? '\r\n' : '\n') + content;
4365
+ return affected;
4366
+ }
4367
+ /**
4368
+ * #4304: `phase remove` adjusts only STATE.md's phase counts. When STATE.md's
4369
+ * current phase (the canonical frontmatter, `Current Phase`, scoped prose
4370
+ * ladder) is the removed identity or one the removal renumbers, every
4371
+ * current-phase field would keep naming the old identity, which afterwards
4372
+ * belongs to a different phase. Returns that current phase for the caller to
4373
+ * refuse on, or null.
4374
+ */
4375
+ function stateCurrentPhaseAffectedByBracketRemoval(cwd, targetId, mapping) {
4376
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
4377
+ if (!node_fs_1.default.existsSync(statePath))
4378
+ return null;
4379
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
4380
+ const fm = frontmatterMod.extractFrontmatter(rawState, statePath);
4381
+ const current = stateMod.resolveCurrentPhaseId(fm, frontmatterMod.stripFrontmatter(rawState));
4382
+ if (current === null || current === undefined)
4383
+ return null;
4384
+ let currentToken = String(current).trim();
4385
+ if (currentToken.includes('-')) {
4386
+ try {
4387
+ currentToken = renderPhaseId(parsePhaseId(currentToken));
4388
+ }
4389
+ catch {
4390
+ return null;
4391
+ }
4392
+ }
4393
+ const match = new RegExp(`^(?:\\[(${phaseIdMod.BRACKET_PROJECT_CODE_SRC})\\.(\\d+)\\][ \\t]*)?(\\d+)(?:\\.(\\d+))?$`, 'i').exec(currentToken);
4394
+ if (!match)
4395
+ return null;
4396
+ // A current phase qualified with another project or milestone is not the
4397
+ // active milestone's phase, whatever its number.
4398
+ if (match[1] !== undefined
4399
+ && (match[1].toUpperCase() !== targetId.project || Number(match[2]) !== Number(targetId.milestone))) {
4400
+ return null;
4401
+ }
4402
+ const phase = Number(match[3]);
4403
+ const subphase = match[4] === undefined ? undefined : Number(match[4]);
4404
+ const sameNumber = (id) => Number(id.phase) === phase
4405
+ && (id.subphase === undefined ? subphase === undefined : Number(id.subphase) === subphase);
4406
+ return sameNumber(targetId) || mapping.some(({ oldId }) => sameNumber(oldId)) ? String(current).trim() : null;
2238
4407
  }
2239
4408
  function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2240
4409
  if (!targetPhase)
@@ -2243,22 +4412,78 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2243
4412
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
2244
4413
  if (!node_fs_1.default.existsSync(roadmapPath))
2245
4414
  error('ROADMAP.md not found');
2246
- const normalized = normalizePhaseName(targetPhase);
2247
- const isDecimal = targetPhase.includes('.');
2248
4415
  const force = options.force || false;
4416
+ const removeConvention = resolvePhaseIdConvention(cwd);
4417
+ const removeContext = removeConvention === 'bracket'
4418
+ ? bracketWriteContext(cwd, loadConfig(cwd))
4419
+ : null;
4420
+ // #4304: canonicalize every bracket argument before
4421
+ // directory matching or any write, through the SAME adapter `phase
4422
+ // insert` now uses (canonicalizeBracketPhaseArgument) — a bare token
4423
+ // through phase-id-display's `phaseToken`, a qualified/display token
4424
+ // through phase-id.cts's strict `parsePhaseId`. A rejected spelling has no
4425
+ // parseInt fallback, so it cannot partially name a real phase and reach
4426
+ // the destructive path.
4427
+ let normalized;
4428
+ let isDecimal;
4429
+ let requestedSlug;
4430
+ let removedInt;
4431
+ let removedSubphase;
4432
+ if (removeContext) {
4433
+ ({ normalized, isDecimal, slug: requestedSlug } = canonicalizeBracketPhaseArgument(removeContext, targetPhase, 'remove'));
4434
+ const [phasePart, subphasePart] = normalized.split('.');
4435
+ removedInt = Number(phasePart);
4436
+ removedSubphase = subphasePart === undefined ? undefined : Number(subphasePart);
4437
+ if (removedSubphase === 0) {
4438
+ error('Cannot remove a .00 sub-phase: bracket sub-phases start at .01.', ERROR_REASON.USAGE);
4439
+ }
4440
+ }
4441
+ else {
4442
+ // Legacy and milestone-prefixed paths retain their original permissive
4443
+ // normalization and parseInt behavior byte-for-byte.
4444
+ normalized = normalizePhaseName(targetPhase);
4445
+ isDecimal = targetPhase.includes('.');
4446
+ removedInt = parseInt(normalized, 10);
4447
+ removedSubphase = isDecimal ? parseInt(normalized.split('.')[1], 10) : undefined;
4448
+ }
2249
4449
  const subdirs = readSubdirectories(phasesDir, true);
2250
4450
  // #2237/#2528: every other resolution path refuses to choose between multiple
2251
4451
  // directories claiming one phase number. This one is the DESTRUCTIVE path, so
2252
4452
  // taking `matches[0]` silently is strictly worse than anywhere else: it turns
2253
4453
  // "resolve nothing" into "delete one of two candidates, unrecoverably, and
2254
4454
  // renumber every phase after it". Refuse before any file is touched.
2255
- const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
4455
+ const candidateDirs = removeContext
4456
+ ? bracketIdsInContext(phasesDir, removeContext).map(({ dir }) => dir)
4457
+ : subdirs;
4458
+ const directoryContext = phaseDirectoryOptions(cwd);
4459
+ let { matches: phaseDirMatches } = matchPhaseDirs(candidateDirs, removeConvention === 'bracket' ? normalized : phaseLookupQuery(targetPhase, directoryContext), removeConvention === 'bracket' ? 'bracket' : undefined, undefined, directoryContext);
4460
+ if (removeContext && requestedSlug !== undefined) {
4461
+ phaseDirMatches = phaseDirMatches.filter((dir) => bracketDirSlug(dir, parsePhaseId(dir)) === requestedSlug);
4462
+ if (phaseDirMatches.length !== 1) {
4463
+ error(`Cannot remove ${targetPhase}: its slug does not exactly match a directory for phase ${normalized}.`, ERROR_REASON.USAGE);
4464
+ }
4465
+ }
4466
+ const uniqueCustomTarget = phaseDirMatches.length === 1
4467
+ && phaseIdMod.customDirectoryCandidates(phaseDirMatches[0], directoryContext).length === 1;
4468
+ const mutationCandidates = subdirs.filter(dir => {
4469
+ const owners = phaseIdMod.customDirectoryCandidates(dir, directoryContext);
4470
+ // A unique proven target excludes siblings whose longer declared owner
4471
+ // wins resolution. Unresolved/ambiguous queries retain the full refusal.
4472
+ return dir === targetPhase || (owners.includes(targetPhase) && (!uniqueCustomTarget || dir === phaseDirMatches[0]));
4473
+ });
4474
+ // The numeric renumbering set comes from the renamers' own selector.
4475
+ const renumbered = removeContext || isCustomPhaseId(targetPhase) ? [] : (isDecimal
4476
+ ? selectDecimalRenames(subdirs, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10), directoryContext)
4477
+ : selectIntegerRenames(subdirs, parseInt(normalized, 10), directoryContext)).map(item => item.dir);
4478
+ assertUnambiguousCustomDirectories([...new Set([...mutationCandidates, ...renumbered])], directoryContext);
2256
4479
  if (phaseDirMatches.length > 1) {
2257
4480
  output({
2258
4481
  removed: null,
2259
4482
  error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
2260
4483
  + `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
2261
- + 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
4484
+ + (removeContext
4485
+ ? 'Remove the stale duplicate directory or give it a distinct bracket phase identity, then retry.'
4486
+ : 'Set a distinct project_code in .planning/config.json, or pass the full directory name.'),
2262
4487
  ambiguous_matches: phaseDirMatches,
2263
4488
  directory_deleted: null,
2264
4489
  renamed_directories: [],
@@ -2269,6 +4494,30 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2269
4494
  return;
2270
4495
  }
2271
4496
  const targetDir = phaseDirMatches[0] || null;
4497
+ const customTarget = targetDir !== null && (directoryContext.directoryIds?.[targetDir] !== undefined
4498
+ || phaseIdMod.customDirectoryCandidates(targetDir, directoryContext).length > 0);
4499
+ const stableIds = new Set(subdirs.flatMap(dir => phaseIdMod.customDirectoryCandidates(dir, directoryContext))
4500
+ .map(id => (isCustomPhaseId(id) ? id : normalizePhaseName(id)).toLowerCase()));
4501
+ const roadmapContentBeforeRemoval = removeContext ? node_fs_1.default.readFileSync(roadmapPath, 'utf-8') : null;
4502
+ if (removeContext) {
4503
+ const targetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
4504
+ const legacyOnly = legacyOnlyBracketRemovalTarget(subdirs, roadmapContentBeforeRemoval, targetPhase, targetId, targetDir !== null);
4505
+ if (legacyOnly) {
4506
+ const evidence = [
4507
+ ...legacyOnly.directories.map((dir) => `directory ${JSON.stringify(dir)}`),
4508
+ ...legacyOnly.headings.map((heading) => `heading ${JSON.stringify(heading)}`),
4509
+ ].join('; ');
4510
+ // roadmap upgrade converts nothing once config selects bracket (it
4511
+ // refuses a partial migration and treats an all-bracket ROADMAP as
4512
+ // done), so name the conversion itself.
4513
+ const directoryPrefix = legacyOnly.directories.length > 0
4514
+ ? ` (directory prefix ${JSON.stringify(bracketDirPrefix(targetId))})`
4515
+ : '';
4516
+ error(`Cannot remove phase ${normalized} under the bracket convention: it resolves only to `
4517
+ + `legacy-spelled artifacts (${evidence}). Convert them to the bracket spelling `
4518
+ + `${renderPhaseId(targetId)}${directoryPrefix} before removing this phase.`);
4519
+ }
4520
+ }
2272
4521
  if (targetDir && !force) {
2273
4522
  // #3183: canonical summary set (root+nested) from the single owner —
2274
4523
  // a root-only readdirSync filter left nested (#3139 layout) summaries
@@ -2279,6 +4528,113 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2279
4528
  error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
2280
4529
  }
2281
4530
  }
4531
+ // #4304: compute the ONE renumber mapping shared by the disk
4532
+ // rename and the ROADMAP rewrite before either runs, from the roadmap
4533
+ // content as it stands right now (only the target directory is about to
4534
+ // be deleted below; deletion does not change which OTHER identities the
4535
+ // scan finds). Both consumers below apply this exact mapping so they
4536
+ // cannot independently diverge on a decimal sub-phase identity.
4537
+ const preRemovalRanges = roadmapContentBeforeRemoval
4538
+ ? currentMilestoneRawRanges(roadmapContentBeforeRemoval, cwd, 'bracket')
4539
+ : null;
4540
+ // #4304: refuse before any mutation when the target's own
4541
+ // heading/checklist line lives entirely OUTSIDE the milestone window the
4542
+ // read side located — a mislocated window (a decoy heading carrying the
4543
+ // active version token before the real milestone heading, a document-level
4544
+ // Progress heading placed before it, or no milestone heading at all)
4545
+ // otherwise lets the destructive delete/rename below proceed while the
4546
+ // ROADMAP rewrite silently never reaches the target's real lines. Does not
4547
+ // attempt to fix the window's own selection (PR-6 / #4751 territory) —
4548
+ // only refuses instead of half-applying.
4549
+ //
4550
+ // #4304: NOT additionally gated on `targetDir` — a ROADMAP-only
4551
+ // target (a phase `phase add` created with no directory materialized yet)
4552
+ // on a mislocated window still half-applied under the `targetDir` gate:
4553
+ // later directories renamed and their ROADMAP lines renumbered onto the
4554
+ // target's identity while the target's own heading/checklist survived,
4555
+ // with an empty report. The guard reads only ROADMAP content (never
4556
+ // `targetDir` itself), so gating it on a directory that may not exist
4557
+ // protected nothing; with no directory and no matching ROADMAP line the
4558
+ // guard already reports no outside line.
4559
+ if (removeContext) {
4560
+ const guardTargetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
4561
+ const { outside, hasLiveTarget } = bracketOwnedLineOutsideActiveWindow(roadmapContentBeforeRemoval, guardTargetId, preRemovalRanges);
4562
+ if (outside) {
4563
+ const windowDescription = preRemovalRanges
4564
+ ? `the located milestone window starting at line ${roadmapContentBeforeRemoval.slice(0, preRemovalRanges.primary.start).split('\n').length} (${roadmapContentBeforeRemoval
4565
+ .slice(preRemovalRanges.primary.start, roadmapContentBeforeRemoval.indexOf('\n', preRemovalRanges.primary.start))
4566
+ .trim()})`
4567
+ : 'no milestone heading at all';
4568
+ error(`Cannot remove phase ${normalized}: its own heading/checklist line `
4569
+ + `(ROADMAP.md line ${outside.lineNumber}: ${JSON.stringify(outside.text)}) `
4570
+ + `lies outside ${windowDescription}. ROADMAP.md's milestone heading was not `
4571
+ + 'found where expected; verify the ROADMAP.md structure before removing.', ERROR_REASON.USAGE);
4572
+ }
4573
+ if (targetDir === null && !hasLiveTarget) {
4574
+ error(`Cannot remove phase ${normalized}: no directory or live heading/checklist exists in the active milestone window.`, ERROR_REASON.USAGE);
4575
+ }
4576
+ }
4577
+ // #4304: refuse before any mutation when an INTEGER phase
4578
+ // still has its own sub-phases. Without this, computeBracketRenumberMapping's
4579
+ // filter (phase > removedInt only) never touches the removed phase's OWN
4580
+ // sub-phases: they stay orphaned on disk/ROADMAP while the NEXT phase's
4581
+ // sub-phases renumber onto the SAME identities, manufacturing duplicates
4582
+ // (legacy has the same defect on its own path — parity, not fixed there).
4583
+ if (removeContext && removedSubphase === undefined) {
4584
+ const ownSubphases = bracketPhaseOwnSubphases(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt);
4585
+ if (ownSubphases.length > 0) {
4586
+ error(`Cannot remove phase ${normalized}: it still has sub-phase(s) `
4587
+ + `${ownSubphases.map((id) => renderPhaseId(id)).join(', ')}. `
4588
+ + 'Remove the sub-phase(s) first, then remove the phase.');
4589
+ }
4590
+ }
4591
+ const bracketMapping = removeContext
4592
+ ? computeBracketRenumberMapping(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt, removedSubphase)
4593
+ : [];
4594
+ // #4304: the mapping inventories only canonical bracket spellings. Refuse
4595
+ // before any mutation while a legacy spelling resolves to an identity it
4596
+ // deletes, renumbers, or renumbers onto; renumbering around it would leave
4597
+ // that spelling on its old number for the reader to misattribute.
4598
+ if (removeContext) {
4599
+ const entangled = legacySpellingsEntangledWithBracketRemoval(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt, removedSubphase, bracketMapping);
4600
+ if (entangled.length > 0) {
4601
+ error(`Cannot remove phase ${normalized}: phase remove renumbers only bracket spellings, and `
4602
+ + `${entangled.length === 1 ? 'this legacy spelling resolves' : 'these legacy spellings resolve'} `
4603
+ + `to a phase it would delete or renumber: ${entangled.map(describeEntangledLegacySpelling).join('; ')}. `
4604
+ + 'Convert each to its bracket spelling, then retry.');
4605
+ }
4606
+ }
4607
+ if (removeContext) {
4608
+ const targetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
4609
+ const unrepresentable = unrepresentableBracketIdsAffectedByRemoval(roadmapContentBeforeRemoval, preRemovalRanges, removedInt, removedSubphase);
4610
+ if (unrepresentable.length > 0) {
4611
+ error(`Cannot remove phase ${normalized}: the active milestone lists phase id(s) `
4612
+ + `${unrepresentable.join(', ')} that are not representable in the bracket convention, `
4613
+ + 'so phase remove can neither delete nor renumber them alongside their siblings. '
4614
+ + 'Renumber or remove them first, then retry.');
4615
+ }
4616
+ const plan = planBracketRoadmapRemoval(roadmapContentBeforeRemoval, targetId, bracketMapping, cwd);
4617
+ if (plan.survivingTargetRows.length > 0) {
4618
+ error(`Cannot remove phase ${normalized}: ROADMAP.md has table row(s) keyed by ${renderPhaseId(targetId)} `
4619
+ + `outside the milestone's own Progress tables (${plan.survivingTargetRows.map((row) => JSON.stringify(row.trim())).join(', ')}). `
4620
+ + 'Removing the phase would leave each row naming an identity a renumbered sibling takes over. '
4621
+ + 'Delete or re-key those rows first, then retry.');
4622
+ }
4623
+ const currentPhase = stateCurrentPhaseAffectedByBracketRemoval(cwd, targetId, bracketMapping);
4624
+ if (currentPhase !== null) {
4625
+ error(`Cannot remove phase ${renderPhaseId(targetId)}: STATE.md's current phase is ${currentPhase}, which this `
4626
+ + 'removal deletes or renumbers, and phase remove does not rewrite the current-phase fields. '
4627
+ + 'Bracket phase remove deletes only phases after the current phase. To remove this one, point STATE.md\'s '
4628
+ + `current phase at a phase before ${renderPhaseId(targetId)}, or, when there is none, clear the current-phase `
4629
+ + 'fields (frontmatter current_phase, the Current Phase field and the Current Position Phase line), then retry.');
4630
+ }
4631
+ }
4632
+ // Validate every bracket rename destination before deleting the target or
4633
+ // changing ROADMAP/STATE, so a planted symlink produces a clean refusal
4634
+ // with the planning tree untouched.
4635
+ if (removeContext) {
4636
+ assertBracketRenameDestinationsSafe(phasesDir, removeContext, bracketMapping, targetDir);
4637
+ }
2282
4638
  // #5118 (no write before the error): the STATE.md rewrite below rebuilds the
2283
4639
  // frontmatter from EVERY surviving phase's report (buildStateFrontmatter →
2284
4640
  // isPhaseComplete), and it runs AFTER the directory removal, the sibling
@@ -2300,13 +4656,22 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2300
4656
  let renamedFiles = [];
2301
4657
  let renamedFileCollisions = [];
2302
4658
  try {
2303
- if (isDecimal) {
2304
- const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
4659
+ if (removeContext) {
4660
+ // #4304: reuse the SAME removedInt/removedSubphase validated
4661
+ // above (before deletion) instead of re-deriving from `normalized`,
4662
+ // which is NaN for a qualified id like `CK.02-02`.
4663
+ const renamed = renameBracketPhases(phasesDir, removeContext, bracketMapping);
4664
+ renamedDirs = renamed.renamedDirs;
4665
+ renamedFiles = renamed.renamedFiles;
4666
+ renamedFileCollisions = renamed.renamedFileCollisions;
4667
+ }
4668
+ else if (!customTarget && isDecimal) {
4669
+ const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10), directoryContext);
2305
4670
  renamedDirs = renamed.renamedDirs;
2306
4671
  renamedFiles = renamed.renamedFiles;
2307
4672
  }
2308
- else {
2309
- const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
4673
+ else if (!customTarget) {
4674
+ const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10), directoryContext);
2310
4675
  renamedDirs = renamed.renamedDirs;
2311
4676
  renamedFiles = renamed.renamedFiles;
2312
4677
  renamedFileCollisions = renamed.renamedFileCollisions;
@@ -2325,7 +4690,14 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2325
4690
  const msg = e instanceof Error ? e.message : String(e);
2326
4691
  error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
2327
4692
  }
2328
- const roadmapUpdated = updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
4693
+ const bracketRoadmapRewrite = removeContext
4694
+ ? updateRoadmapAfterBracketPhaseRemoval(roadmapPath, removedInt, removedSubphase, removeContext, bracketMapping, cwd)
4695
+ : null;
4696
+ const roadmapUpdated = bracketRoadmapRewrite
4697
+ ? bracketRoadmapRewrite.updated
4698
+ : updateRoadmapAfterPhaseRemoval(roadmapPath, directoryContext.directoryIds?.[targetDir ?? '']
4699
+ ?? (targetDir ? phaseIdMod.customDirectoryCandidates(targetDir, directoryContext)[0] : undefined)
4700
+ ?? targetPhase, isDecimal, parseInt(normalized, 10), cwd, customTarget, stableIds);
2329
4701
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
2330
4702
  let stateUpdated = false;
2331
4703
  if (node_fs_1.default.existsSync(statePath)) {
@@ -2403,6 +4775,12 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2403
4775
  // change, not hardcoded regardless of whether ROADMAP.md's content
2404
4776
  // actually changed.
2405
4777
  roadmap_updated: roadmapUpdated,
4778
+ ...(bracketRoadmapRewrite
4779
+ ? {
4780
+ roadmap_lines_rewritten: bracketRoadmapRewrite.roadmapLinesRewritten,
4781
+ references_left_untouched: bracketRoadmapRewrite.referencesLeftUntouched,
4782
+ }
4783
+ : {}),
2406
4784
  state_updated: stateUpdated,
2407
4785
  }, raw);
2408
4786
  // #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
@@ -3150,6 +5528,7 @@ function auditArtifactWarnings(cwd, phaseFullDir) {
3150
5528
  return out;
3151
5529
  }
3152
5530
  function cmdPhaseComplete(cwd, phaseNum, raw) {
5531
+ const directoryContext = phaseDirectoryOptions(cwd);
3153
5532
  if (!phaseNum) {
3154
5533
  error('phase number required for phase complete');
3155
5534
  }
@@ -3186,7 +5565,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3186
5565
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
3187
5566
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
3188
5567
  const today = clock_cjs_1.realClock.localToday();
3189
- const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
5568
+ const phaseInfoRaw = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
3190
5569
  if (!phaseInfoRaw) {
3191
5570
  error(`Phase ${phaseNum} not found`);
3192
5571
  }
@@ -3451,10 +5830,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3451
5830
  const runPhaseCompleteTransaction = () => {
3452
5831
  const writes = [];
3453
5832
  let roadmapContent = null;
5833
+ // #5165: the phase was ALREADY complete in the ROADMAP this transaction
5834
+ // started from (read before any mutation below): its checklist box was
5835
+ // checked, or its Progress-table row read Complete.
5836
+ let phaseWasAlreadyComplete = false;
3454
5837
  if (node_fs_1.default.existsSync(roadmapPath)) {
3455
5838
  const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
3456
5839
  roadmapContent = originalRoadmapContent;
3457
- const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
5840
+ const phaseEscaped = phaseMarkdownRegexSource(phaseNum, directoryContext);
3458
5841
  // #2067: the gap between `]` and `Phase N` must allow only whitespace /
3459
5842
  // markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
3460
5843
  // phase whose description merely mentioned the completed phase number,
@@ -3487,7 +5870,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3487
5870
  // bug because a GSD-authored ROADMAP.md milestone checklist never puts
3488
5871
  // its own `- [ ] Phase N: …` entries inside a fenced code block, but it
3489
5872
  // is a real (and correct) behavioural difference on pathological input.
3490
- const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
5873
+ // The phase-entry grammar's split regexp (#5764): group 1 is everything through the
5874
+ // opening `[` (indent included, so a flip keeps the entry's indent), group 2 the rest.
5875
+ const entryTailSrc = `Phase[ \\t]+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`;
5876
+ const checkboxPattern = (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(entryTailSrc, 'i', { state: 'unchecked' });
5877
+ // #5165: pre-mutation read of the same checklist line, checked this time.
5878
+ const checkedBoxPattern = (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(entryTailSrc, '', { state: 'checked' });
5879
+ const originalMilestoneScope = extractCurrentMilestone(originalRoadmapContent, cwd);
5880
+ phaseWasAlreadyComplete = originalMilestoneScope
5881
+ .split('\n')
5882
+ .some((line) => checkedBoxPattern.test(line));
3491
5883
  // Progress table row: update Plans Complete/Status/Completed columns BY
3492
5884
  // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
3493
5885
  // markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
@@ -3504,27 +5896,22 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3504
5896
  const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
3505
5897
  const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
3506
5898
  const dateShape = /^\d{4}-\d{2}-\d{2}$/;
3507
- /**
3508
- * Within `text` (already scoped to one milestone window by the
3509
- * caller), scope further to the `## Progress` heading section (up to
3510
- * the next `#`/`##` heading) when present, run `edit` against just
3511
- * that slice, and splice the result back — falling back to the whole
3512
- * `text` when no `## Progress` heading exists (mirrors phase-
3513
- * lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
3514
- */
3515
- const editProgressHeadingSlice = (text, edit) => {
3516
- const progressMatch = text.match(/^##[ \t]+Progress\b/im);
3517
- if (!progressMatch || progressMatch.index === undefined) {
3518
- return edit(text);
5899
+ // #5165: a ROADMAP whose checklist box was never ticked (or has none) but
5900
+ // whose Progress-table row already reads Complete is just as complete. A
5901
+ // milestone-grouped table (a `Milestone` column) is read only for the
5902
+ // milestone ROADMAP binds, so a shipped milestone's same-numbered phase
5903
+ // cannot count.
5904
+ if (!phaseWasAlreadyComplete) {
5905
+ const progressTable = (0, roadmap_progress_cjs_1.locateRoadmapProgress)(originalMilestoneScope).table;
5906
+ if (progressTable !== null) {
5907
+ const wantMilestone = progressTable.columns.includes('Milestone')
5908
+ ? (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(roadmapParserMod.getMilestoneInfo(cwd).value?.version)
5909
+ : null;
5910
+ phaseWasAlreadyComplete = progressTable.rows.some((row) => rowMatch(row)
5911
+ && (0, phase_status_cjs_1.parseRoadmapStatusCell)(row['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
5912
+ && (wantMilestone === null || (wantMilestone !== '' && (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(row['Milestone']) === wantMilestone)));
3519
5913
  }
3520
- const headingOffset = progressMatch.index;
3521
- const beforeHeading = text.slice(0, headingOffset);
3522
- const fromHeading = text.slice(headingOffset);
3523
- const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
3524
- const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
3525
- const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
3526
- return beforeHeading + edit(scoped) + after;
3527
- };
5914
+ }
3528
5915
  // ADR-2143 §4: the plan-count write is now routed through
3529
5916
  // withPhaseSection (see mutateMilestonePhase below), which hands this
3530
5917
  // seam call ONLY phase N's own detail-section body — the section
@@ -3636,12 +6023,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3636
6023
  const mutateMilestonePhase = (slice) => {
3637
6024
  let s = slice;
3638
6025
  s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
3639
- s = editProgressHeadingSlice(s, (scoped) => {
6026
+ // #5762: `s` is already one milestone window; the Progress slice is the owner's
6027
+ // (`editProgressTableSlice` -> `editRoadmapProgress`), the same one `roadmap update-plan-progress` uses.
6028
+ s = editProgressTableSlice(s, (scoped) => {
3640
6029
  let text = scoped;
3641
6030
  const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
3642
6031
  if (plansResult.ok)
3643
6032
  text = plansResult.value;
3644
- const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ` ${(0, phase_status_cjs_1.toRoadmapStatusCell)(phase_status_cjs_1.PHASE_STATUS.COMPLETE).padEnd(11)} `);
6033
+ // #5165: a Status cell that already reads Complete is left untouched —
6034
+ // re-padding it rewrote a cell the re-run had no business editing.
6035
+ const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', (current) => (0, phase_status_cjs_1.parseRoadmapStatusCell)(current) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
6036
+ ? current
6037
+ : ` ${(0, phase_status_cjs_1.toRoadmapStatusCell)(phase_status_cjs_1.PHASE_STATUS.COMPLETE).padEnd(11)} `);
3645
6038
  if (statusResult.ok)
3646
6039
  text = statusResult.value;
3647
6040
  // Preserve only a valid ISO date (#1161: idempotent; self-heal
@@ -3667,35 +6060,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3667
6060
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
3668
6061
  if (!planId)
3669
6062
  continue;
3670
- const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
6063
+ // #5542: only a summary paired with a live (non-superseded) plan
6064
+ // flips a row — the same pairing rule the counts use.
6065
+ if (!coreUtilsMod.summaryPairsWithAnyPlan(phaseInfo['plans'] ?? [], summaryFile))
6066
+ continue;
3671
6067
  // ADR-5057 §6 (Phase 13, #5217): the per-plan flip is a bullet
3672
- // mutation, so it goes through `updateBullet` like the phase
6068
+ // mutation (`updateBullet` inside the helper) like the phase
3673
6069
  // checkbox flip above — first matching bullet only, never a
3674
6070
  // fenced line, never a mid-line occurrence.
3675
- const planCheckboxPattern = new RegExp(`^(\\s*-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
3676
- b = (0, markdown_sectionizer_cjs_1.updateBullet)(b, (_bulletText, rawLine) => planCheckboxPattern.test(rawLine), (rawLine) => rawLine.replace(planCheckboxPattern, '$1x$2'));
6071
+ b = coreUtilsMod.tickAnchoredPlanRow(b, planId);
3677
6072
  }
3678
6073
  return b;
3679
6074
  });
3680
6075
  return s;
3681
6076
  };
3682
- const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
3683
- if (milestoneRanges) {
3684
- // Splice later windows first so an earlier window's offsets are not
3685
- // shifted by a length-changing mutation in a later window.
3686
- const windows = [milestoneRanges.details, milestoneRanges.primary]
3687
- .filter((w) => w !== null)
3688
- .sort((a, b) => b.start - a.start);
3689
- for (const w of windows) {
3690
- roadmapContent =
3691
- roadmapContent.slice(0, w.start)
3692
- + mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
3693
- + roadmapContent.slice(w.end);
3694
- }
3695
- }
3696
- else {
3697
- roadmapContent = mutateMilestonePhase(roadmapContent);
3698
- }
6077
+ roadmapContent = editCurrentMilestone(roadmapContent, cwd, mutateMilestonePhase);
3699
6078
  writes.push({
3700
6079
  filePath: roadmapPath,
3701
6080
  before: originalRoadmapContent,
@@ -3709,7 +6088,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3709
6088
  roadmapUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalRoadmapContent, roadmapContent);
3710
6089
  const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
3711
6090
  if (node_fs_1.default.existsSync(reqPath)) {
3712
- const phaseEsc = phaseMarkdownRegexSource(phaseNum);
6091
+ const phaseEsc = phaseMarkdownRegexSource(phaseNum, directoryContext);
3713
6092
  const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
3714
6093
  // #5007 (Phase 6 / ADR-4910 §8): this site captures the entire
3715
6094
  // section BODY through to the next heading (via its own
@@ -3803,7 +6182,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3803
6182
  // checkbox rollback below can distinguish "row existed and accepted" from
3804
6183
  // "row existed and rejected".
3805
6184
  let tableHit = false;
3806
- const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
6185
+ const reqUpdate = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, reqRowMatch, 'Status', (current) => {
3807
6186
  // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
3808
6187
  // gaps_found response) can complete without hand-editing the table.
3809
6188
  if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
@@ -3990,7 +6369,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3990
6369
  // technique milestone.cts's `hasRow` uses (:210-214): a case-
3991
6370
  // insensitive first-cell match, regardless of current Status.
3992
6371
  const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
3993
- return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
6372
+ return (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
3994
6373
  };
3995
6374
  const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
3996
6375
  if (ghostReqIds.length > 0) {
@@ -4057,16 +6436,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4057
6436
  // comparePhaseNum-based so `02` and `2` dedupe. With no ROADMAP.md (or no
4058
6437
  // parseable rows) the set is empty and the scans behave exactly as
4059
6438
  // before.
6439
+ // Custom IDs use declared roadmap order; the numeric-minimum comments
6440
+ // below describe the unchanged legacy cascade.
4060
6441
  const roadmapCompleteNums = [];
4061
6442
  if (roadmapContent !== null) {
4062
6443
  try {
4063
6444
  const milestoneForComplete = extractCurrentMilestone(roadmapContent, cwd);
4064
- const completePattern = new RegExp(`-\\s*\\[[xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi');
6445
+ const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext)})`, 'gi');
4065
6446
  let cm;
4066
6447
  while ((cm = completePattern.exec(milestoneForComplete)) !== null) {
6448
+ if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(cm[0]))
6449
+ continue;
4067
6450
  if (isSentinelPhaseId(cm[1]))
4068
6451
  continue;
4069
- if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm[1], n) === 0)) {
6452
+ if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm[1], n, directoryContext) === 0)) {
4070
6453
  roadmapCompleteNums.push(cm[1]);
4071
6454
  }
4072
6455
  }
@@ -4076,7 +6459,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4076
6459
  * set empty — the scans then behave exactly as they did pre-#4699. */
4077
6460
  }
4078
6461
  }
4079
- const isCompletePhaseNum = (num) => roadmapCompleteNums.some((n) => comparePhaseNum(num, n) === 0);
6462
+ const isCompletePhaseNum = (num) => roadmapCompleteNums.some((n) => comparePhaseNum(num, n, directoryContext) === 0);
4080
6463
  try {
4081
6464
  // #3185 (ADR-3180 Decision 1): "which phase directories belong to
4082
6465
  // the CURRENT milestone" — routed through the canonical owner
@@ -4087,7 +6470,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4087
6470
  // is not necessarily identical to the raw directory name).
4088
6471
  const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
4089
6472
  for (const dir of dirs) {
4090
- const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
6473
+ const custom = phaseIdMod.customPhaseQuery(dir, directoryContext);
6474
+ const owner = custom && customDirectoryOwner(dir, custom);
6475
+ const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
4091
6476
  if (dm) {
4092
6477
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
4093
6478
  if (isSentinelPhaseId(dm[1]))
@@ -4101,8 +6486,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4101
6486
  // correct today — but that makes this scan's correctness depend on an
4102
6487
  // upstream sort nothing here states. Selecting the minimum explicitly costs
4103
6488
  // one comparison and removes the hidden coupling.
4104
- if (comparePhaseNum(dm[1], phaseNum) > 0
4105
- && (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
6489
+ if (comparePhaseNum(dm[1], phaseNum, directoryContext) > 0
6490
+ && (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum, directoryContext) < 0)) {
4106
6491
  diskNextNum = dm[1];
4107
6492
  diskNextName = dm[2] || null;
4108
6493
  }
@@ -4150,8 +6535,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4150
6535
  // checkbox branch widens, and only to the separators #2199 already
4151
6536
  // accepts. The two branches keep separate capture groups, normalized
4152
6537
  // just below the loop.
4153
- const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
4154
- `|-\\s*\\[[ xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gi');
6538
+ const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
6539
+ `|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gim');
4155
6540
  let pm;
4156
6541
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
4157
6542
  // #4078: normalize the two alternation branches' captures (heading
@@ -4182,8 +6567,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4182
6567
  // Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
4183
6568
  // the disk scan and for #2028's lowest-outstanding override; the roadmap
4184
6569
  // defines which phases EXIST and which milestone they belong to.
4185
- if (comparePhaseNum(pmNum, phaseNum) > 0
4186
- && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
6570
+ if (comparePhaseNum(pmNum, phaseNum, directoryContext) > 0
6571
+ && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum, directoryContext) < 0)) {
4187
6572
  roadmapNextNum = pmNum;
4188
6573
  roadmapNextName = pmName
4189
6574
  .replace(/\(INSERTED\)/i, '')
@@ -4207,7 +6592,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4207
6592
  // and the roadmap mean the same phase" cannot drift from "N is above the
4208
6593
  // one just completed". `02` and `2` compare equal, which is the whole
4209
6594
  // point — they are the same phase spelled two ways.
4210
- const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
6595
+ const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum, directoryContext) === 0;
4211
6596
  nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
4212
6597
  nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
4213
6598
  isLastPhase = false;
@@ -4251,11 +6636,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4251
6636
  // Without it, this lowest-outstanding override was blind to dash-grammar
4252
6637
  // rows and could not correct an out-of-order completion on the same
4253
6638
  // mixed-grammar roadmaps that broke stage 2.
4254
- const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi');
6639
+ const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi', { state: 'captured' });
4255
6640
  let cbm;
4256
6641
  let lowestOutstanding = null;
4257
6642
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
4258
- const isChecked = cbm[1].toLowerCase() === 'x';
6643
+ const isChecked = (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(cbm[1]);
4259
6644
  // #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
4260
6645
  // comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
4261
6646
  // backlog row sorts below every real phase and is wrongly selected as next_phase,
@@ -4263,8 +6648,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4263
6648
  // isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
4264
6649
  // real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
4265
6650
  // still selected, preserving #2028's out-of-order-completion behavior.
4266
- if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
4267
- if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
6651
+ if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum, directoryContext) < 0) {
6652
+ if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num, directoryContext) < 0) {
4268
6653
  lowestOutstanding = {
4269
6654
  num: cbm[2],
4270
6655
  name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
@@ -4288,6 +6673,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4288
6673
  }
4289
6674
  if (node_fs_1.default.existsSync(statePath)) {
4290
6675
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
6676
+ // #5165: the milestone STATE asserts (ROADMAP-bound identity first,
6677
+ // else STATE's own frontmatter) scopes the post-completion progress
6678
+ // derivation to that milestone's Progress-table rows.
6679
+ const stateFm = frontmatterMod.extractFrontmatter(originalStateContent, statePath);
6680
+ const storedMilestone = roadmapParserMod.getMilestoneInfo(cwd).value?.version
6681
+ ?? (0, roadmap_progress_cjs_1.milestoneVersionOf)(stateFm['milestone']);
6682
+ // #5165: re-completing a phase that is already complete in ROADMAP.md
6683
+ // while STATE has moved past it is a no-op for STATE's position —
6684
+ // the transition, the metrics row and the authoritative current-phase
6685
+ // pin would all rewind it. A first-time completion (box unchecked, or
6686
+ // STATE not past N, #2028 out-of-order) still transitions.
6687
+ const stateAlreadyPast = phaseWasAlreadyComplete
6688
+ && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath);
4291
6689
  let stateContent = originalStateContent;
4292
6690
  // ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
4293
6691
  // shape/name, Status, Current Plan, Last Activity + Description, and
@@ -4302,8 +6700,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4302
6700
  // NOT go through readModifyWriteStateMd because STATE.md is
4303
6701
  // committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
4304
6702
  // #3374 / #3469).
4305
- const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
4306
- phaseDisplayNameFromSlug(nextPhaseName);
6703
+ const nextPhaseDisplayName = stateAlreadyPast
6704
+ ? null
6705
+ : phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
6706
+ phaseDisplayNameFromSlug(nextPhaseName);
4307
6707
  // #4824: classify the SELECTED next phase from disk (the same ladder
4308
6708
  // roadmap analyze uses) so the Status / Current Plan / Stopped-at
4309
6709
  // handoff does not say "ready to plan" for a phase already planned or
@@ -4312,7 +6712,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4312
6712
  let nextPhaseDisk = null;
4313
6713
  if (nextPhaseNum && !isLastPhase) {
4314
6714
  try {
4315
- const nextInfo = findPhaseInternal(cwd, nextPhaseNum);
6715
+ const nextInfo = findPhaseInternal(cwd, nextPhaseNum, undefined, directoryContext);
4316
6716
  if (nextInfo && nextInfo.found && nextInfo.directory) {
4317
6717
  const { value } = (0, phase_status_cjs_1.phaseStatus)(node_path_1.default.join(cwd, nextInfo.directory), {
4318
6718
  convention: resolvePhaseIdConvention(cwd),
@@ -4324,7 +6724,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4324
6724
  /* best-effort: an unreadable next phase keeps `Ready to plan` */
4325
6725
  }
4326
6726
  }
4327
- const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
6727
+ const completeResult = stateAlreadyPast ? { content: stateContent } : (0, state_transition_cjs_1.transitionCore)(stateContent, {
4328
6728
  kind: 'completePhase',
4329
6729
  phaseNum,
4330
6730
  nextPhaseNum,
@@ -4333,6 +6733,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4333
6733
  planCount,
4334
6734
  summaryCount,
4335
6735
  nextPhaseDisk,
6736
+ // #5165: the body counter and the frontmatter progress below count the
6737
+ // SAME milestone's Progress rows.
6738
+ milestone: storedMilestone,
4336
6739
  }, {
4337
6740
  clock: clock_cjs_1.realClock,
4338
6741
  // #4901: the body counter derives from the same current-milestone scope
@@ -4343,7 +6746,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4343
6746
  sourcePath: statePath,
4344
6747
  });
4345
6748
  stateContent = completeResult.content;
4346
- stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
6749
+ if (!stateAlreadyPast) {
6750
+ stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
6751
+ }
4347
6752
  // #2736: the transition holds the next phase's exact display name in
4348
6753
  // the intent; pass it as authoritative so the sync's prose
4349
6754
  // re-derivation cannot rewrite current_phase_name to the name's own
@@ -4376,7 +6781,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4376
6781
  ? extractCurrentMilestone(roadmapContent, cwd)
4377
6782
  : null;
4378
6783
  const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
4379
- ? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
6784
+ ? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope, storedMilestone)
4380
6785
  : null;
4381
6786
  const authoritativeProgress = postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
4382
6787
  ? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
@@ -4528,7 +6933,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
4528
6933
  if (!phaseNum) {
4529
6934
  error('phase number required for phase uat-passed');
4530
6935
  }
4531
- const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
6936
+ const phaseInfoRaw = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd));
4532
6937
  if (!phaseInfoRaw) {
4533
6938
  error(`Phase ${phaseNum} not found`);
4534
6939
  }
@@ -4550,7 +6955,7 @@ function cmdPhaseListPlans(cwd, phaseNum, raw) {
4550
6955
  if (!phaseNum) {
4551
6956
  error('phase number required for phase list-plans');
4552
6957
  }
4553
- const phaseInfo = findPhaseInternal(cwd, phaseNum);
6958
+ const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd));
4554
6959
  if (!phaseInfo) {
4555
6960
  output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
4556
6961
  return;
@@ -4588,4 +6993,12 @@ module.exports = {
4588
6993
  computeDependencyLevels,
4589
6994
  buildShortFormToId,
4590
6995
  normalizeDependencyToken,
6996
+ // Test seam (#4304): the bracket renumber mapping is a disk and ROADMAP
6997
+ // bijection whose order must keep every rename destination free; exposed
6998
+ // so a property test can drive it over generated identity sets.
6999
+ _computeBracketRenumberMapping: computeBracketRenumberMapping,
7000
+ // Test seam (#4304): the qualified-reference rewriter the ROADMAP rewrite
7001
+ // applies once per mapping entry, so the same property can rewrite a line
7002
+ // in mapping order.
7003
+ _replaceQualifiedBracketReference: replaceQualifiedBracketReference,
4591
7004
  };