@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
@@ -1,14 +1,24 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Pure phase-id parsing/matching helpers — normalize, token match,
4
- * milestone/phase-dir id parsing, phase-markdown regex builders.
4
+ * milestone/phase-dir id parsing, phase-markdown regex builders, and identity
5
+ * indexes over caller-supplied roadmap text. No filesystem or config reads.
5
6
  *
6
7
  * Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
7
8
  * The hand-written bodies are preserved byte-for-behaviour; only the module
8
9
  * boundary moved. The core.cjs re-export spine was retired in epic #1267;
9
10
  * callers import phase-id helpers from phase-id.cjs directly.
10
11
  *
12
+ * Custom directory lookup decision table (#5186):
13
+ * 1. An exact physical basename selects that directory and reports its owner.
14
+ * 2. Exact recorded IDs win; a unique case/padding alias may name a record.
15
+ * Multiple alias records remain ambiguous rather than last-write-wins.
16
+ * 3. Markerless reads use known heading/ID prefixes; mutations refuse when
17
+ * more than one owner fits. Marked segment-prefix pairs are independent.
18
+ * 4. Unrecorded numeric and bracket queries keep their legacy matching paths.
19
+ *
11
20
  * Dependencies:
21
+ * - ./markdown-sectionizer.cjs (tokenizeHeadings over supplied roadmap text)
12
22
  * - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
13
23
  * longer the owner of pattern-escaping, only a consumer)
14
24
  * - ./core-utils.cjs (generateSlugInternal — #3883/ADR-3473 §8.3: the
@@ -23,7 +33,9 @@
23
33
  * body) instead, mirroring the same cycle-break already used by
24
34
  * core-utils.cts's own getPhaseFileStats/plan-scan.cjs seam.
25
35
  */
36
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
26
37
  const pattern_cjs_1 = require("./pattern.cjs");
38
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
27
39
  // ─── Phase-id helpers ─────────────────────────────────────────────────────────
28
40
  // project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
29
41
  // leading underscores are not valid project codes per .planning/config.json.
@@ -59,7 +71,7 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
59
71
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
60
72
  // introduced outside this module without a `// phase-id-owner:` justification.
61
73
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
62
- // #4764: a phase REFERENCE in depends-on PROSE — the token in context, directly
74
+ // #4764: a legacy phase REFERENCE in depends-on PROSE — the token in context, directly
63
75
  // following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
64
76
  // "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
65
77
  // anchored on, because two readers consume Depends-on prose (init.manager's
@@ -72,7 +84,8 @@ const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
72
84
  // dependencies. The `-` separator deliberately extracts range ENDPOINTS only
73
85
  // ("Phase 1-3" → 1, 3) — the pre-#4764 behavior; interior enumeration stays
74
86
  // out (a range's middle is not written as a reference).
75
- const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*)`;
87
+ const PHASE_DEP_TOKEN_LIST_SOURCE = `${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*`;
88
+ const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_DEP_TOKEN_LIST_SOURCE})`;
76
89
  // #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
77
90
  // sites that scan directory names (where a project code or a variant suffix may
78
91
  // legitimately be lowercase) and therefore cannot use a case-sensitive class.
@@ -303,6 +316,43 @@ const PHASE_HEADING_BASELINE = Object.freeze({
303
316
  *
304
317
  * Pure: takes the resolved convention, never reads config.
305
318
  */
319
+ // #4304: `bracketAlt`'s own alternation, factored out to a named
320
+ // function so every write-side consumer that needs "this bracket, optionally
321
+ // followed by the literal Phase label, then a digit" derives from the SAME
322
+ // expression the read grammar compiles — never a hand-retyped `[ \t]+` /
323
+ // case-sensitive copy. `idSrc` is the regex SOURCE for the bracket's
324
+ // `{CODE}.{MM}` interior: the generic `BRACKET_ID_SRC` class (capturing or
325
+ // not) for a classifier that must recognize ANY bracket identity, or one
326
+ // escaped, already-known project/milestone pair (via
327
+ // `bracketQualifiedIntroSrcFor` below) for a rewriter/detector that already
328
+ // knows exactly which identity it means. Returns only the regex SOURCE —
329
+ // every caller compiles it themselves (every reader uses the `i` flag), so
330
+ // this composes with `g`, an outer capturing group, or a trailing literal
331
+ // number.
332
+ function bracketAltIntroSrcFor(idSrc) {
333
+ // `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
334
+ // followed by a blank line and a digit-leading prose line read as one phase.
335
+ return `\\[${idSrc}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
336
+ }
337
+ /**
338
+ * #4304: the qualified-mention intro for ONE already-known
339
+ * bracket identity — `[{PROJECT}.{MM}]` (both escaped literals, not a
340
+ * class), optionally followed by the `Phase` label — built from
341
+ * `bracketAltIntroSrcFor` so a rewriter or detector anchoring a specific
342
+ * phase-number token right after it accepts exactly the spellings
343
+ * `phaseHeadingPrefixSrcFor`'s bracket alternative does: any case (compile
344
+ * with `i`), zero-or-more spaces, an optional `Phase` label. phase.cts's
345
+ * write-side bracket-identity regexes (the qualified-reference replacer, the
346
+ * qualified-mention detector) build their intro through this instead of
347
+ * re-typing `[ \t]+` / case-sensitive brackets independently of the read
348
+ * grammar. A hand-composed case-sensitive, `[ \t]+`-spaced copy would reject
349
+ * `[ck.02] 02:`, `[CK.02] PHASE 02:`, and the no-space `[CK.02]02:`, all of
350
+ * which `roadmap get-phase`, `roadmap analyze`, `phase insert` and
351
+ * `phase add` accept as real phases.
352
+ */
353
+ function bracketQualifiedIntroSrcFor(project, milestone) {
354
+ return bracketAltIntroSrcFor(`${(0, pattern_cjs_1.escapeRegex)(String(project))}\\.${(0, pattern_cjs_1.escapeRegex)(String(milestone))}`);
355
+ }
306
356
  function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
307
357
  const base = baseline === PHASE_HEADING_BASELINE.ANY_BRACKET
308
358
  ? BASE_ANY_BRACKET_HEADING_PREFIX_SRC
@@ -310,8 +360,6 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
310
360
  if (convention !== 'bracket')
311
361
  return base;
312
362
  const id = capturing ? `(${BRACKET_ID_SRC})` : BRACKET_ID_SRC;
313
- // `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
314
- // followed by a blank line and a digit-leading prose line read as one phase.
315
363
  // BOTH bracket forms are admitted at both baselines, and both CAPTURE. The
316
364
  // any-bracket base already matches `[GSD.999] Phase 07:` on its own — but
317
365
  // through the base alternative, which captures nothing, so the reader saw
@@ -319,9 +367,31 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
319
367
  // counted a labeled icebox heading as a real phase while the label-less form
320
368
  // beside it was excluded. Two derivations of one ROADMAP disagreed. The
321
369
  // bracket alternative is tried FIRST so it wins the capture.
322
- const bracketAlt = `\\[${id}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
370
+ const bracketAlt = bracketAltIntroSrcFor(id);
323
371
  return `(?:${bracketAlt}|${base})`;
324
372
  }
373
+ /**
374
+ * Parse one roadmap phase-checkbox row through the same anchored grammar used
375
+ * by readers and writers (#4982). The identity must be the first thing after
376
+ * the checkbox, tolerating only an optional `**`. Bracket mode captures the
377
+ * leading `[CODE.MM] PP` identity. Both paths treat whitespace or a colon
378
+ * immediately after the phase token as the boundary. Returning semantic fields
379
+ * keeps consumers from depending on capture-group offsets.
380
+ */
381
+ function parsePhaseChecklistLine(line, convention, context) {
382
+ const capturesBracketId = convention === 'bracket';
383
+ const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturesBracketId);
384
+ const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})`
385
+ + `${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`, 'i', { state: 'captured' });
386
+ const match = pattern.exec(line);
387
+ if (!match)
388
+ return null;
389
+ return {
390
+ checked: (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(match[1]),
391
+ bracketId: capturesBracketId ? match[2] : undefined,
392
+ phaseToken: capturesBracketId ? match[3] : match[2],
393
+ };
394
+ }
325
395
  /**
326
396
  * The single owner of the "scan a whole document for every phase heading"
327
397
  * pattern (#4865 / ADR-4910 §8) — anchored (`^ {0,3}#{2,4}`, so indentation up
@@ -339,13 +409,56 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
339
409
  * This function answers "which phases exist in this content", not "does this
340
410
  * specific phase exist".
341
411
  */
342
- function buildPhaseHeadingScanRegex(baseline, convention) {
412
+ function buildPhaseHeadingScanRegex(baseline, convention, context) {
343
413
  const bracketGroup = convention === 'bracket' ? 1 : null;
344
414
  const phaseNumGroup = bracketGroup ? 2 : 1;
345
415
  const phaseNameGroup = phaseNumGroup + 1;
346
- const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
416
+ const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
347
417
  return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
348
418
  }
419
+ /**
420
+ * Every scan that answers "which phase numbers does this roadmap text declare" (#5528): section
421
+ * headings, plus checklist bullets when `withBullets`. A non-bracket convention gets the plain
422
+ * `Phase N` heading scan and the checkbox-led `Phase N` bullet it always had.
423
+ *
424
+ * Under the bracket convention it also gets, beyond `buildPhaseHeadingScanRegex`'s `[ID] NN` and
425
+ * `[ID] Phase NN` headings: the word-first heading `Phase [ID] NN`; and a bullet whose bracket IS
426
+ * the id (`- [GSD.02] 03: X`, no checkbox). The checkbox of the other bullet shape may not itself
427
+ * be a bracket id, or `- [GSD.999] Phase 07` would read `[GSD.999]` as a checkbox and drop the id
428
+ * that marks it an icebox row. A bullet is one line, so its label uses `[ \t]` where a heading's
429
+ * uses `\s`.
430
+ */
431
+ function buildPhaseDeclarationScans(convention, withBullets = true) {
432
+ const heading = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
433
+ const scans = [{ regex: heading.regex, idGroup: heading.bracketGroup, numGroup: heading.phaseNumGroup }];
434
+ const bracket = convention === 'bracket';
435
+ // Under bracket a numbered-list item (`1. [ ] **[GSD.02] 05: n**`) is a checklist row too.
436
+ const bullet = bracket ? '^[ \\t]*(?:-|\\d+[.)])[ \\t]*' : '^[ \\t]*-[ \\t]*';
437
+ // Bracket ids may carry a letter suffix (`12A`), as the heading scan's phase token does.
438
+ const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : '(\\d+)(?=[:.\\s*]|$)';
439
+ const checkbox = `\\[${bracket ? `(?!${BRACKET_ID_SRC}\\])` : ''}[^\\]]{0,200}\\][ \\t]*`;
440
+ if (!bracket) {
441
+ if (withBullets)
442
+ scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+${tail}`, 'gim'), idGroup: null, numGroup: 1 });
443
+ return scans;
444
+ }
445
+ scans.push({ regex: new RegExp(`^ {0,3}#{2,4}\\s*Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gim'), idGroup: 1, numGroup: 2 });
446
+ if (withBullets) {
447
+ const label = `(?:\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))|Phase[ \\t]+)`;
448
+ scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}${label}${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
449
+ // The word-first heading's bullet twin: `- [ ] Phase [GSD.02] 07:`.
450
+ scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
451
+ // The checkbox forms above deliberately over-count a colon-less `- [ ] [GSD.02] 2026 notes` as phase 2026: a
452
+ // skipped number is harmless, a reused one is not, so do not "fix" them with a colon requirement.
453
+ // With no checkbox to vouch for it the number needs a `:`, a bold close or end of line, or a dash
454
+ // separator (`- [GSD.02] 05 - Name`), or `- [GSD.02] 2026 notes` is phase 2026.
455
+ scans.push({ regex: new RegExp(`${bullet}\\*{0,2}\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))(\\d+)(?=[A-Za-z]?(?::|\\*{0,2}[ \\t]*$|[ \\t]+[-\u2013\u2014][ \\t]))`, 'gim'), idGroup: 1, numGroup: 2 });
456
+ }
457
+ // A dash-separated heading (`### [GSD.02] 05 - Name`) is outside the colon grammar the readers share, but still a declaration.
458
+ // `###`/`####` only: a `##` milestone heading (`## [CK.02] 2 - Foundation`) names the milestone, not a phase.
459
+ scans.push({ regex: new RegExp(`^ {0,3}#{3,4}\\s*\\[(${BRACKET_ID_SRC})\\][ \\t]*(\\d+)(?=[A-Za-z]?[ \\t]+[-\u2013\u2014][ \\t])`, 'gim'), idGroup: 1, numGroup: 2 });
460
+ return scans;
461
+ }
349
462
  function stripProjectCodePrefix(value, caseInsensitive = true) {
350
463
  const input = String(value);
351
464
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -489,6 +602,116 @@ function parsePhaseId(input) {
489
602
  // tokens unchanged.
490
603
  throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
491
604
  }
605
+ /**
606
+ * Tokenize dependency references with source spans. Bracket-qualified lists
607
+ * canonicalize every numeric segment before strict identity parsing. If the
608
+ * qualified identity still cannot be parsed, the reader keeps the original
609
+ * token as a legacy dependency instead of silently dropping it.
610
+ */
611
+ function tokenizePhaseDependencyReferences(prose, convention) {
612
+ const input = prose;
613
+ const found = [];
614
+ const qualifiedTokenSpans = [];
615
+ const legacyRefRe = new RegExp(`${PHASE_DEP_REF_SOURCE}`, 'gi');
616
+ const tokenRe = new RegExp(PHASE_NUMBER_TOKEN_SOURCE, convention === 'bracket' ? 'gi' : 'g');
617
+ let refMatch;
618
+ while ((refMatch = legacyRefRe.exec(input)) !== null) {
619
+ const referenceStart = refMatch.index + refMatch[0].length - refMatch[1].length;
620
+ tokenRe.lastIndex = 0;
621
+ let tokenMatch;
622
+ while ((tokenMatch = tokenRe.exec(refMatch[1])) !== null) {
623
+ const start = referenceStart + tokenMatch.index;
624
+ found.push({ kind: 'legacy', start, end: start + tokenMatch[0].length, token: tokenMatch[0] });
625
+ }
626
+ }
627
+ if (convention !== 'bracket') {
628
+ return found;
629
+ }
630
+ const bracketDisplayRe = new RegExp(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true)}(${PHASE_DEP_TOKEN_LIST_SOURCE})`, 'gi');
631
+ let displayMatch;
632
+ while ((displayMatch = bracketDisplayRe.exec(input)) !== null) {
633
+ if (!displayMatch[1])
634
+ continue;
635
+ const tokenList = displayMatch[2];
636
+ const tokenListStart = displayMatch.index + displayMatch[0].length - tokenList.length;
637
+ tokenRe.lastIndex = 0;
638
+ let tokenMatch;
639
+ while ((tokenMatch = tokenRe.exec(tokenList)) !== null) {
640
+ const start = tokenListStart + tokenMatch.index;
641
+ const end = start + tokenMatch[0].length;
642
+ qualifiedTokenSpans.push({ start, end });
643
+ const numeric = tokenMatch[0].split('.').every((part) => /^\d+$/.test(part))
644
+ ? tokenMatch[0].split('.').map(pad2).join('.')
645
+ : null;
646
+ try {
647
+ if (numeric === null)
648
+ throw new Error('not a numeric bracket phase token');
649
+ const id = parsePhaseId(`[${foldBracketId(displayMatch[1])}] ${numeric}`);
650
+ found.push({
651
+ kind: 'qualified',
652
+ referenceStart: displayMatch.index,
653
+ start,
654
+ end,
655
+ token: renderPhaseId(id),
656
+ });
657
+ }
658
+ catch {
659
+ found.push({
660
+ kind: 'qualified-fallback',
661
+ referenceStart: displayMatch.index,
662
+ start,
663
+ end,
664
+ token: tokenMatch[0],
665
+ });
666
+ }
667
+ }
668
+ }
669
+ const chunks = input.matchAll(/\S+/g);
670
+ for (const chunk of chunks) {
671
+ const candidate = chunk[0]
672
+ .replace(/^[`*_([{]+/, '')
673
+ .replace(/[`*_\])}.:,;]+$/, '');
674
+ try {
675
+ const id = parsePhaseId(candidate);
676
+ const sub = id.subphase ? `.${id.subphase}` : '';
677
+ const canonicalDash = `${id.project}.${id.milestone}-${id.phase}${sub}`;
678
+ if (id.plan || candidate !== canonicalDash)
679
+ continue;
680
+ found.push({
681
+ kind: 'identity',
682
+ start: chunk.index ?? 0,
683
+ end: (chunk.index ?? 0) + candidate.length,
684
+ token: renderPhaseId(id),
685
+ });
686
+ }
687
+ catch {
688
+ // Most prose chunks are not identities. parsePhaseId is the classifier.
689
+ }
690
+ }
691
+ const trimmed = input.trim();
692
+ if (new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i').test(trimmed)) {
693
+ const start = input.indexOf(trimmed);
694
+ found.push({ kind: 'bare', start, end: start + trimmed.length, token: trimmed });
695
+ }
696
+ const identityAware = found.filter(({ kind, start, end }) => {
697
+ if (kind !== 'legacy')
698
+ return true;
699
+ return !qualifiedTokenSpans.some((span) => start === span.start && end === span.end);
700
+ });
701
+ identityAware.sort((a, b) => a.start - b.start);
702
+ return identityAware;
703
+ }
704
+ function extractPhaseDependencyTokens(prose, convention) {
705
+ const found = tokenizePhaseDependencyReferences(prose, convention);
706
+ const seen = new Set();
707
+ return found.flatMap(({ token }) => {
708
+ const key = token.toUpperCase();
709
+ if (seen.has(key))
710
+ return [];
711
+ seen.add(key);
712
+ return [token];
713
+ });
714
+ }
492
715
  function renderMilestoneId(id) {
493
716
  return `[${id.project}.${id.milestone}]`;
494
717
  }
@@ -660,7 +883,10 @@ function isSentinelPhaseDir(dirName) {
660
883
  * Render a regex source fragment matching a phase number against ROADMAP/STATE
661
884
  * prose regardless of zero-padding on either side.
662
885
  */
663
- function phaseMarkdownRegexSource(phaseNum) {
886
+ function phaseMarkdownRegexSource(phaseNum, context) {
887
+ const custom = context && customPhaseQuery(String(phaseNum), context);
888
+ if (custom)
889
+ return (0, pattern_cjs_1.escapeRegex)(custom.id);
664
890
  const stripped = stripProjectCodePrefix(phaseNum);
665
891
  // Milestone-prefixed IDs: M-NN or M-N-N (deep).
666
892
  const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
@@ -698,7 +924,33 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
698
924
  return null;
699
925
  return (0, pattern_cjs_1.escapeRegex)(raw);
700
926
  }
701
- function comparePhaseNum(a, b) {
927
+ function comparePhaseNum(a, b, context) {
928
+ if (context?.phaseNaming === 'custom') {
929
+ const index = customIdentityIndex(context);
930
+ const identity = (value) => {
931
+ const text = String(value);
932
+ if (index.recorded.has(text))
933
+ return text;
934
+ const query = customPhaseQuery(text, context);
935
+ return query?.recordedId ?? (query ? (query.listed ? query.id : customDirectoryOwner(text, query)?.id ?? query.id) : text);
936
+ };
937
+ const first = identity(a), second = identity(b);
938
+ // Arbitrary custom IDs have no numeric ordering: use their declared order.
939
+ // The relation must stay a total preorder for every `.sort` it feeds, so
940
+ // once a project declares a custom ID every declared ID sorts by position
941
+ // and ahead of every undeclared one; undeclared IDs (including unlisted
942
+ // numeric directories) keep the original comparator among themselves.
943
+ // A project with only legacy numeric IDs never enters this branch.
944
+ if (index.hasCustom) {
945
+ const left = index.orderByFold.get(recordedPhaseKey(first)), right = index.orderByFold.get(recordedPhaseKey(second));
946
+ if (left !== undefined && right !== undefined)
947
+ return left - right;
948
+ if (left !== undefined)
949
+ return -1;
950
+ if (right !== undefined)
951
+ return 1;
952
+ }
953
+ }
702
954
  // Strip optional project_code prefix before comparing
703
955
  const sa = stripProjectCodePrefix(a);
704
956
  const sb = stripProjectCodePrefix(b);
@@ -1292,6 +1544,223 @@ const LEADING_DIGIT_RUN_PREFIX_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}`);
1292
1544
  const BARE_INTEGER_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}$`);
1293
1545
  /** Strip leading zeros for numeric-equality compare, keeping a lone "0". */
1294
1546
  const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
1547
+ /** #5186: the directory shape shared by phase.add and custom-ID readers. */
1548
+ const UNSAFE_PHASE_DIRECTORY_PART_RE = /[/\\\0]|\.\./;
1549
+ class PhaseDirectoryNameError extends Error {
1550
+ kind;
1551
+ constructor(message, kind = 'invalid') {
1552
+ super(message);
1553
+ this.kind = kind;
1554
+ }
1555
+ }
1556
+ function phaseDirectoryName(id, slug, projectCode) {
1557
+ if (!id || [id, slug, projectCode ?? ''].some(part => UNSAFE_PHASE_DIRECTORY_PART_RE.test(part))) {
1558
+ throw new PhaseDirectoryNameError('Invalid phase id or directory component: paths, .. and NUL are not allowed');
1559
+ }
1560
+ return `${projectCode ? `${projectCode}-` : ''}${id}-${slug}`;
1561
+ }
1562
+ /** Grammar required only when writing an arbitrary custom identity. */
1563
+ function validateCustomPhaseId(id) {
1564
+ if (!id || id !== id.trim() || /[\s:]/.test(id) || id.startsWith('[')) {
1565
+ throw new PhaseDirectoryNameError('Invalid custom phase id: empty IDs, whitespace, colons and a leading [ are not allowed');
1566
+ }
1567
+ }
1568
+ /** Keep numeric, letter-suffixed and bracket queries on their legacy path. */
1569
+ const LEGACY_PHASE_ID_RE = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}(?:-\\d+)*$`, 'i');
1570
+ function isCustomPhaseId(id) {
1571
+ return !LEGACY_PHASE_ID_RE.test(id)
1572
+ && !id.startsWith('[');
1573
+ }
1574
+ /** Preserve every legacy segment while applying its normal padding rules. */
1575
+ function recordedPhaseKey(id) {
1576
+ // Whole-token normalization can discard a letter-suffixed continuation
1577
+ // (2A-2). Normalize its segments independently so it never aliases 2A.
1578
+ return (LEGACY_PHASE_ID_RE.test(id) ? id.split('-').map(normalizePhaseName).join('-') : id).toLowerCase();
1579
+ }
1580
+ const CUSTOM_PHASE_ID_FILE = '.phase-id';
1581
+ /** Naming policy is supplied by the config owner; grammar remains pure. */
1582
+ function phaseDirectoryContext(config, roadmap = '', knownIds = []) {
1583
+ return { phaseNaming: typeof config.phase_naming === 'string' ? config.phase_naming.toLowerCase() : config.phase_naming, projectCode: config.project_code ?? null, roadmap, knownIds };
1584
+ }
1585
+ /** Custom scans reuse the numeric source verbatim outside configured custom projects. */
1586
+ function phaseScanTokenSource(context) {
1587
+ return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : PHASE_NUMBER_TOKEN_SOURCE;
1588
+ }
1589
+ const customIdentityIndexes = new WeakMap();
1590
+ function customIdentityIndex(options) {
1591
+ let index = customIdentityIndexes.get(options);
1592
+ if (!index || index.roadmap !== options.roadmap || index.knownIds !== options.knownIds) {
1593
+ const headingId = /^Phase[ \t]+([^ \t:\n]+)(?:[ \t]+\([^)\n]*\))?[ \t]*:/;
1594
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(options.roadmap ?? '')
1595
+ .filter(heading => heading.level >= 2 && heading.level <= 4)
1596
+ .flatMap(heading => heading.text.match(headingId)?.[1] ?? []);
1597
+ const recorded = new Set(Object.values(options.directoryIds ?? {}));
1598
+ const recordedByFold = new Map();
1599
+ for (const id of recorded) {
1600
+ const key = recordedPhaseKey(id);
1601
+ const aliases = recordedByFold.get(key);
1602
+ if (aliases)
1603
+ aliases.push(id);
1604
+ else
1605
+ recordedByFold.set(key, [id]);
1606
+ }
1607
+ const ids = new Set([...headings, ...options.knownIds ?? [], ...recorded]);
1608
+ index = {
1609
+ roadmap: options.roadmap, knownIds: options.knownIds, explicitIds: new Set(options.knownIds ?? []), ids, recorded, recordedByFold, order: new Map([...ids].map((id, i) => [id, i])),
1610
+ // Declared position under the same fold the writers use, so `02` and `2` share a slot.
1611
+ orderByFold: [...ids].reduce((fold, id, i) => fold.has(recordedPhaseKey(id)) ? fold : fold.set(recordedPhaseKey(id), i), new Map()),
1612
+ hasCustom: recorded.size > 0 || [...ids].some(isCustomPhaseId),
1613
+ prefixes: [...ids].filter(token => token && !UNSAFE_PHASE_DIRECTORY_PART_RE.test(token))
1614
+ .sort((a, b) => b.length - a.length)
1615
+ .map(token => ({ id: token, prefix: phaseDirectoryName(token, '') })),
1616
+ };
1617
+ customIdentityIndexes.set(options, index);
1618
+ }
1619
+ return index;
1620
+ }
1621
+ /** Preflight every custom writer, including all IDs in a batch, before writes. */
1622
+ function validateCustomPhaseIds(ids, context) {
1623
+ const index = customIdentityIndex(context);
1624
+ const known = new Set(index.ids);
1625
+ const prospective = { ...context, knownIds: [...context.knownIds ?? [], ...ids] };
1626
+ for (const id of ids) {
1627
+ validateCustomPhaseId(id);
1628
+ // The same sentinel grammar filters phases list and roadmap.analyze.
1629
+ // Refuse identities those readers would hide rather than creating orphans.
1630
+ if (isSentinelPhaseId(id)) {
1631
+ throw new PhaseDirectoryNameError(`Custom ID ${id} is reserved for backlog or icebox phases and would be hidden by phase readers; choose a visible ID`, 'collision');
1632
+ }
1633
+ if (context.projectCode && id.toLowerCase().startsWith(`${context.projectCode}-`.toLowerCase())) {
1634
+ throw new PhaseDirectoryNameError(`Custom ID ${id} already begins with project_code ${context.projectCode}; pass the ID without its project prefix`);
1635
+ }
1636
+ const collision = [...known].find(other => recordedPhaseKey(id) === recordedPhaseKey(other));
1637
+ if (collision) {
1638
+ throw new PhaseDirectoryNameError(`Custom ID ${id} collides with existing or pending ID ${collision}; choose a distinct ID`, 'collision');
1639
+ }
1640
+ for (const dir of context.unmarkedDirectories ?? []) {
1641
+ const candidates = customDirectoryCandidates(dir, prospective);
1642
+ if (candidates.some(candidate => recordedPhaseKey(candidate) === recordedPhaseKey(id))) {
1643
+ throw new PhaseDirectoryNameError(`Custom ID ${id} would claim markerless directory ${dir}; candidates: ${candidates.join(', ')}. Write the owning ID into ${dir}/${CUSTOM_PHASE_ID_FILE} and retry`, 'collision');
1644
+ }
1645
+ }
1646
+ known.add(id);
1647
+ }
1648
+ }
1649
+ /** Custom heading IDs are identity hints only; titles never locate directories. */
1650
+ function customPhaseQuery(id, options) {
1651
+ if (options?.phaseNaming !== 'custom' || id.startsWith('['))
1652
+ return null;
1653
+ if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(id))
1654
+ return null;
1655
+ const index = customIdentityIndex(options);
1656
+ const prefix = options.projectCode ? `${options.projectCode}-` : '';
1657
+ const queryBody = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
1658
+ // Markers are authoritative; only legacy-shaped IDs share their ordinary
1659
+ // numeric padding normalization. Resolve recorded IDs before interpreting a
1660
+ // query as a basename: an ID may equal a different directory's full name.
1661
+ const aliases = index.recordedByFold.get(recordedPhaseKey(queryBody)) ?? [];
1662
+ let recorded = index.recorded.has(id) ? id : index.recorded.has(queryBody) ? queryBody : aliases.length === 1 ? aliases[0] : undefined;
1663
+ if (recorded !== undefined) {
1664
+ id = recorded;
1665
+ }
1666
+ else {
1667
+ const directoryId = options.directoryIds?.[id]
1668
+ ?? (options.unmarkedDirectories?.includes(id) ? customDirectoryOwner(id, {
1669
+ id, listed: false, projectCode: options.projectCode, prefixes: index.prefixes,
1670
+ })?.id : undefined);
1671
+ if (directoryId !== undefined) {
1672
+ id = directoryId;
1673
+ if (index.recorded.has(id))
1674
+ recorded = id;
1675
+ }
1676
+ }
1677
+ const body = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
1678
+ const listed = index.ids.has(id);
1679
+ // Legacy numeric tokens (including configured display prefixes) keep their
1680
+ // reading unless a writer explicitly recorded this exact ID. That record
1681
+ // also distinguishes an unpadded 2A from the custom continuation 2A-2.
1682
+ if (!index.recorded.has(id) && !body.includes('-') && LEGACY_PHASE_ID_RE.test(body))
1683
+ return null;
1684
+ if (!index.recorded.has(id) && !isCustomPhaseId(id) && !(listed && /[a-z]/i.test(id)))
1685
+ return null;
1686
+ // Only a complete legacy segment identifies a numeric directory. Digit-leading
1687
+ // custom IDs such as 2FA must not collapse to their leading digit run.
1688
+ if (!listed && !options.directoryIds?.[id] && body.includes('-') && LEGACY_PHASE_ID_RE.test(body.split('-')[0]))
1689
+ return null;
1690
+ // Listed identities outrank the query; a directory's slug never invents an ID.
1691
+ return {
1692
+ id, listed: listed, recordedId: recorded, projectCode: options.projectCode, directoryIds: options.directoryIds,
1693
+ prefixes: listed ? index.prefixes : [...index.prefixes, { id, prefix: phaseDirectoryName(id, '') }],
1694
+ };
1695
+ }
1696
+ /** Candidate project prefixes for the custom directory shape. */
1697
+ function customDirectoryBodies(dir, projectCode) {
1698
+ const bodies = [dir];
1699
+ if (projectCode) {
1700
+ const prefix = `${projectCode}-`;
1701
+ if (dir.startsWith(prefix))
1702
+ bodies.unshift(dir.slice(prefix.length));
1703
+ }
1704
+ return bodies;
1705
+ }
1706
+ /** Known IDs and writer-shaped prefixes are computed once per query. */
1707
+ function customDirectoryOwner(dir, query) {
1708
+ // A writer-recorded identity survives heading edits and archival. A slug
1709
+ // cannot prove where an arbitrary hyphenated ID ends. Legacy directories
1710
+ // keep heading-assisted read compatibility; destructive callers require proof.
1711
+ const recordedId = query.directoryIds?.[dir];
1712
+ if (recordedId !== undefined) {
1713
+ if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(recordedId))
1714
+ return null;
1715
+ const prefixes = [phaseDirectoryName(recordedId, '')];
1716
+ // Scaffold retains legacy numeric directory padding while recording the
1717
+ // requested ID. Resolve its directory shape independently of the query
1718
+ // normalization above; a digit-leading custom ID still owns its full token.
1719
+ if (LEGACY_PHASE_ID_RE.test(recordedId))
1720
+ prefixes.push(phaseDirectoryName(normalizePhaseName(recordedId), ''));
1721
+ for (const prefix of prefixes) {
1722
+ const body = customDirectoryBodies(dir, query.projectCode).find(body => body.startsWith(prefix));
1723
+ if (body)
1724
+ return { id: recordedId, slug: body.slice(prefix.length) };
1725
+ }
1726
+ return null;
1727
+ }
1728
+ for (const body of customDirectoryBodies(dir, query.projectCode)) {
1729
+ const owner = query.prefixes.find(candidate => body.startsWith(candidate.prefix));
1730
+ if (owner)
1731
+ return { id: owner.id, slug: body.slice(owner.prefix.length) };
1732
+ }
1733
+ return null;
1734
+ }
1735
+ /** All plausible owners of a markerless directory, for destructive preflight. */
1736
+ function customDirectoryCandidates(dir, context) {
1737
+ if (context.phaseNaming !== 'custom')
1738
+ return [];
1739
+ const recorded = context.directoryIds?.[dir];
1740
+ if (recorded !== undefined) {
1741
+ if (!recorded || UNSAFE_PHASE_DIRECTORY_PART_RE.test(recorded))
1742
+ return [];
1743
+ const owner = customDirectoryOwner(dir, {
1744
+ id: recorded, listed: true, projectCode: context.projectCode,
1745
+ directoryIds: context.directoryIds, prefixes: [],
1746
+ });
1747
+ return owner ? [owner.id] : [];
1748
+ }
1749
+ const index = customIdentityIndex(context);
1750
+ const candidates = [...new Set(customDirectoryBodies(dir, context.projectCode).flatMap(body => index.prefixes.filter(candidate => body.startsWith(candidate.prefix)).map(candidate => candidate.id)))];
1751
+ // A lone legacy numeric heading is not proof of a custom identity. Preserve
1752
+ // all candidates when ownership is ambiguous, and explicit recorded hints.
1753
+ if (candidates.length === 1 && LEGACY_PHASE_ID_RE.test(candidates[0])
1754
+ && !candidates[0].includes('-') && !index.explicitIds.has(candidates[0]))
1755
+ return [];
1756
+ return candidates;
1757
+ }
1758
+ function customPhaseDirectoryParts(dir, query) {
1759
+ const owner = customDirectoryOwner(dir, query);
1760
+ if (query.recordedId !== undefined)
1761
+ return owner?.id === query.recordedId ? owner : null;
1762
+ return owner?.id.toLowerCase() === query.id.toLowerCase() ? owner : null;
1763
+ }
1295
1764
  /**
1296
1765
  * #2528: the CANONICAL phase-directory match selection — the one rule every
1297
1766
  * directory-resolution path (the shared locator plus the `find-phase` and
@@ -1299,7 +1768,11 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
1299
1768
  * here because the surrounding scan/ambiguity/shaping code exists per site and
1300
1769
  * had already diverged; the selection itself must not.
1301
1770
  *
1302
- * Two passes:
1771
+ * Readers supply their naming context; legacy direct callers default to the
1772
+ * context-free numeric policy. Configured custom IDs
1773
+ * resolve through the inverse of phaseDirectoryName before normalization can
1774
+ * collapse an ID such as A-2 into a numeric query. Other queries retain the
1775
+ * legacy two passes:
1303
1776
  * 1. PRIMARY — exact token match (`phaseTokenMatches`), unchanged behavior.
1304
1777
  * 2. BARE-INTEGER FALLBACK — only when the primary pass matched NOTHING and
1305
1778
  * the query is a bare integer, re-filter by each directory's own LEADING
@@ -1343,31 +1816,95 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
1343
1816
  *
1344
1817
  * TAKE `matches[0]` — `cmdPhasesList`, `cmdInitManager`, `cmdRoadmapAnalyze`,
1345
1818
  * the schema-drift gate (`resolvePhaseDirByToken`), `detectVerifyFailed`. Each read a directory to
1346
- * DECORATE a row they are already emitting; each used `.find()` before this
1347
- * PR, so first-match is their prior behavior preserved verbatim, and each is
1348
- * order-stable because the directory list is sorted and this function filters
1349
- * without reordering.
1819
+ * DECORATE a row they are already emitting. In bracket mode those current-
1820
+ * checkout callers pass the active project+milestone context: an exact
1821
+ * qualified match wins, and bracket directories from other milestones are
1822
+ * excluded before any first-match policy can run. Legacy selection order is
1823
+ * unchanged.
1350
1824
  *
1351
- * The honest caveat on that second tier: the bare-number fallback makes
1352
- * multi-match newly REACHABLE for inputs that previously found nothing, so those
1353
- * five can now silently pick one of several candidates where they used to report
1354
- * not-found. That is a widening of an existing first-match rule, not a new rule
1355
- * — but it is a widening, and promoting any of them to refusal is a UX decision
1356
- * about their own output, not a change to selection, so it does not belong here.
1825
+ * Without an active bracket context the historical first-match/ambiguity policy
1826
+ * remains the caller's responsibility. With a context, only migration-window
1827
+ * legacy names can participate in fallback when the exact bracket identity is
1828
+ * absent; a prior milestone's qualified directory can never mask the active one.
1357
1829
  *
1358
1830
  * `usedBareFallback` tells callers to derive the displayed phase number from
1359
1831
  * the directory's leading digit run instead of `extractPhaseToken` (whose
1360
1832
  * token for these dirs is the mis-absorbed multi-segment form).
1361
1833
  */
1362
- function matchPhaseDirs(dirs, normalized, convention) {
1363
- const primary = dirs.filter(d => phaseTokenMatches(d, normalized, convention));
1834
+ /**
1835
+ * The query a directory lookup hands to `matchPhaseDirs`. Custom projects pass
1836
+ * the id as written (their ids have no padding form); every other convention
1837
+ * keeps the normalized token callers always supplied.
1838
+ */
1839
+ function phaseLookupQuery(query, context) {
1840
+ return context?.phaseNaming === 'custom' ? query : normalizePhaseName(query);
1841
+ }
1842
+ function matchPhaseDirs(dirs, query, convention, bracketContext, context = phaseDirectoryContext({})) {
1843
+ const custom = convention === 'bracket' ? null : customPhaseQuery(query, context);
1844
+ if (custom?.recordedId !== undefined) {
1845
+ return { matches: dirs.filter(dir => customPhaseDirectoryParts(dir, custom) !== null), usedBareFallback: false };
1846
+ }
1847
+ // A complete basename names that physical directory when no record owns the
1848
+ // query. Custom projects only: a numeric project with `01` and `01-foo` must
1849
+ // still reach the #2237 ambiguity refusal for the bare token `01`.
1850
+ if (context.phaseNaming === 'custom' && convention !== 'bracket') {
1851
+ const directoryMatches = dirs.filter(dir => dir === query);
1852
+ if (directoryMatches.length > 0)
1853
+ return { matches: directoryMatches, usedBareFallback: false };
1854
+ }
1855
+ // Bracket queries arrive canonical from the lookup boundary; every other
1856
+ // convention normalizes here (custom ids keep their literal spelling below).
1857
+ const normalized = convention === 'bracket' ? query : normalizePhaseName(query);
1858
+ // Recorded directories never enter legacy token or bare-number fallback for
1859
+ // another query. Keep those compatibility passes for unmarked directories.
1860
+ if (context.phaseNaming === 'custom' && convention !== 'bracket') {
1861
+ dirs = dirs.filter(dir => context.directoryIds?.[dir] === undefined
1862
+ || (custom !== null && customPhaseDirectoryParts(dir, custom) !== null));
1863
+ }
1864
+ if (custom) {
1865
+ // A full directory name is an existing lookup form, distinct from an ID prefix.
1866
+ const exact = dirs.filter(d => d === custom.id);
1867
+ if (exact.length > 0)
1868
+ return { matches: exact, usedBareFallback: false };
1869
+ const matches = dirs.filter(d => customPhaseDirectoryParts(d, custom) !== null);
1870
+ if (matches.length > 0 || isCustomPhaseId(query))
1871
+ return { matches, usedBareFallback: false };
1872
+ }
1873
+ // Custom writers preserve an explicit ID's spelling, including an unpadded
1874
+ // letter suffix (7B). Prefer that literal token before the legacy padded form.
1875
+ if (context?.phaseNaming === 'custom' && convention !== 'bracket' && query !== normalized) {
1876
+ const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
1877
+ if (literal.length > 0)
1878
+ return { matches: literal, usedBareFallback: false };
1879
+ }
1880
+ let candidates = dirs;
1881
+ // #4304: a bare phase number from STATE/ROADMAP names the active bracket
1882
+ // identity, not every physical directory left behind by prior milestones.
1883
+ // Prefer the exact qualified directory. If it is absent, retain only
1884
+ // migration-window legacy names; a differently-qualified bracket directory
1885
+ // must never win merely because it sorts first.
1886
+ if (convention === 'bracket' && bracketContext && !bracketQualifiedKey(normalized, convention)) {
1887
+ const qualified = `${bracketContext.project}.${bracketContext.milestone}-${normalized}`;
1888
+ const exact = dirs.filter(d => phaseTokenMatches(d, qualified, convention));
1889
+ if (exact.length > 0)
1890
+ return { matches: exact, usedBareFallback: false };
1891
+ candidates = dirs.filter(d => bracketQualifiedKey(d, convention) === null);
1892
+ }
1893
+ const primary = candidates.filter(d => phaseTokenMatches(d, normalized, convention));
1364
1894
  if (primary.length > 0)
1365
1895
  return { matches: primary, usedBareFallback: false };
1896
+ // Normalization pads a literal continuation ID such as 0-00 to 00-00.
1897
+ // Preserve its exact-token lookup before falling back to a bare integer.
1898
+ if (query !== normalized && query.includes('-')) {
1899
+ const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
1900
+ if (literal.length > 0)
1901
+ return { matches: literal, usedBareFallback: false };
1902
+ }
1366
1903
  const bare = String(normalized);
1367
1904
  if (!BARE_INTEGER_RE.test(bare))
1368
1905
  return { matches: primary, usedBareFallback: false };
1369
1906
  const want = unpad(bare);
1370
- const fallback = dirs.filter(d => {
1907
+ const fallback = candidates.filter(d => {
1371
1908
  const m = stripProjectCodePrefix(d).match(LEADING_DIGIT_RUN_RE);
1372
1909
  return m !== null && unpad(m[1]) === want;
1373
1910
  });
@@ -1379,9 +1916,9 @@ function matchPhaseDirs(dirs, normalized, convention) {
1379
1916
  * directory's leading digit run (the whole point of the fallback is that the
1380
1917
  * extracted token is wrong for these dirs).
1381
1918
  */
1382
- function phaseNumberForMatch(dirName, usedBareFallback) {
1919
+ function phaseNumberForMatch(dirName, usedBareFallback, convention) {
1383
1920
  if (!usedBareFallback)
1384
- return extractPhaseToken(dirName);
1921
+ return extractPhaseToken(dirName, convention);
1385
1922
  const stripped = stripProjectCodePrefix(dirName);
1386
1923
  const prefix = dirName.slice(0, dirName.length - stripped.length);
1387
1924
  const m = stripped.match(LEADING_DIGIT_RUN_PREFIX_RE);
@@ -1410,7 +1947,9 @@ function phaseNumberForMatch(dirName, usedBareFallback) {
1410
1947
  * of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment.
1411
1948
  * Arithmetic is avoided (`parseInt` would lose precision on a long digit run).
1412
1949
  */
1413
- function phaseKeyFromToken(token) {
1950
+ function phaseKeyFromToken(token, context) {
1951
+ if (context && customPhaseQuery(String(token), context))
1952
+ return String(token).toUpperCase();
1414
1953
  const stripped = String(token)
1415
1954
  .split('-')
1416
1955
  .map(segment => segment.replace(/^0+(?=\d)/, ''))
@@ -1430,8 +1969,16 @@ function phaseKeyFromToken(token) {
1430
1969
  * #2562's own defect class reached from the other side: both sides of a comparison
1431
1970
  * must be derived not merely by the same function but under the same convention.
1432
1971
  */
1433
- function phaseKeyFromDir(dirName, convention) {
1434
- return phaseKeyFromToken(extractPhaseToken(dirName, convention));
1972
+ function phaseKeyFromDir(dirName, convention, context) {
1973
+ if (context && convention !== 'bracket') {
1974
+ const custom = customPhaseQuery(dirName, context);
1975
+ if (custom) {
1976
+ const owner = customDirectoryOwner(dirName, custom);
1977
+ if (owner)
1978
+ return phaseKeyFromToken(owner.id, context);
1979
+ }
1980
+ }
1981
+ return phaseKeyFromToken(extractPhaseToken(dirName, convention), context);
1435
1982
  }
1436
1983
  /**
1437
1984
  * Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table
@@ -1440,11 +1987,12 @@ function phaseKeyFromDir(dirName, convention) {
1440
1987
  * mistaken for a non-phase. Returns null when the value does not BEGIN with a
1441
1988
  * phase token (`parsePhaseFromProse` anchoring, #2111).
1442
1989
  */
1443
- function phaseKeyFromProse(value) {
1990
+ function phaseKeyFromProse(value, context) {
1444
1991
  if (value == null)
1445
1992
  return null;
1446
- const { phase } = parsePhaseFromProse(String(value).replace(/[*_`~]/g, ''));
1447
- return phase === null ? null : phaseKeyFromToken(phase);
1993
+ const clean = String(value).replace(/[*_`~]/g, '');
1994
+ const { phase } = parsePhaseFromProse(clean, undefined, context);
1995
+ return phase === null ? null : phaseKeyFromToken(phase, context);
1448
1996
  }
1449
1997
  /**
1450
1998
  * The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a
@@ -1470,13 +2018,34 @@ function parentPhaseKey(key) {
1470
2018
  * `{ phase: null }` because they do not begin with a phase token. The name
1471
2019
  * extraction (parenthetical or em-dash tail, minus status words) is unchanged.
1472
2020
  */
1473
- function parsePhaseFromProse(value) {
2021
+ /**
2022
+ * True when `value` is the planned-phase writer's own status phrase
2023
+ * (`READY TO EXECUTE`), which can sit in the name position of a `Phase:` line but is
2024
+ * never a phase name (#5369). The one owner of that phrase: the prose parser and the
2025
+ * planned-phase writers both ask here.
2026
+ */
2027
+ function isPlannedPhaseStatusPhrase(value) {
2028
+ return typeof value === 'string' && /^ready to execute$/i.test(value.trim());
2029
+ }
2030
+ // The leading phase id of a prose field. #5626: under `milestone-prefixed` the id is the whole `M-NN`
2031
+ // (`3-01`), so the number capture takes one optional `-NN` segment; every other convention (and an absent
2032
+ // one) keeps the dotted-only capture, where `3-01` reads as phase `3` exactly as before.
2033
+ const PHASE_PROSE_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i;
2034
+ const PHASE_PROSE_MILESTONE_PREFIXED_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+(?:-\d+)?[A-Z]?(?:\.\d+)*)\b/i;
2035
+ function parsePhaseFromProse(value, convention, context) {
1474
2036
  if (!value)
1475
2037
  return { phase: null, name: null };
1476
2038
  // Coerce defensively so a non-string caller cannot throw on this canonical
1477
2039
  // surface (mirrors the sibling #2121 functions' String(...) handling).
1478
2040
  const str = String(value);
1479
- const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
2041
+ const candidate = context?.phaseNaming === 'custom'
2042
+ ? str.trim().replace(/^Phase[ \t]+/i, '').split(/[ \t:]/, 1)[0].replace(/\.$/, '')
2043
+ : null;
2044
+ const query = candidate && context && customPhaseQuery(candidate, context);
2045
+ // Only an identity a writer recorded or the roadmap lists is a phase id; an
2046
+ // ordinary word in the field (`complete`, `None`, `TBD`) is not one.
2047
+ const custom = query && (query.listed || query.recordedId !== undefined);
2048
+ const phaseMatch = str.match(convention === 'milestone-prefixed' ? PHASE_PROSE_MILESTONE_PREFIXED_RE : PHASE_PROSE_RE);
1480
2049
  // The name-extraction quantifiers are length-bounded so a crafted long
1481
2050
  // unterminated run (many `(` or `—`) in an untrusted STATE.md field value
1482
2051
  // cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
@@ -1501,7 +2070,9 @@ function parsePhaseFromProse(value) {
1501
2070
  // The precedence-decision vocabulary is deliberately broader than the final
1502
2071
  // name-nulling filter below: a dash tail that merely LOOKS like a status
1503
2072
  // annotation should lose to a parenthetical name, without changing which
1504
- // extracted names are nulled (that set stays the long-standing three).
2073
+ // extracted names are nulled (that set is the long-standing three plus the
2074
+ // planned-phase writer's own `READY TO EXECUTE`, #5369: with no parenthetical
2075
+ // name to prefer it was otherwise harvested as the phase name).
1505
2076
  const STATUS_WORD_RE = /^(?:complete|executing|not started)$/i;
1506
2077
  const STATUSY_TAIL_RE = /^(?:completed?|executing|not started|planning|planned|ready(?:\s+to\s+\S.{0,50})?|done|in progress|blocked|paused|verifying)$/i;
1507
2078
  const dashRaw = dashName?.[1]?.trim() ?? null;
@@ -1513,11 +2084,11 @@ function parsePhaseFromProse(value) {
1513
2084
  // `(Name) — STATUS` shape); with no parenthetical it stays the best guess.
1514
2085
  && !(parenName && /^[A-Z][A-Z0-9_-]*$/.test(dashRaw));
1515
2086
  const rawName = dashIsName ? dashRaw : (parenName?.[1] ?? dashRaw ?? null);
1516
- const name = rawName && !STATUS_WORD_RE.test(rawName.trim())
2087
+ const name = rawName && !STATUS_WORD_RE.test(rawName.trim()) && !isPlannedPhaseStatusPhrase(rawName)
1517
2088
  ? rawName.trim()
1518
2089
  : null;
1519
2090
  return {
1520
- phase: phaseMatch ? phaseMatch[1] : null,
2091
+ phase: custom ? candidate : phaseMatch ? phaseMatch[1] : null,
1521
2092
  name,
1522
2093
  };
1523
2094
  }
@@ -1631,11 +2202,13 @@ function roadmapPhaseLookupSources(phaseNum) {
1631
2202
  sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
1632
2203
  return [...new Set(sources)];
1633
2204
  }
1634
- module.exports = {
2205
+ const phaseIdExports = {
1635
2206
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
1636
2207
  OPTIONAL_PHASE_TAG_SOURCE,
1637
2208
  PHASE_NUMBER_TOKEN_SOURCE,
1638
2209
  PHASE_DEP_REF_SOURCE,
2210
+ tokenizePhaseDependencyReferences,
2211
+ extractPhaseDependencyTokens,
1639
2212
  CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
1640
2213
  CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
1641
2214
  PHASE_CONTINUATION_SEGMENT_SOURCE,
@@ -1652,7 +2225,10 @@ module.exports = {
1652
2225
  BASE_PHASE_LABEL_PREFIX_SRC,
1653
2226
  PHASE_HEADING_BASELINE,
1654
2227
  phaseHeadingPrefixSrcFor,
2228
+ parsePhaseChecklistLine,
2229
+ bracketQualifiedIntroSrcFor,
1655
2230
  buildPhaseHeadingScanRegex,
2231
+ buildPhaseDeclarationScans,
1656
2232
  foldBracketId,
1657
2233
  bracketQualifiedKey,
1658
2234
  stripProjectCodePrefix,
@@ -1676,14 +2252,29 @@ module.exports = {
1676
2252
  scopeToPhase,
1677
2253
  phaseTokenMatches,
1678
2254
  matchPhaseDirs,
2255
+ phaseLookupQuery,
2256
+ phaseDirectoryContext,
2257
+ phaseScanTokenSource,
2258
+ phaseDirectoryName,
2259
+ validateCustomPhaseId,
2260
+ validateCustomPhaseIds,
2261
+ PhaseDirectoryNameError,
2262
+ customPhaseQuery,
2263
+ customPhaseDirectoryParts,
2264
+ customDirectoryOwner,
2265
+ customDirectoryCandidates,
2266
+ CUSTOM_PHASE_ID_FILE,
2267
+ isCustomPhaseId,
1679
2268
  phaseNumberForMatch,
1680
2269
  phaseKeyFromToken,
1681
2270
  phaseKeyFromDir,
1682
2271
  phaseKeyFromProse,
1683
2272
  parentPhaseKey,
1684
2273
  parsePhaseFromProse,
2274
+ isPlannedPhaseStatusPhrase,
1685
2275
  stripConfiguredProjectCodePrefix,
1686
2276
  isForeignPrefixedPhaseQuery,
1687
2277
  roadmapPhaseLookupSources,
1688
2278
  renderPhaseBranchName,
1689
2279
  };
2280
+ module.exports = phaseIdExports;