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