@opengsd/gsd-core 1.10.0 → 1.12.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 (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -11,22 +11,28 @@
11
11
  *
12
12
  * Dependencies (leaf modules only — no loadConfig):
13
13
  * - node:fs / node:path (stdlib)
14
- * - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource)
14
+ * - ./phase-id.cjs (phaseMarkdownRegexSource)
15
+ * - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam)
15
16
  * - ./planning-workspace.cjs (planningDir)
16
17
  * - ./shell-command-projection.cjs (platformReadSync)
17
- * - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection)
18
+ * - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection, collectSection)
19
+ * - ./markdown-table.cjs (findTableWithColumns)
18
20
  */
19
21
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
22
  return (mod && mod.__esModule) ? mod : { "default": mod };
21
23
  };
22
24
  const node_fs_1 = __importDefault(require("node:fs"));
23
25
  const node_path_1 = __importDefault(require("node:path"));
26
+ const pattern_cjs_1 = require("./pattern.cjs");
24
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
28
  const phaseIdModule = require("./phase-id.cjs");
26
- const { escapeRegex, phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
29
+ const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
27
30
  // #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
28
31
  // the lookup-source ordering); imported here rather than defined locally.
29
- roadmapPhaseLookupSources, } = phaseIdModule;
32
+ roadmapPhaseLookupSources, extractPhaseToken, isSentinelPhaseId,
33
+ // #3641: the single-owner heading-intro and digit-token grammar sources —
34
+ // see BRACKET_PHASE_ENTRY_HEADING_RE below.
35
+ PHASE_HEADING_PREFIX_SRC, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdModule;
30
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
37
  const planningWorkspace = require("./planning-workspace.cjs");
32
38
  const { planningDir } = planningWorkspace;
@@ -35,6 +41,10 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
35
41
  const unusableInputMod = require("./unusable-input.cjs");
36
42
  const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
37
43
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
44
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const planningScopeMod = require("./planning-scope.cjs");
47
+ const { SCOPE } = planningScopeMod;
38
48
  // ─── Roadmap milestone scoping ───────────────────────────────────────────────
39
49
  /**
40
50
  * Markers that classify a MILESTONE HEADING (or `<summary>`) as closed/shipped
@@ -74,7 +84,7 @@ function stripShippedMilestones(content) {
74
84
  * linear in the ROADMAP's length — an untrusted ROADMAP cannot drive backtracking.
75
85
  */
76
86
  function isMilestoneShippedInRoadmap(content, version) {
77
- const boundedVersion = `${escapeRegex(version)}(?![\\w.-])`;
87
+ const boundedVersion = `${(0, pattern_cjs_1.escapeRegex)(version)}(?![\\w.-])`;
78
88
  const candidates = [
79
89
  // A milestone heading: `## v2.0 Launch — ✅ SHIPPED`.
80
90
  new RegExp(`^#{1,3}[^\\S\\n]+(?!Phase\\s+\\S)[^\\n]*${boundedVersion}[^\\n]*$`, 'gmi'),
@@ -90,7 +100,532 @@ function isMilestoneShippedInRoadmap(content, version) {
90
100
  return false;
91
101
  }
92
102
  /**
93
- * Extract the current milestone section from ROADMAP.md by positive lookup.
103
+ * #3184 (epic #3180 Phase 2): the sole owner of "where does this milestone
104
+ * heading's section end". Lifted from `currentMilestoneRawRanges`'s prior
105
+ * inline copy — the only one of three byte-identical copies that carried a
106
+ * "keep in sync" comment (evidence the risk was known, not controlled).
107
+ * `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`, and
108
+ * `getMilestonePhaseFilter`'s versionOverride branch all call this instead of
109
+ * re-deriving it.
110
+ */
111
+ function computeMilestoneSectionEnd(content, headingText, headingStart) {
112
+ const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
113
+ const afterHeading = headingStart + headingText.length;
114
+ // Use tokenizeHeadings (fence-aware, offsets into original content) to find
115
+ // the next stop boundary without re-implementing fence detection. T4 seam migration.
116
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
117
+ for (const h of headings) {
118
+ if (h.offset <= headingStart)
119
+ continue;
120
+ if (h.offset < afterHeading)
121
+ continue;
122
+ if (h.level > level)
123
+ continue;
124
+ // Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
125
+ if (/^Phase\s+\S/i.test(h.text))
126
+ continue;
127
+ if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
128
+ continue;
129
+ return h.offset;
130
+ }
131
+ return content.length;
132
+ }
133
+ /**
134
+ * #3216 (epic #3180 §7.2 Scope amendment): the version-AGNOSTIC sibling of
135
+ * `locateMilestoneHeadings` — enumerates EVERY milestone heading in document
136
+ * order, carrying its own version token, curated name, and shipped/closed
137
+ * status. Consolidates the THIRD independent re-derivation the widened guard
138
+ * found at `roadmap.cts:454` (`cmdRoadmapAnalyze`'s inline
139
+ * `/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`), which truncated names at a
140
+ * parenthetical and had no phase-heading exclusion.
141
+ *
142
+ * `MILESTONE_HEADING_LINE_SOURCE` immediately below is the ONE textual
143
+ * expression of the grammar `^#{1,3}\s+(?!Phase\s+\S)` in this file;
144
+ * `locateMilestoneHeadings` builds its own pattern from the SAME constant
145
+ * instead of re-typing the pattern text, so it is a version-FILTERED VIEW
146
+ * over this function's grammar, never a second expression of it.
147
+ *
148
+ * Name extraction follows the pinned rule (ADR-3180 §7.2 amendment, "Name
149
+ * extraction — pinned rule" via `extractMilestoneHeadingName`): strip
150
+ * everything through the heading's OWN version token — not necessarily one a
151
+ * caller is separately asking about — then ONE leading delimiter and
152
+ * surrounding whitespace via the shared `stripLeadingDelimiter`. `(` is an
153
+ * ordinary name character and is never a terminator (#3171). `getMilestoneInfo`
154
+ * shares this same extraction so the parenthetical rule has exactly one
155
+ * implementation.
156
+ */
157
+ function listMilestoneHeadings(content) {
158
+ const pattern = new RegExp(MILESTONE_HEADING_LINE_SOURCE, 'gmi');
159
+ const out = [];
160
+ let m;
161
+ while ((m = pattern.exec(content)) !== null) {
162
+ // #3216 review (Finding 4): the shared grammar's `[^\n]*` captures a
163
+ // trailing `\r` on a CRLF-encoded ROADMAP (the inline `cmdRoadmapAnalyze`
164
+ // regex this replaced called `.trim()`; this did not). `.trim()` here
165
+ // matches that prior behavior. `version` (digits/dots/letters only, via
166
+ // `extractMilestoneHeadingName`'s regex) and `name` (already run through
167
+ // `stripLeadingDelimiter`, which ends in `.trim()`) cannot carry a
168
+ // trailing `\r`, so only `heading` needs the fix.
169
+ //
170
+ // `heading` carries the heading text WITHOUT the leading `#{1,3}` run and
171
+ // its following whitespace — matching the inline `cmdRoadmapAnalyze`
172
+ // regex this function replaced (`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`,
173
+ // whose capture group 1 begins AFTER `##\s*`). `locateMilestoneHeadings`
174
+ // legitimately returns a DIFFERENT representation (`m[1]`, `#`s included)
175
+ // — the two owners agree on WHICH milestone headings are selected, not on
176
+ // raw heading text.
177
+ const heading = m[0].replace(/^#{1,3}\s+/, '').trim();
178
+ const extracted = extractMilestoneHeadingName(heading);
179
+ if (extracted === null)
180
+ continue; // no version token on this heading — not a milestone heading
181
+ out.push({
182
+ heading,
183
+ version: extracted.version,
184
+ name: extracted.name,
185
+ closed: isClosedMilestoneHeading(heading),
186
+ });
187
+ }
188
+ return out;
189
+ }
190
+ // #3216: the ONE textual expression of "level-bounded (h1-h3), phase-excluded
191
+ // heading line" in this file. `listMilestoneHeadings` and
192
+ // `locateMilestoneHeadings` both build their pattern from this constant
193
+ // rather than typing `^#{1,3}\s+(?!Phase\s+\S)` a second time — the exact
194
+ // duplication class ADR-3180 §7.2's widened guard exists to catch.
195
+ const MILESTONE_HEADING_LINE_SOURCE = '^#{1,3}\\s+(?!Phase\\s+\\S)[^\\n]*';
196
+ /**
197
+ * #3184: the sole milestone-heading locator. Boundary-matched on the version
198
+ * token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
199
+ * because a milestone STATE legitimately selects its own sub-milestone
200
+ * heading (`v8.0` matching `## v8.0-B …` — `0` is a word char, `-` is not, so
201
+ * `\b` matches) — that is deliberate, load-bearing behavior (#730). The
202
+ * stricter `(?![\w.-])` boundary answers a DIFFERENT question — "is exactly
203
+ * this milestone shipped" (`isMilestoneShippedInRoadmap`) / "which Phase
204
+ * Details section belongs to exactly this one's version token"
205
+ * (`detailsVersionBoundary`) — and applying it here breaks #730 sub-milestone
206
+ * selection. `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`,
207
+ * and `getMilestonePhaseFilter`'s versionOverride branch all consume this
208
+ * instead of re-deriving their own heading-location regex.
209
+ *
210
+ * #3216: rewritten as a version-FILTERED VIEW over `MILESTONE_HEADING_LINE_SOURCE`
211
+ * — the SAME grammar `listMilestoneHeadings` enumerates — rather than a
212
+ * second expression of it. The returned `RegExpExecArray[]` contract
213
+ * (`m[0] === m[1]`, `m.index` at the heading's start) is byte-for-byte
214
+ * unchanged, so its 4 existing callers are unaffected.
215
+ */
216
+ function locateMilestoneHeadings(content, version) {
217
+ const escapedVersion = (0, pattern_cjs_1.escapeRegex)(version);
218
+ // ADR-3180 §7.1 locks this boundary as `\b`, not the stricter
219
+ // `(?![\w.-])` — Amendment 2 tried the stricter boundary and reverted it.
220
+ // `\b` alone is what preserves the #730 sub-milestone selection this
221
+ // function owns: `v2.0` still matches inside `v2.0.1`, `v8.0` still
222
+ // matches `## v8.0-B …`.
223
+ const boundary = new RegExp(`${escapedVersion}\\b`, 'i');
224
+ const pattern = new RegExp(`(${MILESTONE_HEADING_LINE_SOURCE})`, 'gmi');
225
+ const matches = [];
226
+ let m;
227
+ while ((m = pattern.exec(content)) !== null) {
228
+ if (boundary.test(m[1]))
229
+ matches.push(m);
230
+ }
231
+ return matches;
232
+ }
233
+ /**
234
+ * #3184: named predicate replacing the two `state.cts` re-derivations
235
+ * (`buildStateFrontmatter`, `syncStateFrontmatter`) that each hand-rolled the
236
+ * same "is this version bounded to a versioned ROADMAP heading" regex. A
237
+ * straight consolidation of the two identical `state.cts` regexes onto the
238
+ * shared `locateMilestoneHeadings` owner — no behavior change.
239
+ */
240
+ function isMilestoneBoundedInRoadmap(content, version) {
241
+ return locateMilestoneHeadings(content, version).length > 0;
242
+ }
243
+ /**
244
+ * #3184: does this ROADMAP carry ANY versioned milestone heading (`v1.2`-style
245
+ * token on a level 1-3 non-Phase heading), independent of any particular
246
+ * version. `extractCurrentMilestoneScoped` (free-form-vs-versioned row 3/4
247
+ * classification) and `getMilestonePhaseFilter` (the deprecation warning +
248
+ * the same row 3/4 classification for its versionOverride branch) each
249
+ * hand-rolled this identically — the guard does not catch intra-owner-file
250
+ * copies by construction, so this was found by review instead.
251
+ */
252
+ function hasVersionedMilestones(content) {
253
+ return /^#{1,3}\s+.*v\d+\.\d+/mi.test(content);
254
+ }
255
+ // This file's milestone-heading vocabulary: a version token (`v1.2`-style),
256
+ // a ✅/🚧/📋 status marker, or the word "Milestone". Tested against a
257
+ // non-Phase heading's own text by `hasMilestoneSectioning` below — this
258
+ // module's sole owner of "is this heading a milestone heading".
259
+ const MILESTONE_HEADING_SIGNAL_PATTERN = /v\d+\.\d+|✅|📋|🚧|\bMilestone\b/i;
260
+ /**
261
+ * #3184/#3204/#2828/#1761/#3185: could a WHOLE-DOCUMENT phase count conflate
262
+ * two different milestones? That is the only question `buildStateFrontmatter`
263
+ * (`state.cts`) asks its single caller of this predicate.
264
+ *
265
+ * Three prior models were tried, and all three tried to infer milestone-ness
266
+ * from POSITION — where a heading sits relative to other headings — and all
267
+ * three broke a real shape because position does not carry it:
268
+ *
269
+ * 1. "Is there ANY non-Phase level-2/3 heading" (pre-#3184). #3204: a FLAT
270
+ * roadmap carrying one ordinary structural heading (`## Progress`) was
271
+ * misclassified as milestone-sectioned, and `safeToUseRoadmapCount`
272
+ * clobbered a correct ROADMAP-declared count down to the on-disk directory
273
+ * count. Not-Phase-ness was never the right question.
274
+ * 2. "Do >=2 non-Phase headings EACH own a nested (STRICTLY DEEPER) Phase
275
+ * heading" (#3184's rewrite). Two independent review findings broke this:
276
+ * (a) #1761 regression — real sibling milestones are commonly at the SAME
277
+ * level as their own Phase headings (`## v1.0` / `## Phase 1:` / `## v2.0`
278
+ * / `## Phase 3:`), so "strictly deeper" never matches for either sibling
279
+ * and the predicate answers false, letting the whole-document count
280
+ * conflate them exactly as #1761 did. (b) #3204 reintroduced — the
281
+ * bundled greenfield template itself (`gsd-core/templates/roadmap.md:149-171`:
282
+ * `## Phases` -> `### 🚧 v1.1 — …` -> `#### Phase 5: …`) nests a Phase
283
+ * heading arbitrarily deep under EVERY ancestor in the chain, so a
284
+ * generic wrapper heading ("Phases") with no milestone meaning of its own
285
+ * counted as its own candidate section and single-milestone documents
286
+ * were misclassified as sectioned again.
287
+ * 3. "Immediate adjacency, at any level" (interim #3185 rewrite, never
288
+ * shipped past this file's own working tree). Fixed both #3184 defects
289
+ * above, but adjacency is STILL a positional signal, and #3185 reproduced
290
+ * a THIRD shape it cannot see: a flat roadmap where `## Overview` happens
291
+ * to sit immediately before `## Phase 1:` and, independently, `## Notes`
292
+ * sits immediately before `## Phase 4:` later in the same document. Two
293
+ * purely structural headings, zero milestone meaning, each "adjacent" to a
294
+ * Phase heading by coincidence of document layout — ≥2 owners, so the
295
+ * flat 6-phase roadmap was misclassified as sectioned and clobbered to the
296
+ * 2 on-disk phase directories. Same root defect as #3204's `## Progress`,
297
+ * wearing a different heading shape.
298
+ *
299
+ * The model that actually holds for every shape above abandons position
300
+ * entirely and asks about the heading's own text: is it a MILESTONE HEADING —
301
+ * a non-Phase heading at level 1-3 carrying a milestone VOCABULARY signal
302
+ * (a version token, a ✅/🚧/📋 status marker, or the word "Milestone")?
303
+ * Sectioning is present iff there are >=2 such headings — one or zero cannot
304
+ * conflate siblings by construction, no matter where they sit. This resolves
305
+ * every prior failure:
306
+ * - #3204 / this file's `## Progress`: no signal — 0 milestone headings.
307
+ * - #3185 `## Overview` / `## Notes` interleaved with flat phases: neither
308
+ * carries a signal — 0 milestone headings, regardless of adjacency.
309
+ * - #1761 same-level siblings (`## v1.0` / `## v2.0`): each carries a version
310
+ * token — 2 milestone headings, sectioned, no level or adjacency test
311
+ * needed.
312
+ * - #1761 unmarked prose siblings (`## Milestone 1: …` / `## Milestone 2: …`):
313
+ * each carries the word "Milestone" — 2 milestone headings, sectioned.
314
+ * - Bundled template wrapper (`## Phases` -> `### 🚧 v1.1` -> `#### Phase 5:`):
315
+ * `## Phases` carries no signal; `### 🚧 v1.1` carries a marker and a
316
+ * version token but is only ONE heading — 1 milestone heading, not
317
+ * sectioned.
318
+ *
319
+ * Deliberately NOT a denylist of heading names (fragile, unbounded) and NOT
320
+ * collapsed into `hasVersionedMilestones` (a non-versioned-but-marked or
321
+ * "Milestone"-named section still conflates siblings — see that function's
322
+ * own doc comment, which answers a narrower question: ANY version token
323
+ * anywhere, not "are there >=2 independently-signalled milestone headings").
324
+ * Routed through `tokenizeHeadings` (fence- and CRLF-aware, single owner of
325
+ * ATX heading tokenisation) rather than a second regex pass, so a heading
326
+ * inside a fenced code block is never tokenised in the first place and
327
+ * cannot flip this result. The Phase-heading test (`/^Phase\s+\S/i`) is the
328
+ * SAME literal reused by `computeMilestoneSectionEnd` / `locateMilestoneHeadings`
329
+ * above, not a fresh copy. `MILESTONE_HEADING_SIGNAL_PATTERN`'s version-token
330
+ * and marker alternatives mirror the literal fragments already used by
331
+ * `hasVersionedMilestones` (`v\d+\.\d+`) and `computeMilestoneSectionEnd`
332
+ * (`✅|📋|🚧`) rather than inventing a fourth independent copy of the same
333
+ * vocabulary; the "Milestone" word is the one signal none of those three
334
+ * needed and this predicate does.
335
+ *
336
+ * Honest limit: this is a NARROWER signal than any of the three position-based
337
+ * attempts — a heading is only a candidate if its OWN TEXT carries a version
338
+ * token, a status marker, or the word "Milestone". Two milestone sections that
339
+ * carry NONE of the three (e.g. `## First Chapter` / `## Second Chapter`, each
340
+ * with their own Phase headings, no version, no marker, no "Milestone" word)
341
+ * are not detected as sectioned, and the whole-document count is trusted even
342
+ * though it may still conflate them. No fixture in this repo's bundled
343
+ * template or the #3204/#1761/#3185 reports exercises that shape; it is
344
+ * recorded here rather than hidden.
345
+ */
346
+ function countMilestoneHeadings(content) {
347
+ const isPhaseHeading = (text) => /^Phase\s+\S/i.test(text);
348
+ let milestoneHeadingCount = 0;
349
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
350
+ if (heading.level < 1 || heading.level > 3)
351
+ continue;
352
+ if (isPhaseHeading(heading.text))
353
+ continue;
354
+ if (!MILESTONE_HEADING_SIGNAL_PATTERN.test(heading.text))
355
+ continue;
356
+ milestoneHeadingCount++;
357
+ }
358
+ return milestoneHeadingCount;
359
+ }
360
+ function hasMilestoneSectioning(content) {
361
+ // The >=2 short-circuit the inline walk used to have is gone — a ROADMAP's
362
+ // heading count is small and tokenizeHeadings materializes the full token
363
+ // array regardless, so the shared walk pays nothing for it.
364
+ return countMilestoneHeadings(content) >= 2;
365
+ }
366
+ /**
367
+ * #3642: the >=1 sibling of `hasMilestoneSectioning`. The >=2 predicate
368
+ * answers SIBLING-conflation ("could two sections' phases mix") and is
369
+ * unchanged; but `buildStateFrontmatter`'s unbounded branch asks a question
370
+ * >=2 under-answers: "is there ANY milestone section whose phases a
371
+ * whole-document count would attribute to a milestone that matches no
372
+ * heading?" With exactly ONE section and an asserted milestone absent from
373
+ * the ROADMAP, >=2 said "flat" and the single section's phases leaked into
374
+ * the asserted milestone's total_phases (silent clobber of the stored
375
+ * value). Same walk, same vocabulary, threshold 1 — exported for that
376
+ * consumer only; every other consumer keeps the >=2 semantics.
377
+ */
378
+ function hasAnyMilestoneSection(content) {
379
+ return countMilestoneHeadings(content) >= 1;
380
+ }
381
+ /**
382
+ * #3184: the sole "which heading is this milestone's" rule — locate the version's
383
+ * headings, prefer the first that is not marked CLOSED/SHIPPED, else fall back to the
384
+ * first match. Returns null when the version has no heading at all.
385
+ *
386
+ * Extracted because three sites had written this same two-line selection
387
+ * independently (sliceMilestoneWindow, extractCurrentMilestoneScoped,
388
+ * currentMilestoneRawRanges) — the composition-level divergence ADR-3180
389
+ * Decision 4(c) covers: calling the owner's primitives and re-assembling the
390
+ * result locally is indistinguishable from re-deriving it.
391
+ */
392
+ function selectMilestoneHeading(content, version) {
393
+ const matches = locateMilestoneHeadings(content, version);
394
+ if (matches.length === 0)
395
+ return null;
396
+ return matches.find((m) => !isClosedMilestoneHeading(m[1])) ?? matches[0];
397
+ }
398
+ /**
399
+ * #3184: the sole "give me this version's window" composition. Delegates
400
+ * heading selection to `selectMilestoneHeading` (the sole selection owner)
401
+ * and then to `computeMilestoneSectionEnd` for the slice. Returns null when
402
+ * the version has no heading at all, so callers can distinguish "no such
403
+ * milestone section" from "empty section".
404
+ *
405
+ * Review finding (post-merge of this phase's first pass): `getMilestonePhaseFilter`'s
406
+ * versionOverride branch and `cmdMilestoneComplete`'s unstarted-phase guard
407
+ * had each independently composed `locateMilestoneHeadings` +
408
+ * `computeMilestoneSectionEnd` into a window — the SAME derivation written
409
+ * twice, and they disagreed (one skipped CLOSED headings, the other did not)
410
+ * — exactly the composition-level divergence ADR-3180 Decision 4(c) warns
411
+ * about: calling the owner and then re-assembling the result locally is
412
+ * indistinguishable from re-deriving it. Both sites now call this instead.
413
+ */
414
+ function sliceMilestoneWindow(content, version) {
415
+ const selected = selectMilestoneHeading(content, version);
416
+ if (selected === null)
417
+ return null;
418
+ return content.slice(selected.index, computeMilestoneSectionEnd(content, selected[0], selected.index));
419
+ }
420
+ /**
421
+ * #3184: counts RAW phase references — a `#{2,4} Phase <id>:` heading
422
+ * (fence-aware via `tokenizeHeadings`) or a `#2199` bullet entry — BEFORE any
423
+ * sentinel filter. Used for BOTH sides of `classifyMilestoneWindow`'s row-8
424
+ * comparison (does the window contain phase entries; does the document).
425
+ * Deliberately does NOT filter `999.x`/Phase 0 sentinels: the question here
426
+ * is "did the window reach the phase region", not "how many real phases
427
+ * exist" — a window containing only sentinel phases still reached the
428
+ * region and must read COMPLETE, not TRUNCATED.
429
+ */
430
+ // #3641: the bracket-convention phase-ENTRY heading shape — ADR-612 Decision
431
+ // 1's own discriminator: a phase heading is a bracket followed by a
432
+ // DIGIT-then-colon (`### [GSD.04] 01: Name`); a bracket followed by a NAME
433
+ // is a milestone heading and must never count. Every fragment interpolates a
434
+ // single-owner export from phase-id.cts — the heading intro
435
+ // (PHASE_HEADING_PREFIX_SRC: a `[...]` bracket optionally followed by a
436
+ // `Phase ` label, or a bare `Phase ` label), the digit-bearing token
437
+ // (PHASE_NUMBER_TOKEN_SOURCE, which also covers the dotted sub-phase form
438
+ // `[GSD.02] 05.03:`), and the optional pre-colon tag
439
+ // (OPTIONAL_PHASE_TAG_SOURCE) — never a re-typed grammar. Tested IN
440
+ // ADDITION to the legacy pattern below, so bracket mode is a strict
441
+ // superset: mid-migration legacy-labeled headings (`Phase AUTH-101:`-style
442
+ // custom ids included) keep their existing recognition. Review finding: an
443
+ // earlier single-alternative form with a `[\\w]` token admitted
444
+ // `[bracket] Word:` shapes — a colon-bearing MILESTONE heading inside the
445
+ // window read as an entry (defeating V005 outright for that spelling) and a
446
+ // decoy `### [GSD.04] Notes:` outside the window manufactured a false V005
447
+ // while suppressing the correct V004. The digit anchor forecloses both.
448
+ const BRACKET_PHASE_ENTRY_HEADING_RE = new RegExp(`^${PHASE_HEADING_PREFIX_SRC}${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
449
+ function hasPhaseEntries(markdown, phaseIdConvention) {
450
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
451
+ // #3641: the widened grammar engages ONLY when the resolved convention is
452
+ // 'bracket' — a project that has not opted in runs the legacy pattern
453
+ // alone, byte-identically.
454
+ const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
455
+ const bracketMode = phaseIdConvention === 'bracket';
456
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(markdown)) {
457
+ if (h.level < 2 || h.level > 4)
458
+ continue;
459
+ if (phaseHeadingPattern.test(h.text))
460
+ return true;
461
+ if (bracketMode && BRACKET_PHASE_ENTRY_HEADING_RE.test(h.text))
462
+ return true;
463
+ }
464
+ // #3184 review finding: the bullet fallback must be fence-aware too, or a
465
+ // FENCED markdown EXAMPLE of the `- [ ] **Phase N — Name**` syntax (e.g. a
466
+ // doc showing the convention) counts as a real phase entry. Strip fences
467
+ // through the canonical seam before testing, matching tokenizeHeadings'
468
+ // fence-awareness above.
469
+ if (BULLET_PHASE_LINE_PATTERN.test((0, markdown_sectionizer_cjs_1.stripFencedCode)(markdown).text))
470
+ return true;
471
+ // #3577: a markdown-table phase listing also declares phases.
472
+ return collectTablePhaseRows(markdown).length > 0;
473
+ }
474
+ // ─── #3577: markdown-table phase listings ─────────────────────────────────────
475
+ // #3577: a GFM table declares phases when its header's FIRST cell is the literal
476
+ // `Phase` (optionally `Phase #` / `Phase No.` / `Phase number`) and the header does
477
+ // NOT match a known non-listing schema — the canonical RoadmapProgress table
478
+ // (`| Phase | Plans Complete | Status | Completed |`) leads with `Phase` too, and
479
+ // its rows are progress markers, not declarations. Data rows carry the phase id in
480
+ // their first cell (digit-bearing canonical shape — `Phase`-word header cells and
481
+ // `---` delimiter rows are digit-free and excluded by construction). Fence-aware
482
+ // via stripFencedCode, matching the #3184 lesson: a fenced EXAMPLE of the table
483
+ // form is not a declared phase.
484
+ const PHASE_LISTING_HEADER_RE = /^\|?\s*phase(?:\s*(?:#|no\.?|number))?\s*\|/i;
485
+ const TABLE_PHASE_ID_RE = /^[A-Za-z]?\d[\w.-]*$/;
486
+ function collectTablePhaseRows(window) {
487
+ const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
488
+ const lines = unfenced.split(/\r?\n/);
489
+ const rows = [];
490
+ for (let i = 0; i + 1 < lines.length; i++) {
491
+ if (!PHASE_LISTING_HEADER_RE.test(lines[i]))
492
+ continue;
493
+ const headerCells = (0, markdown_table_cjs_1.splitTableRow)(lines[i]);
494
+ if ((0, markdown_table_cjs_1.matchTableSchema)(headerCells) !== null)
495
+ continue; // canonical non-listing schema
496
+ if (!(0, markdown_table_cjs_1.isDelimiterRow)((0, markdown_table_cjs_1.splitTableRow)(lines[i + 1])))
497
+ continue;
498
+ for (let j = i + 2; j < lines.length; j++) {
499
+ // GFM semantics: the table ENDS at the first line that is not a table
500
+ // row. Review finding: breaking only on blank lines let subsequent prose
501
+ // (e.g. a bare `2026-01-01` date line) be harvested as a phase id.
502
+ if (!/^\s*\|/.test(lines[j]))
503
+ break;
504
+ const cells = (0, markdown_table_cjs_1.splitTableRow)(lines[j]);
505
+ if (cells.length === 0 || cells.every((c) => c === ''))
506
+ break; // defensive: blank row
507
+ const first = cells[0] ?? '';
508
+ if (!TABLE_PHASE_ID_RE.test(first))
509
+ continue;
510
+ if (!/^999\b/.test(first)) {
511
+ rows.push({ id: first, name: cells[1] && cells[1] !== '' ? cells[1] : null, row: lines[j] });
512
+ }
513
+ }
514
+ }
515
+ return rows;
516
+ }
517
+ /**
518
+ * #3262: the sole owner of "which phase ids does THIS milestone window
519
+ * declare". Extracted verbatim from `getMilestonePhaseFilter`'s former inline
520
+ * heading scan + bullet scan so the new `roadmap milestone-scope` probe (the
521
+ * write-time milestone-scope guard's capture/compare signal) reads the SAME
522
+ * derivation the phase filter builds its membership set from — never a second
523
+ * copy of either scan.
524
+ *
525
+ * Fence-aware on both scans (tokenizeHeadings + stripFencedCode), matching
526
+ * `hasPhaseEntries` above: a fenced markdown EXAMPLE of either syntax is not
527
+ * a declared phase.
528
+ *
529
+ * #3185: deliberately NOT isSentinelPhaseId here. That predicate treats a
530
+ * leading 0 as sentinel milestone 0, which would swallow the #2554 decimal
531
+ * phase ids ("00.1" is a real phase, not milestone 0). This scan asks a
532
+ * narrower question — "which phase ids does this window declare" — where only
533
+ * the 999 icebox range is excluded.
534
+ */
535
+ function scanMilestonePhaseIds(window) {
536
+ const ids = new Set();
537
+ // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
538
+ // T4 seam migration: phase headings inside fences are excluded automatically.
539
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
540
+ const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
541
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(window)) {
542
+ if (h.level < 2 || h.level > 4)
543
+ continue;
544
+ const pm = phaseHeadingPattern.exec(h.text);
545
+ if (pm && !/^999\b/.test(pm[1]))
546
+ ids.add(pm[1]);
547
+ }
548
+ // #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
549
+ // so a bullet-house-style ROADMAP populates the milestone phase set instead of
550
+ // collapsing to a zero-count pass-all filter.
551
+ let bm;
552
+ const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
553
+ const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
554
+ while ((bm = scanner.exec(unfenced)) !== null) {
555
+ if (!/^999\b/.test(bm[1]))
556
+ ids.add(bm[1]);
557
+ }
558
+ // #3577: table-declared ids join the same membership set — the milestone
559
+ // filter must not collapse a table-house-style window to zero-count.
560
+ for (const tr of collectTablePhaseRows(window))
561
+ ids.add(tr.id);
562
+ return ids;
563
+ }
564
+ /**
565
+ * #3262 (write-time milestone-scope guard): does this free-text value contain
566
+ * a heading line that would TERMINATE the current milestone window if spliced
567
+ * into ROADMAP.md? Returns the offending heading texts (empty array = safe).
568
+ *
569
+ * Mirrors the parser's own terminator vocabulary (`computeMilestoneSectionEnd`):
570
+ * a heading terminates the window when it is level 1-3, is NOT a Phase heading
571
+ * (`/^Phase\s+\S/i` — the phase's OWN numbered heading is existing, correct,
572
+ * load-bearing behavior and is never a violation), and carries a milestone
573
+ * signal. The signal test is the union of `MILESTONE_HEADING_SIGNAL_PATTERN`
574
+ * (this module's "is this heading a milestone heading" vocabulary) and `🔄`
575
+ * (which terminates in `extractCurrentMilestoneScoped`'s own preamble pattern)
576
+ * — deliberately the CONSERVATIVE union: a field value whose line is a level
577
+ * 1-3 heading naming a version, a status marker, or the word "Milestone" is
578
+ * exactly the shape that silently narrows the window, so the guard rejects on
579
+ * any of them rather than re-deriving which specific marker a given roadmap's
580
+ * terminator would fire on.
581
+ *
582
+ * Two deliberate conservatisms, both one-directional (reject more, never less):
583
+ * - `computeMilestoneSectionEnd` also bounds by the milestone heading's own
584
+ * level (a `###` marker only terminates a `###`-level milestone heading);
585
+ * this predicate flags every level 1-3 marker regardless, because which
586
+ * level the active milestone heading uses is a property of the document at
587
+ * write time, not of the text being validated.
588
+ * - level 4+ headings never terminate any window and are not flagged.
589
+ *
590
+ * Fence-aware via `tokenizeHeadings`: a FENCED example of a milestone heading
591
+ * inside a field value does not terminate the real window, so it must not be
592
+ * a violation either — the parser and this guard must agree on fences.
593
+ */
594
+ function findMilestoneScopeHeadingLines(text) {
595
+ const out = [];
596
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text)) {
597
+ if (h.level > 3)
598
+ continue;
599
+ if (/^Phase\s+\S/i.test(h.text))
600
+ continue;
601
+ if (MILESTONE_HEADING_SIGNAL_PATTERN.test(h.text) || /🔄/.test(h.text)) {
602
+ out.push(h.text.trim());
603
+ }
604
+ }
605
+ return out;
606
+ }
607
+ /**
608
+ * #3184: pure decision table (no I/O, no regex construction from caller
609
+ * data) implementing the design's Behavior table rows 1-8 (the remaining
610
+ * rows 9-17 reduce to one of these six through how the caller constructs its
611
+ * input, not additional branches here). Kernighan's Law fired during design:
612
+ * `getMilestonePhaseFilter` is already cyclomatic 36, so this discriminator
613
+ * is extracted as its own named, separately-testable function rather than
614
+ * inlined.
615
+ */
616
+ function classifyMilestoneWindow(input) {
617
+ const { readable, versionResolved, hasVersionedMilestones, headingFound, windowHasPhaseEntries, documentHasPhaseEntries } = input;
618
+ return (!readable ? SCOPE.UNREADABLE : // row 2
619
+ !versionResolved && !hasVersionedMilestones ? SCOPE.COMPLETE : // row 3: free-form legacy roadmap
620
+ !versionResolved && hasVersionedMilestones ? SCOPE.UNSCOPED : // row 4
621
+ versionResolved && !headingFound ? SCOPE.UNSCOPED : // row 5
622
+ headingFound && !windowHasPhaseEntries && documentHasPhaseEntries ? SCOPE.TRUNCATED : // row 8
623
+ SCOPE.COMPLETE // rows 6, 7
624
+ );
625
+ }
626
+ /**
627
+ * Extract the current milestone section from ROADMAP.md by positive lookup,
628
+ * carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
94
629
  *
95
630
  * @param content - ROADMAP.md content.
96
631
  * @param cwd - Project working directory, used to read the companion STATE.md
@@ -99,10 +634,28 @@ function isMilestoneShippedInRoadmap(content, version) {
99
634
  * `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
100
635
  * default) preserves the prior `planningDir(cwd)` resolution exactly,
101
636
  * including its `GSD_WORKSTREAM` env fallback.
637
+ *
638
+ * #3184: `extractCurrentMilestone`'s CRITICAL blast radius (200+ affected
639
+ * symbols, 20 direct callers) means its signature and return type do not
640
+ * change. This is the real owner; `extractCurrentMilestone` becomes a
641
+ * one-line wrapper returning `.value` so every existing caller is untouched.
642
+ *
643
+ * @param phaseIdConvention - #3641: the RESOLVED `phase_id_convention`
644
+ * config value, threaded to `hasPhaseEntries` so the scope axis's row-8
645
+ * comparison recognizes bracket-convention phase entries
646
+ * (`### [GSD.04] 01: Name`). Optional: absent, or any value other than
647
+ * `'bracket'`, compiles the legacy entry grammar byte-identically — the
648
+ * widening engages only for a project that resolved the convention
649
+ * explicitly. `extractCurrentMilestone`'s wrapper deliberately does NOT
650
+ * expose it (its 20 callers are not the scope-axis consumers; V005's
651
+ * router site and `getMilestonePhaseFilter` resolve and thread it).
102
652
  */
103
- function extractCurrentMilestone(content, cwd, ws) {
104
- if (!cwd)
105
- return stripShippedMilestones(content);
653
+ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
654
+ if (!cwd) {
655
+ // Row 1: a deliberate unscoped read (no cwd supplied) is a real answer —
656
+ // the caller asked for no scoping, so whole-document is COMPLETE.
657
+ return { value: stripShippedMilestones(content), scope: SCOPE.COMPLETE };
658
+ }
106
659
  let version = null;
107
660
  try {
108
661
  const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
@@ -121,12 +674,28 @@ function extractCurrentMilestone(content, cwd, ws) {
121
674
  version = 'v' + inProgressMatch[1];
122
675
  }
123
676
  }
124
- if (!version)
125
- return stripShippedMilestones(content);
126
- const escapedVersion = escapeRegex(version);
127
- const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi');
128
- const summaryPattern = new RegExp(`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`, 'i');
129
- const headingMatches = [...content.matchAll(sectionPattern)];
677
+ const versionResolved = version !== null;
678
+ // #3184: routed through the shared owner (was an inline copy — see the
679
+ // twin copy in `getMilestonePhaseFilter`, the intra-owner-file duplicate
680
+ // review caught since the drift guard exempts this file by construction).
681
+ const versionedMilestonesPresent = hasVersionedMilestones(content);
682
+ if (!version) {
683
+ const value = stripShippedMilestones(content);
684
+ return {
685
+ value,
686
+ scope: classifyMilestoneWindow({
687
+ readable: true,
688
+ versionResolved,
689
+ hasVersionedMilestones: versionedMilestonesPresent,
690
+ headingFound: false,
691
+ windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
692
+ documentHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
693
+ }),
694
+ };
695
+ }
696
+ const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(content), phaseIdConvention);
697
+ const summaryPattern = new RegExp(`<summary[^>]*>([^<]*${(0, pattern_cjs_1.escapeRegex)(version)}[^<]*)<\\/summary>`, 'i');
698
+ const headingMatches = locateMilestoneHeadings(content, version);
130
699
  if (headingMatches.length === 0) {
131
700
  const summaryMatch = content.match(summaryPattern);
132
701
  if (summaryMatch) {
@@ -146,39 +715,42 @@ function extractCurrentMilestone(content, cwd, ws) {
146
715
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
147
716
  .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
148
717
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
149
- return preamble + content.slice(detailsOpenIdx, detailsEnd);
718
+ const value = preamble + content.slice(detailsOpenIdx, detailsEnd);
719
+ return {
720
+ value,
721
+ scope: classifyMilestoneWindow({
722
+ readable: true,
723
+ versionResolved,
724
+ hasVersionedMilestones: versionedMilestonesPresent,
725
+ headingFound: true,
726
+ windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
727
+ documentHasPhaseEntries,
728
+ }),
729
+ };
150
730
  }
151
731
  }
152
- return stripShippedMilestones(content);
732
+ const value = stripShippedMilestones(content);
733
+ return {
734
+ value,
735
+ scope: classifyMilestoneWindow({
736
+ readable: true,
737
+ versionResolved,
738
+ hasVersionedMilestones: versionedMilestonesPresent,
739
+ headingFound: false,
740
+ windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
741
+ documentHasPhaseEntries,
742
+ }),
743
+ };
153
744
  }
154
745
  const allMatches = headingMatches;
155
746
  const isClosed = isClosedMilestoneHeading;
156
747
  const firstMatch = allMatches[0];
157
- const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
748
+ // #3184: selection collapses to the sole owner; `allMatches` is still needed
749
+ // below (offsets, detailsMatch search), so only the selection itself routes
750
+ // through `selectMilestoneHeading` rather than the whole block.
751
+ const selected = selectMilestoneHeading(content, version);
158
752
  const sectionStart = selected.index;
159
- const computeSectionEnd = (headingText, headingStart) => {
160
- const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
161
- const afterHeading = headingStart + headingText.length;
162
- // Use tokenizeHeadings (fence-aware, offsets into original content) to find
163
- // the next stop boundary without re-implementing fence detection. T4 seam migration.
164
- const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
165
- for (const h of headings) {
166
- if (h.offset <= headingStart)
167
- continue;
168
- if (h.offset < afterHeading)
169
- continue;
170
- if (h.level > level)
171
- continue;
172
- // Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
173
- if (/^Phase\s+\S/i.test(h.text))
174
- continue;
175
- if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
176
- continue;
177
- return h.offset;
178
- }
179
- return content.length;
180
- };
181
- const sectionEnd = computeSectionEnd(selected[0], sectionStart);
753
+ const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
182
754
  const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
183
755
  const firstMilestoneMatch = content.match(anyMilestonePattern);
184
756
  const preambleCutoff = firstMilestoneMatch
@@ -198,7 +770,7 @@ function extractCurrentMilestone(content, cwd, ws) {
198
770
  // that share a version prefix do not cross-pollinate. (#730)
199
771
  const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
200
772
  const detailsVersionBoundary = selectedVersionToken
201
- ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
773
+ ? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
202
774
  : null;
203
775
  let detailsSection = '';
204
776
  const detailsMatch = allMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
@@ -207,7 +779,7 @@ function extractCurrentMilestone(content, cwd, ws) {
207
779
  (m.index ?? 0) >= sectionEnd);
208
780
  if (detailsMatch) {
209
781
  const detailsStart = detailsMatch.index ?? 0;
210
- detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
782
+ detailsSection = content.slice(detailsStart, computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart));
211
783
  }
212
784
  // #2947: the preamble strip removes `### Phase N:` detail headings from the
213
785
  // pre-milestone region so they don't duplicate the ones inside the selected
@@ -219,13 +791,41 @@ function extractCurrentMilestone(content, cwd, ws) {
219
791
  // the selected milestone section actually contains its own — otherwise the
220
792
  // preamble phases ARE this milestone's phases and must be preserved.
221
793
  const currentSectionHasPhaseDetails = /^#{2,4}\s*Phase\s+\S/im.test(currentSection);
222
- const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details')
223
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
224
- .replace(currentSectionHasPhaseDetails ? /^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim : /$/, '')
225
- .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
226
- return detailsSection
794
+ const preambleBase = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details');
795
+ // #3235: the conditional wraps the REPLACE, not the pattern. This used to select between the
796
+ // strip regex and a `/$/` sentinel, which made the do-not-strip branch an identity replacement
797
+ // (CodeQL js/identity-replacement, alert 53) -- correct, but it left both branches sharing one
798
+ // replacement argument, so changing `''` would silently give the no-op branch a real effect.
799
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
800
+ const preambleWithoutPhaseDetails = currentSectionHasPhaseDetails
801
+ ? preambleBase.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
802
+ : preambleBase;
803
+ // Unconditional in BOTH branches -- the #730 `Phase Details` heading strip is independent of
804
+ // whether the selected milestone section carries phase details of its own.
805
+ const preamble = preambleWithoutPhaseDetails.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
806
+ const value = detailsSection
227
807
  ? preamble + currentSection + '\n' + detailsSection
228
808
  : preamble + currentSection;
809
+ return {
810
+ value,
811
+ scope: classifyMilestoneWindow({
812
+ readable: true,
813
+ versionResolved,
814
+ hasVersionedMilestones: versionedMilestonesPresent,
815
+ headingFound: true,
816
+ windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
817
+ documentHasPhaseEntries,
818
+ }),
819
+ };
820
+ }
821
+ /**
822
+ * #3184: thin wrapper preserving `extractCurrentMilestone`'s exact signature
823
+ * and return type — CRITICAL blast radius (20 direct callers), so the type
824
+ * stays `string`. `extractCurrentMilestoneScoped` is the real owner; callers
825
+ * that need to branch on scope opt in to it directly.
826
+ */
827
+ function extractCurrentMilestone(content, cwd, ws) {
828
+ return extractCurrentMilestoneScoped(content, cwd, ws).value;
229
829
  }
230
830
  function replaceInCurrentMilestone(content, pattern, replacement) {
231
831
  const apply = (src) => typeof replacement === 'function'
@@ -315,6 +915,25 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
315
915
  section,
316
916
  };
317
917
  }
918
+ // #3577: markdown-table row fallback. Mirrors the #2199 bullet fallback's
919
+ // tier — used only AFTER heading and bullet lookups fail on scoped + full
920
+ // content, so a heading with a Requirements/Goal section always wins. The row
921
+ // itself is the section (single line), the name comes from column 2.
922
+ function findRoadmapTablePhaseInContent(content, phaseNum) {
923
+ const wanted = String(phaseNum).replace(/^0+(?=.)/, '');
924
+ for (const tr of collectTablePhaseRows(content)) {
925
+ if (tr.id.replace(/^0+(?=.)/, '') !== wanted)
926
+ continue;
927
+ return {
928
+ found: true,
929
+ phase_number: String(phaseNum),
930
+ phase_name: tr.name ?? `Phase ${tr.id}`,
931
+ goal: null,
932
+ section: tr.row.trim(),
933
+ };
934
+ }
935
+ return null;
936
+ }
318
937
  function findRoadmapBulletPhaseInContent(content, phaseNum, phaseSource) {
319
938
  // #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns
320
939
  // the single bullet line as the section (no multi-line body) — used only as a
@@ -335,7 +954,8 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
335
954
  if (!phaseNum)
336
955
  return null;
337
956
  const normalizedPhase = stripProjectCodePrefix(phaseNum);
338
- if (/^999(?:\.|$)/.test(normalizedPhase))
957
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
958
+ if (isSentinelPhaseId(normalizedPhase))
339
959
  return null;
340
960
  // Resolved INSIDE the try for the same reason as getMilestoneInfo below: planningDir
341
961
  // throws a plain Error for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and resolving
@@ -373,6 +993,13 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
373
993
  if (fullBullet)
374
994
  return fullBullet;
375
995
  }
996
+ // #3577: last tier — a markdown-table row declaration.
997
+ const scopedTable = findRoadmapTablePhaseInContent(content, phaseNum);
998
+ if (scopedTable)
999
+ return scopedTable;
1000
+ const fullTable = findRoadmapTablePhaseInContent(fullContent, phaseNum);
1001
+ if (fullTable)
1002
+ return fullTable;
376
1003
  return null;
377
1004
  }
378
1005
  catch (err) {
@@ -402,6 +1029,39 @@ function reportUnreadableRoadmap(err, roadmapPath) {
402
1029
  return;
403
1030
  warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
404
1031
  }
1032
+ // ─── Roadmap progress table (#1956/#2012 decoy avoidance) ─────────────────────
1033
+ /**
1034
+ * Locate ROADMAP.md's "Progress" table — the sole owner of the #2012
1035
+ * decoy-avoidance scope for the `drift-guard phase-status` CLI seam (#1956).
1036
+ *
1037
+ * Scopes to the `## Progress` heading first (level-2, exact case-insensitive
1038
+ * text `'progress'`, `{ levelBounded: true }`) via `collectSection` — the
1039
+ * same CRLF-safe seam `stateCurrentPositionSlice` (state-document.cts) uses
1040
+ * to scope STATE.md's `## Current Position` — so a differently-headed table
1041
+ * that happens to share the same column names (e.g. an "Archive Notes"
1042
+ * table) is never picked up instead of the real one (#2012). Falls back to
1043
+ * scanning the WHOLE document when no `## Progress` heading exists, so a
1044
+ * headingless milestone slice (#1445) still resolves rather than going
1045
+ * uncheckable — the same fallback `deriveProgressFromRoadmap`
1046
+ * (phase-lifecycle.cts) deliberately preserves.
1047
+ *
1048
+ * `deriveProgressFromRoadmap` independently expresses this same "scope to
1049
+ * `## Progress`, else whole document" rule via its own regex-based scope
1050
+ * (kept there deliberately rather than refactored onto this function — its
1051
+ * blast radius is large). The two locators are therefore separate
1052
+ * implementations of the same scoping rule and must agree about WHICH table
1053
+ * is the Progress table; a parity test in
1054
+ * tests/adr-22-plan-drift-guard.test.cjs asserts they do, per the repo's
1055
+ * generative-fix-divergence guard.
1056
+ *
1057
+ * Returns the same shape `findTableWithColumns` returns (or `null`).
1058
+ */
1059
+ function findRoadmapProgressTable(roadmapContent) {
1060
+ const isProgressHeading = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'progress';
1061
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(roadmapContent, isProgressHeading, { levelBounded: true });
1062
+ const scoped = section ? section.body : roadmapContent;
1063
+ return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
1064
+ }
405
1065
  /**
406
1066
  * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
407
1067
  * milestone-name capture. Markdown headings commonly take the shape
@@ -414,6 +1074,89 @@ function reportUnreadableRoadmap(err, roadmapPath) {
414
1074
  function stripLeadingDelimiter(s) {
415
1075
  return s.replace(/^[\s—–:-]+/, '').trim();
416
1076
  }
1077
+ /**
1078
+ * #3216 (ADR-3180 §7.2's "Name extraction — pinned rule"): the sole "milestone
1079
+ * heading text → version + curated name" rule. Strips everything through the
1080
+ * heading's OWN version token — NOT necessarily a version a caller is
1081
+ * separately asking about (a `v2.0` STATE selecting a `## v2.0.1 — Portability`
1082
+ * heading yields the name `Portability`, never `.1 — Portability`) — then ONE
1083
+ * leading delimiter and surrounding whitespace via `stripLeadingDelimiter`.
1084
+ * `(` is an ordinary name character and is never a terminator (#3171). Shared
1085
+ * by `listMilestoneHeadings` and `getMilestoneInfo` so this rule has exactly
1086
+ * one implementation. Returns `null` when `headingText` carries no version
1087
+ * token at all (e.g. a non-milestone heading reached this by mistake).
1088
+ *
1089
+ * @param expectedVersion - When the caller already knows the exact version it
1090
+ * is looking for (the STATE-anchored `getMilestoneInfo` path, which located
1091
+ * this heading via `selectMilestoneHeading(roadmap, stateVersion)`), pass it
1092
+ * here so the "own version token" is found by anchoring to that KNOWN
1093
+ * literal (escaped, then extended by the same dash/dot continuation grammar
1094
+ * for the row-16/17 sub-milestone cases) instead of independently
1095
+ * re-deriving a version-shaped pattern from scratch. `listMilestoneHeadings`
1096
+ * (version-agnostic enumeration — no target version exists) omits this and
1097
+ * keeps the generic re-derivation. Anchoring on the known literal is what
1098
+ * makes a hostile STATE `milestone:` value (regex metacharacters, single-
1099
+ * segment `vN`, a literal `$&`/`$1`) resolve correctly: the generic pattern
1100
+ * only recognizes the real GSD version grammar and stops early on anything
1101
+ * outside it, leaving hostile characters in the extracted "name".
1102
+ */
1103
+ function extractMilestoneHeadingName(headingText, expectedVersion) {
1104
+ const versionMatch = expectedVersion
1105
+ // Anchor to the KNOWN literal version, then extend across any immediate
1106
+ // dash/dot continuation the heading's OWN token carries beyond it (e.g.
1107
+ // requested v8.0 -> heading's own v8.0-B; requested v2.0 -> v2.0.1).
1108
+ // `.match()` here — never `.replace()` — so a `$&`/`$1`-bearing version
1109
+ // is located as a literal substring and never interpreted as a
1110
+ // String.replace() substitution pattern.
1111
+ ? headingText.match(new RegExp(`${(0, pattern_cjs_1.escapeRegex)(expectedVersion)}(?:[-.][A-Za-z0-9]+)*`, 'i'))
1112
+ // No known target: re-derive a version-shaped token generically. `v3` /
1113
+ // `v3.3` / `v3.3.3` must all resolve to themselves (§7.2), so the dotted
1114
+ // continuation is zero-or-more, not one-or-more.
1115
+ : headingText.match(/v\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*/i);
1116
+ if (!versionMatch)
1117
+ return null;
1118
+ const version = versionMatch[0];
1119
+ const afterVersion = headingText.slice((versionMatch.index ?? 0) + version.length);
1120
+ // Amendment (§7.2 pinned rule): after stripping the leading delimiter, also
1121
+ // strip a trailing run of status markers (✅ 📋 🚧) plus surrounding
1122
+ // whitespace — the marker is already carried structurally by `closed`, so
1123
+ // duplicating it inside `name` (e.g. "Old ✅") is redundant and wrong. Only
1124
+ // these three markers, only at the end; a marker inside a name is untouched.
1125
+ const name = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
1126
+ return { version, name };
1127
+ }
1128
+ /**
1129
+ * #3216 (epic #3180 §7.2, "Milestone identity"): which milestone is current,
1130
+ * and what is it called. Binds to the canonical `locateMilestoneHeadings` /
1131
+ * `listMilestoneHeadings` / `extractMilestoneHeadingName` owners and deletes
1132
+ * both hand-rolled heading regexes this function used to carry — the
1133
+ * level-blind STATE-version regex (#3197) and the unanchored fallback regex
1134
+ * (#3171) — so the class of defect they produced ("### Phase N: … v3.3 …"
1135
+ * read as milestone `v3.3`; a name truncated at `(`) is structurally
1136
+ * unrepresentable rather than merely fixed on this one copy.
1137
+ *
1138
+ * Never throws (#2245) — the outer try/catch returns `{value: null, scope:
1139
+ * UNREADABLE}` on any failure, preserving `state.cts:1663`'s "this wrapper
1140
+ * could never be triggered" invariant. Absence (ENOENT) is silent (#1881,
1141
+ * ADR-1411); a genuine read fault (e.g. EACCES) still reports via
1142
+ * `reportUnreadableRoadmap`, which discriminates on the errno exactly as
1143
+ * before.
1144
+ *
1145
+ * The `{version:'v1.0', name:'milestone'}` default this function used to
1146
+ * return on every unresolved path is DELETED per §7.2 rule 4 — it was
1147
+ * output-identical to a successful read of a genuine v1.0 project. Every
1148
+ * unresolved path now returns a `scope` other than `COMPLETE` instead.
1149
+ */
1150
+ /**
1151
+ * #3216 review Finding 2: `getMilestoneInfo`'s `{ value, scope }` return shape
1152
+ * was hand-built as an inline object literal at every return point — factored
1153
+ * out once so the shape itself cannot drift between call sites. Purely a
1154
+ * literal-shape constructor: does not decide, validate, or alter any value or
1155
+ * scope — every per-branch rationale comment stays exactly where it was.
1156
+ */
1157
+ function scoped(value, scope) {
1158
+ return { value, scope };
1159
+ }
417
1160
  function getMilestoneInfo(cwd) {
418
1161
  // Declared here but RESOLVED INSIDE the try, so the catch can name the file without
419
1162
  // moving planningDir() out of the protected region. planningDir throws a plain Error
@@ -423,6 +1166,8 @@ function getMilestoneInfo(cwd) {
423
1166
  // skipped and the default is returned exactly as before.
424
1167
  let roadmapPath;
425
1168
  try {
1169
+ if (!cwd)
1170
+ return scoped(null, SCOPE.UNREADABLE);
426
1171
  roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
427
1172
  const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
428
1173
  if (roadmap === null)
@@ -447,60 +1192,96 @@ function getMilestoneInfo(cwd) {
447
1192
  }
448
1193
  }
449
1194
  if (stateVersion) {
450
- const escapedVer = escapeRegex(stateVersion);
1195
+ const escapedVer = (0, pattern_cjs_1.escapeRegex)(stateVersion);
451
1196
  // #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct
452
1197
  // guaranteed to carry the milestone's curated name adjacent to its version
453
1198
  // (the active-milestone bullet). A `##` heading is often nameless
454
1199
  // ("## vX.Y — Active Milestone") and, when unanchored, was matched
455
1200
  // spuriously on a copy quoted inside backticks in this very bullet.
456
- const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i'));
1201
+ // #3216 fix (progressMarkerBulletIsConsultedBeforeHeading): the version
1202
+ // is commonly wrapped in its OWN bold pair — `🚧 **v3.3** Name` — so a
1203
+ // trailing `\*?\*?` after the version (mirroring the leading one) is
1204
+ // required before the `\s+` that anchors the name capture; without it
1205
+ // the closing `**` sits between the version and the required whitespace
1206
+ // and the whole match fails, silently falling through to the heading.
1207
+ const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\*?\\*?\\s+([^*\\n]+)`, 'i'));
457
1208
  if (listMatch) {
458
1209
  const name = stripLeadingDelimiter(listMatch[1]);
459
1210
  if (name)
460
- return { version: stateVersion, name };
1211
+ return scoped({ version: stateVersion, name }, SCOPE.COMPLETE);
461
1212
  }
462
- // Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag)
463
- // so a heading quoted inside backticks or prose mid-line can no longer
464
- // match. Skip shipped (✅) headings.
465
- const headingMatch = roadmap.match(new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im'));
466
- if (headingMatch && !headingMatch[0].includes('✅')) {
467
- // Strip a leading delimiter — `.trim()` removes whitespace, not the
468
- // em-dash/colon that conventionally separates version from name.
469
- const name = stripLeadingDelimiter(headingMatch[1]);
470
- if (name)
471
- return { version: stateVersion, name };
1213
+ // #3216: heading selection routes through the shared owner
1214
+ // (`selectMilestoneHeading` — locate → prefer-non-closed, mirroring
1215
+ // `sliceMilestoneWindow`), deleting the level-blind `^##…` regex (#3197)
1216
+ // and the unanchored `[:\s]+([^\n(]+)` name capture that truncated at a
1217
+ // parenthetical (#3171). A CLOSED/shipped heading is not "current" (row
1218
+ // 5) — it falls through to the TRUNCATED return below exactly as a
1219
+ // missing heading would.
1220
+ const selected = selectMilestoneHeading(roadmap, stateVersion);
1221
+ if (selected) {
1222
+ const headingText = selected[1].replace(/^#{1,3}\s+/, '');
1223
+ if (!isClosedMilestoneHeading(headingText)) {
1224
+ // #3216 fix: pass the KNOWN stateVersion so name extraction anchors
1225
+ // to it (see extractMilestoneHeadingName's `expectedVersion` doc) —
1226
+ // fixes single-segment versions (`v3`, no dot) and hostile STATE
1227
+ // values (regex metacharacters, literal `$&`/`$1`) that the generic
1228
+ // re-derivation used by listMilestoneHeadings cannot recognize.
1229
+ const extracted = extractMilestoneHeadingName(headingText, stateVersion);
1230
+ if (extracted && extracted.name) {
1231
+ return scoped({ version: stateVersion, name: extracted.name }, SCOPE.COMPLETE);
1232
+ }
1233
+ }
472
1234
  }
473
- return { version: stateVersion, name: 'milestone' };
1235
+ // Version is known (STATE.md), but no name-bearing evidence resolved:
1236
+ // no 🚧 bullet, no usable heading (absent, phase-only-excluded, shipped,
1237
+ // or heading-but-nameless). §7.2 rule 4 — never fabricate a name.
1238
+ return scoped({ version: stateVersion, name: null }, SCOPE.TRUNCATED);
474
1239
  }
1240
+ // No STATE.md version. The 🚧 in-progress bullet is still consulted first
1241
+ // (unchanged from the pre-#3216 fallback).
475
1242
  const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
476
1243
  if (inProgressMatch) {
477
- return {
478
- version: 'v' + inProgressMatch[1],
479
- name: inProgressMatch[2].trim(),
480
- };
1244
+ return scoped({ version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() }, SCOPE.COMPLETE);
481
1245
  }
1246
+ // #3216: enumerate every OPEN (non-shipped) milestone heading via the
1247
+ // shared owner and take the first in document order — deletes the
1248
+ // unanchored `/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/` fallback
1249
+ // regex (#3171/#3197), whose `## ` prefix matched starting at the SECOND
1250
+ // `#` of a `### Phase N: …` heading.
482
1251
  const cleaned = stripShippedMilestones(roadmap);
483
- const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
484
- if (headingMatch) {
485
- return {
486
- version: 'v' + headingMatch[1],
487
- name: headingMatch[2].trim(),
488
- };
1252
+ const openHeadings = listMilestoneHeadings(cleaned).filter((h) => !h.closed);
1253
+ if (openHeadings.length > 0) {
1254
+ const first = openHeadings[0];
1255
+ if (first.name) {
1256
+ return scoped({ version: first.version, name: first.name }, SCOPE.COMPLETE);
1257
+ }
1258
+ return scoped({ version: first.version, name: null }, SCOPE.TRUNCATED);
489
1259
  }
490
- const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
491
- return {
492
- version: versionMatch ? versionMatch[0] : 'v1.0',
493
- name: 'milestone',
494
- };
1260
+ // No usable milestone heading anywhere. A version token mentioned ONLY
1261
+ // inside an excluded `### Phase N: … vX.Y …` heading is not evidence
1262
+ // (#3197) and must not be reported as if it were a real version — value
1263
+ // stays null, scope UNSCOPED. A version token mentioned OUTSIDE any Phase
1264
+ // heading (prose, a bullet, a non-milestone heading) is weak-but-real
1265
+ // evidence — version retained, name null, scope TRUNCATED.
1266
+ const withoutPhaseHeadingLines = cleaned.replace(/^#{1,4}\s*Phase\s+\S[^\n]*$/gim, '');
1267
+ const bareVersionMatch = withoutPhaseHeadingLines.match(/v\d+(?:\.\d+)+/i);
1268
+ if (bareVersionMatch) {
1269
+ return scoped({ version: bareVersionMatch[0], name: null }, SCOPE.TRUNCATED);
1270
+ }
1271
+ // Free-form legacy ROADMAP with no version anywhere reachable, OR the
1272
+ // only version-bearing heading was a `### Phase N` heading. §7.1's
1273
+ // "free-form is COMPLETE" governs WINDOWING (whole document is the
1274
+ // window); identity has no version to report and must not invent one.
1275
+ return scoped(null, SCOPE.UNSCOPED);
495
1276
  }
496
1277
  catch (err) {
497
1278
  // This function has no existsSync guard, so an absent ROADMAP arrives here too, as a
498
- // synthetic Error with no errno. Only a real read fault is reported; the populated
499
- // default is returned unchanged either way, and a plausible-looking default needs the
500
- // diagnostic more than an empty sentinel does, not less (ADR-1411).
1279
+ // synthetic Error with no errno. Only a real read fault is reported; `value: null` is
1280
+ // returned unchanged either way, and a plausible-looking default needs the diagnostic
1281
+ // more than an empty sentinel does, not less (ADR-1411).
501
1282
  if (roadmapPath !== undefined)
502
1283
  reportUnreadableRoadmap(err, roadmapPath);
503
- return { version: 'v1.0', name: 'milestone' };
1284
+ return scoped(null, SCOPE.UNREADABLE);
504
1285
  }
505
1286
  }
506
1287
  /**
@@ -527,13 +1308,23 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
527
1308
  let missingExplicitVersion = false;
528
1309
  let versionScoped = false;
529
1310
  let versionSectionFound = false;
1311
+ let scope = SCOPE.UNREADABLE;
530
1312
  try {
531
1313
  const roadmapPath = node_path_1.default.join(planningDir(cwd, ws), 'ROADMAP.md');
532
1314
  const roadmapContent = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
533
1315
  if (roadmapContent === null)
534
1316
  throw new Error('missing');
535
- let roadmap = extractCurrentMilestone(roadmapContent, cwd, ws);
536
- const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
1317
+ const scopedResult = extractCurrentMilestoneScoped(roadmapContent, cwd, ws, phaseIdConvention);
1318
+ let roadmap = scopedResult.value;
1319
+ // Default: the filter's window IS extractCurrentMilestoneScoped's own
1320
+ // window (reused verbatim, not re-derived — ADR-3180 Decision 4c).
1321
+ // Overwritten below when `versionOverride` scopes to a DIFFERENT window.
1322
+ scope = scopedResult.scope;
1323
+ // #3184: routed through the shared owner (was an inline copy — see the
1324
+ // twin copy in `extractCurrentMilestoneScoped`, the intra-owner-file
1325
+ // duplicate review caught since the drift guard exempts this file by
1326
+ // construction).
1327
+ const hasVersionedMilestonesGlobal = hasVersionedMilestones(roadmapContent);
537
1328
  const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
538
1329
  if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
539
1330
  console.warn('[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
@@ -541,76 +1332,52 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
541
1332
  'ROADMAP does not use versioned milestone headings. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default).');
542
1333
  }
543
1334
  if (versionOverride) {
544
- const escapedVersion = escapeRegex(versionOverride);
545
- const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
546
- let sectionMatch = roadmapContent.match(sectionPattern);
547
- if (!sectionMatch) {
548
- const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
549
- const summaryHit = roadmapContent.match(summaryPat);
550
- if (summaryHit) {
551
- const beforeSummary = roadmapContent.slice(0, summaryHit.index);
552
- const detailsIdx = beforeSummary.lastIndexOf('<details');
553
- if (detailsIdx !== -1) {
554
- sectionMatch = null;
555
- }
556
- }
1335
+ // #3184: route the whole "locate headings -> pick the active one ->
1336
+ // section-end" composition through the single owner (sliceMilestoneWindow)
1337
+ // instead of assembling it here. This branch used to be a bare `.match()`
1338
+ // — first hit, no closed-heading skip, no version-token boundary — and a
1339
+ // review pass caught it independently re-composing the SAME primitives
1340
+ // `cmdMilestoneComplete`'s guard composed, disagreeing on closed-heading
1341
+ // skipping. Now both sites call one function. Boundary-matched
1342
+ // (`(?![\w.-])`) and closed-heading-skipping is a declared Tier-2 change
1343
+ // affecting every caller that passes `versionOverride`: `roadmap.analyze`
1344
+ // / `milestone complete` (this module, `cmdMilestoneComplete` in
1345
+ // milestone.cts), `inspectWorkstream` (workstream-inventory.cts:518,
1346
+ // via `currentVersion`), and `buildStateFrontmatter` (state.cts:1700,
1347
+ // via `storedMilestone`).
1348
+ const sliced = sliceMilestoneWindow(roadmapContent, versionOverride);
1349
+ const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(roadmapContent), phaseIdConvention);
1350
+ if (sliced !== null) {
1351
+ versionScoped = true;
1352
+ versionSectionFound = true;
1353
+ roadmap = sliced;
557
1354
  }
558
- if (!sectionMatch) {
559
- const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
1355
+ else {
1356
+ const escapedVersion = (0, pattern_cjs_1.escapeRegex)(versionOverride);
560
1357
  const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
561
- if (hasVersionedMilestones && !versionInSummary) {
1358
+ if (hasVersionedMilestonesGlobal && !versionInSummary) {
562
1359
  roadmap = '';
563
1360
  missingExplicitVersion = true;
564
1361
  }
1362
+ // else: version appears only inside a `<summary>`, or there are no
1363
+ // versioned milestones anywhere — `roadmap` keeps
1364
+ // extractCurrentMilestoneScoped's own (STATE-scoped) result, matching
1365
+ // the pre-existing summary-block / free-form fallback shape.
565
1366
  }
566
- else {
567
- versionScoped = true;
568
- versionSectionFound = true;
569
- const sectionStart = sectionMatch.index;
570
- const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
571
- const afterHeading = sectionStart + sectionMatch[0].length;
572
- // Use tokenizeHeadings (fence-aware, offsets into original content) to find
573
- // the next milestone-boundary heading. T4 seam migration.
574
- const allHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent);
575
- let sectionEnd = roadmapContent.length;
576
- for (const h of allHeadings) {
577
- if (h.offset < afterHeading)
578
- continue;
579
- if (h.level > headingLevel)
580
- continue;
581
- if (/^Phase\s+\S/i.test(h.text))
582
- continue;
583
- if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
584
- continue;
585
- sectionEnd = h.offset;
586
- break;
587
- }
588
- const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
589
- roadmap = currentSection;
590
- }
591
- }
592
- // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
593
- // T4 seam migration: phase headings inside fences are excluded automatically.
594
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
595
- const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
596
- for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
597
- if (h.level < 2 || h.level > 4)
598
- continue;
599
- const pm = phaseHeadingPattern.exec(h.text);
600
- // Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter.
601
- if (pm && !/^999\b/.test(pm[1]))
602
- milestonePhaseNums.add(pm[1]);
1367
+ scope = classifyMilestoneWindow({
1368
+ readable: true,
1369
+ versionResolved: true,
1370
+ hasVersionedMilestones: hasVersionedMilestonesGlobal,
1371
+ headingFound: sliced !== null,
1372
+ windowHasPhaseEntries: hasPhaseEntries(roadmap, phaseIdConvention),
1373
+ documentHasPhaseEntries,
1374
+ });
603
1375
  }
604
- // #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
605
- // so a bullet-house-style ROADMAP populates the milestone phase set instead of
606
- // collapsing to a zero-count pass-all filter.
607
- {
608
- let bm;
609
- const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
610
- while ((bm = scanner.exec(roadmap)) !== null) {
611
- if (!/^999\b/.test(bm[1]))
612
- milestonePhaseNums.add(bm[1]);
613
- }
1376
+ // #3262: the set-building scan now lives in its own named owner
1377
+ // (`scanMilestonePhaseIds`) so the new `roadmap milestone-scope` probe
1378
+ // reads the SAME derivation this filter does — never a second copy.
1379
+ for (const id of scanMilestonePhaseIds(roadmap)) {
1380
+ milestonePhaseNums.add(id);
614
1381
  }
615
1382
  }
616
1383
  catch {
@@ -619,7 +1386,9 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
619
1386
  * any failure milestonePhaseNums stays empty, which below already
620
1387
  * degrades to the same pass-all filter this function returns when a
621
1388
  * ROADMAP genuinely has zero recognizable phase headings — a safe,
622
- * non-corrupting (over-inclusive, never under-inclusive) degrade. */
1389
+ * non-corrupting (over-inclusive, never under-inclusive) degrade.
1390
+ * #3184: `scope` was set to SCOPE.UNREADABLE before the try (row 2) and
1391
+ * is left as-is here — the read/parse fault IS the unreadable case. */
623
1392
  }
624
1393
  if (milestonePhaseNums.size === 0) {
625
1394
  const passAll = (() => true);
@@ -630,6 +1399,10 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
630
1399
  // `versionScoped` is reset here — this is the only surviving evidence that
631
1400
  // the current milestone exists in the ROADMAP and simply has no phases yet.
632
1401
  passAll.versionSectionFound = versionSectionFound;
1402
+ // #3184: the filter's FUNCTION behavior is unchanged — pass-all still
1403
+ // passes all. `scope` is the decidable signal a destructive consumer
1404
+ // reads to refuse instead (ADR-3180 Decision 3's two-tier policy).
1405
+ passAll.scope = scope;
633
1406
  return passAll;
634
1407
  }
635
1408
  function normalizePhaseIdSegments(id) {
@@ -640,6 +1413,10 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
640
1413
  // second copy of that logic — the drift-prone shape this issue is about.
641
1414
  const normalized = new Set([...milestonePhaseNums].map(n => normalizePhaseIdSegments(n).toLowerCase()));
642
1415
  const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
1416
+ // #3213: longest-first so a hyphenated declared ID (e.g. "proj-42") is tested
1417
+ // before a prefix of it (e.g. "proj") in the segment-boundary membership loop
1418
+ // below — otherwise the shorter id would admit a dir that belongs to the longer.
1419
+ const normalizedIdsLongestFirst = [...normalized].sort((a, b) => b.length - a.length);
643
1420
  // #2043: milestone-prefixed sub-phase components must be zero-padded — so a
644
1421
  // single-digit slug word after the phase
645
1422
  // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
@@ -648,28 +1425,63 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
648
1425
  // "14-2026-photos-…") captures "14" and is not excluded as a bogus "14-2026" id.
649
1426
  // Built via new RegExp (no /i — the [A-Za-z] letter class does real case handling).
650
1427
  const numericRe = roadmapUsesHyphenedIds
651
- ? new RegExp(`^0*(\\d+(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Za-z]?(?:\\.\\d+)*)`)
1428
+ ? new RegExp(`^0*(\\d+[A-Za-z]?(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?)*(?:\\.\\d+)*)(?=-|$)`)
652
1429
  // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
653
1430
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
654
1431
  function isDirInMilestone(dirName) {
655
1432
  const m2 = dirName.match(numericRe);
656
1433
  if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase()))
657
1434
  return true;
658
- const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
659
- if (customMatch && normalized.has(customMatch[1].toLowerCase()))
660
- return true;
1435
+ // #3213: segment-boundary membership test, scoped to LETTER-LEADING (custom-
1436
+ // ID) directories only. The prior greedy capture
1437
+ // `^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)` swallowed the WHOLE hyphenated
1438
+ // directory name (A-tool-output-contract was captured as
1439
+ // "A-tool-output-contract", not "A"), so every letter-named phase directory
1440
+ // (Phase A:..Phase L: — GSD's own convention, ADR-612 first-class non-numeric
1441
+ // IDs) fell out of the milestone and counts were silently fabricated over
1442
+ // whatever numeric directory survived. A letter-leading directory belongs if
1443
+ // its lowercased name EQUALS a declared phase ID, or BEGINS with that ID
1444
+ // followed by "-" (so "A-tool-output-contract" matches ID "a";
1445
+ // "PROJ-42-description" matches ID "proj-42"; "AB-combined" does NOT match
1446
+ // "a"). SCOPED TO LETTER-LEADING DIRS because numeric dirs are owned by
1447
+ // numericRe above, which respects the #2232 continuation grammar — a bare
1448
+ // startsWith here would wrongly admit "14-02-photos-…" to phase "14" when its
1449
+ // real token is "14-02" (continuation-absorbed, not declared).
1450
+ if (/^[A-Za-z]/.test(dirName)) {
1451
+ const lowerDir = dirName.toLowerCase();
1452
+ for (const id of normalizedIdsLongestFirst) {
1453
+ if (lowerDir === id || lowerDir.startsWith(id + '-'))
1454
+ return true;
1455
+ }
1456
+ }
661
1457
  const stripped = stripProjectCodePrefix(dirName);
662
1458
  if (stripped !== dirName) {
663
1459
  const sm = stripped.match(numericRe);
664
1460
  if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase()))
665
1461
  return true;
666
1462
  }
1463
+ // #3185: last resort — ask the CANONICAL phase-id token extractor. The
1464
+ // three attempts above are all leading-DIGIT or bare-alnum shapes, so none
1465
+ // of them can match a #1324 letter-prefixed-DECIMAL directory
1466
+ // (`P0.0-foundation`) against its own `### Phase P0.0:` heading: numericRe
1467
+ // needs a leading digit, `customMatch` stops at the `.` and yields `P0`,
1468
+ // and stripProjectCodePrefix needs a dash before the digit. The observable
1469
+ // symptom was `stats` reporting such a phase with plans: 0 while its
1470
+ // directory held plan files, because the heading seeded the row but the
1471
+ // directory never folded in. extractPhaseToken is #2121's single owner of
1472
+ // "what is this directory's phase token", so this defers to it rather than
1473
+ // widening a fourth bespoke regex here. Additive: it can only ADMIT a
1474
+ // directory, never exclude one the attempts above already matched.
1475
+ const token = extractPhaseToken(dirName);
1476
+ if (token && normalized.has(normalizePhaseIdSegments(String(token)).toLowerCase()))
1477
+ return true;
667
1478
  return false;
668
1479
  }
669
1480
  isDirInMilestone.phaseCount = milestonePhaseNums.size;
670
1481
  isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
671
1482
  isDirInMilestone.versionScoped = versionScoped;
672
1483
  isDirInMilestone.versionSectionFound = versionSectionFound;
1484
+ isDirInMilestone.scope = scope;
673
1485
  return isDirInMilestone;
674
1486
  }
675
1487
  /**
@@ -678,12 +1490,12 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
678
1490
  * writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
679
1491
  * same-numbered phase in a shipped milestone.
680
1492
  *
681
- * Mirrors the region selection in `extractCurrentMilestone` (version detection →
682
- * active heading → next milestone boundary → optional Phase Details section).
683
- * Returns null when there is no versioned active milestone; callers then fall
684
- * back to whole-content mutation (the prior behaviour).
685
- *
686
- * NOTE: keep the region logic here in sync with extractCurrentMilestone.
1493
+ * Mirrors the region selection in `extractCurrentMilestoneScoped` (version
1494
+ * detection → active heading → next milestone boundary → optional Phase
1495
+ * Details section) — both consume the same `locateMilestoneHeadings` /
1496
+ * `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
1497
+ * keep in sync. Returns null when there is no versioned active milestone;
1498
+ * callers then fall back to whole-content mutation (the prior behaviour).
687
1499
  */
688
1500
  function currentMilestoneRawRanges(content, cwd) {
689
1501
  if (!cwd)
@@ -706,37 +1518,18 @@ function currentMilestoneRawRanges(content, cwd) {
706
1518
  }
707
1519
  if (!version)
708
1520
  return null;
709
- const escapedVersion = escapeRegex(version);
710
- const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi');
711
- const headingMatches = [...content.matchAll(sectionPattern)];
1521
+ const headingMatches = locateMilestoneHeadings(content, version);
712
1522
  if (headingMatches.length === 0)
713
1523
  return null;
714
1524
  const isClosed = isClosedMilestoneHeading;
715
- const firstMatch = headingMatches[0];
716
- const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
1525
+ // #3184: selection collapses to the sole owner; `headingMatches` is still
1526
+ // needed below for the detailsMatch search over all headings.
1527
+ const selected = selectMilestoneHeading(content, version);
717
1528
  const sectionStart = selected.index ?? 0;
718
- const computeSectionEnd = (headingText, headingStart) => {
719
- const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
720
- const afterHeading = headingStart + headingText.length;
721
- for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
722
- if (h.offset <= headingStart)
723
- continue;
724
- if (h.offset < afterHeading)
725
- continue;
726
- if (h.level > level)
727
- continue;
728
- if (/^Phase\s+\S/i.test(h.text))
729
- continue;
730
- if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
731
- continue;
732
- return h.offset;
733
- }
734
- return content.length;
735
- };
736
- const sectionEnd = computeSectionEnd(selected[0], sectionStart);
1529
+ const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
737
1530
  const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
738
1531
  const detailsVersionBoundary = selectedVersionToken
739
- ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
1532
+ ? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
740
1533
  : null;
741
1534
  const detailsMatch = headingMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
742
1535
  !isClosed(m[1]) &&
@@ -745,18 +1538,47 @@ function currentMilestoneRawRanges(content, cwd) {
745
1538
  let details = null;
746
1539
  if (detailsMatch) {
747
1540
  const detailsStart = detailsMatch.index ?? 0;
748
- details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) };
1541
+ details = { start: detailsStart, end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart) };
749
1542
  }
750
1543
  return { primary: { start: sectionStart, end: sectionEnd }, details };
751
1544
  }
752
1545
  module.exports = {
753
1546
  stripShippedMilestones,
754
1547
  extractCurrentMilestone,
1548
+ extractCurrentMilestoneScoped,
755
1549
  isMilestoneShippedInRoadmap,
1550
+ isMilestoneBoundedInRoadmap,
756
1551
  replaceInCurrentMilestone,
757
1552
  getRoadmapPhaseInternal,
758
1553
  getMilestoneInfo,
759
1554
  getMilestonePhaseFilter,
760
1555
  currentMilestoneRawRanges,
761
1556
  withPhaseSection,
1557
+ computeMilestoneSectionEnd,
1558
+ locateMilestoneHeadings,
1559
+ listMilestoneHeadings,
1560
+ selectMilestoneHeading,
1561
+ classifyMilestoneWindow,
1562
+ // #3184: the sole "give me this version's window" composition — see its
1563
+ // own doc comment. milestone.cts's destructive-consumer guard consumes
1564
+ // this instead of composing locate+select+section-end itself.
1565
+ sliceMilestoneWindow,
1566
+ hasVersionedMilestones,
1567
+ hasMilestoneSectioning,
1568
+ // #3642: the >=1 sibling buildStateFrontmatter's unbounded branch consumes.
1569
+ hasAnyMilestoneSection,
1570
+ // #1956: sole owner of the #2012 decoy-avoidance scope for the
1571
+ // `drift-guard phase-status` CLI seam.
1572
+ findRoadmapProgressTable,
1573
+ // #3262 (write-time milestone-scope guard): the window phase-id scan owner
1574
+ // (consumed by getMilestonePhaseFilter above and the roadmap milestone-scope
1575
+ // CLI probe) and the free-text predicate the phase add/add-batch/insert
1576
+ // guards and the edit-phase workflow's pre/post capture are built on.
1577
+ scanMilestonePhaseIds,
1578
+ collectTablePhaseRows,
1579
+ findMilestoneScopeHeadingLines,
1580
+ // #3641: the scope axis's phase-ENTRY predicate, exported so roadmap
1581
+ // validate's V004 document-level check routes through the same single
1582
+ // owner (and its convention gate) instead of a private inline copy.
1583
+ hasPhaseEntries,
762
1584
  };