@opengsd/gsd-core 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -21,9 +21,11 @@
21
21
  var __importDefault = (this && this.__importDefault) || function (mod) {
22
22
  return (mod && mod.__esModule) ? mod : { "default": mod };
23
23
  };
24
+ const node_crypto_1 = require("node:crypto");
24
25
  const node_fs_1 = __importDefault(require("node:fs"));
25
26
  const node_path_1 = __importDefault(require("node:path"));
26
27
  const pattern_cjs_1 = require("./pattern.cjs");
28
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
27
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
30
  const phaseIdModule = require("./phase-id.cjs");
29
31
  const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
@@ -53,10 +55,14 @@ const planningWorkspace = require("./planning-workspace.cjs");
53
55
  const { planningDir, resolvePhaseIdConvention } = planningWorkspace;
54
56
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
55
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const frontmatterMod = require("./frontmatter.cjs");
59
+ const { rawFrontmatterMapping } = frontmatterMod;
60
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
61
  const unusableInputMod = require("./unusable-input.cjs");
57
62
  const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
58
63
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
59
64
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
65
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
60
66
  // eslint-disable-next-line @typescript-eslint/no-require-imports
61
67
  const planningScopeMod = require("./planning-scope.cjs");
62
68
  const { SCOPE } = planningScopeMod;
@@ -72,12 +78,94 @@ const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progres
72
78
  function isClosedMilestoneHeading(headingText) {
73
79
  return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
74
80
  }
81
+ /**
82
+ * Is this `<details>` block a CLOSED milestone? One test for the read-side strip
83
+ * and the write-side mask. The `<summary>` is read with inline tags removed
84
+ * (`<b>v1.0</b> ✅`); a milestone-shaped summary (version token or "milestone")
85
+ * that says "Complete(d)" counts too. No `<summary>` means not closed.
86
+ */
87
+ function isClosedMilestoneBlock(block) {
88
+ const summary = block.match(/<summary[^>]*>([\s\S]{0,1000}?)<\/summary>/i);
89
+ if (!summary)
90
+ return false;
91
+ const text = summary[1].replace(/<[^>]{0,200}>/g, '');
92
+ return isClosedMilestoneHeading(text)
93
+ || (/\bcomplete(?:d)?\b/i.test(text) && /\bv\d+(?:\.\d+)*\b|\bmilestone\b/i.test(text)
94
+ && !MILESTONE_ACTIVE_MARKER_PATTERN.test(text));
95
+ }
75
96
  /**
76
97
  * Strip shipped milestone content wrapped in <details> blocks.
77
98
  */
78
99
  function stripShippedMilestones(content) {
79
100
  return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
80
101
  }
102
+ /**
103
+ * #5543: hide every CLOSED-milestone `<details>` block (`<details>` opener, a
104
+ * `<summary>` that `isClosedMilestoneHeading` reads as shipped/archived, the
105
+ * #3982 test) behind a placeholder comment; `restore` puts them back
106
+ * byte-for-byte, or returns null when `edited` lost or duplicated a placeholder.
107
+ * For writers whose first `### Phase N:` in document order must never sit in a
108
+ * shipped block. Other `<details>` (notes, a phase wrapper, an active
109
+ * milestone's collapsed block) stay visible, so a roadmap with no archived
110
+ * milestone is edited as before. Blocks are matched by depth, so a notes
111
+ * `<details>` nested inside a shipped one stays inside it (the flat
112
+ * `taggedBlockPattern` closes at the inner `</details>`). An unmatched
113
+ * opener (prose, a fence) wraps nothing, so a later shipped block is still
114
+ * masked. Fences are not parsed, as in every ROADMAP `<details>` reader here.
115
+ */
116
+ function maskShippedBlocks(content) {
117
+ const blocks = [];
118
+ let nonce = 0;
119
+ while (content.includes(`<!-- gsd-masked-shipped-${nonce}-`))
120
+ nonce += 1;
121
+ const placeholder = (i) => `<!-- gsd-masked-shipped-${nonce}-${i} -->`;
122
+ const tags = /<details(?:\s[^>]{0,1000})?>|<\/details>/gi;
123
+ // Matched pairs by stack; an unmatched opener (prose, a fence) never closes, so it
124
+ // wraps nothing and a later block is still found. `outer` keeps only the pairs not
125
+ // nested in another matched pair.
126
+ const open = [];
127
+ const outer = [];
128
+ for (let m = tags.exec(content); m !== null; m = tags.exec(content)) {
129
+ if (m[0][1] !== '/')
130
+ open.push(m.index);
131
+ else if (open.length > 0) {
132
+ const start = open.pop();
133
+ while (outer.length > 0 && outer[outer.length - 1][0] >= start)
134
+ outer.pop();
135
+ outer.push([start, m.index + m[0].length]);
136
+ }
137
+ }
138
+ let masked = '';
139
+ let copied = 0;
140
+ for (const [start, end] of outer) {
141
+ const block = content.slice(start, end);
142
+ if (block.startsWith('<details>') && isClosedMilestoneBlock(block)) {
143
+ masked += content.slice(copied, start) + placeholder(blocks.length);
144
+ blocks.push(block);
145
+ copied = end;
146
+ }
147
+ }
148
+ masked += content.slice(copied);
149
+ return {
150
+ masked,
151
+ restore(edited) {
152
+ let restored = edited;
153
+ for (let i = 0; i < blocks.length; i++) {
154
+ const marker = placeholder(i);
155
+ const at = restored.indexOf(marker);
156
+ if (at === -1 || restored.indexOf(marker, at + marker.length) !== -1)
157
+ return null;
158
+ restored = restored.slice(0, at) + blocks[i] + restored.slice(at + marker.length);
159
+ }
160
+ return restored;
161
+ },
162
+ };
163
+ }
164
+ function editOutsideShippedBlocks(content, edit) {
165
+ const { masked, restore } = maskShippedBlocks(content);
166
+ const edited = edit(masked);
167
+ return (typeof edited === 'string' ? restore(edited) : null) ?? content;
168
+ }
81
169
  /**
82
170
  * #3982: strip only <details> blocks whose <summary> marks a CLOSED milestone
83
171
  * (ARCHIVED/SHIPPED/✅/… without an active marker) — the narrow form of
@@ -86,12 +174,15 @@ function stripShippedMilestones(content) {
86
174
  * reproduce the phase_count: 0 class of #557/#2947.
87
175
  */
88
176
  function stripClosedMilestoneDetails(content) {
89
- return content.replace(/<details\b[^>]*>[\s\S]*?<\/details>/gi, (block) => {
90
- const summaryMatch = block.match(/<summary[^>]*>([^<]*)<\/summary>/i);
91
- if (!summaryMatch)
92
- return block;
93
- return isClosedMilestoneHeading(summaryMatch[1]) ? '' : block;
94
- });
177
+ return content.replace(/<details\b[^>]*>[\s\S]*?<\/details>/gi, (block) => (isClosedMilestoneBlock(block) ? '' : block));
178
+ }
179
+ /**
180
+ * Classify a complete `<details>` block through isClosedMilestoneBlock, the one rule shared with
181
+ * stripClosedMilestoneDetails and the write-side shipped-block mask. Destructive consumers share this predicate so
182
+ * an active collapsed phase list is never reclassified as shipped history.
183
+ */
184
+ function isClosedMilestoneDetails(block) {
185
+ return isClosedMilestoneBlock(block);
95
186
  }
96
187
  /**
97
188
  * #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
@@ -347,6 +438,25 @@ function listMilestoneHeadings(content) {
347
438
  // rather than typing `^#{1,3}\s+(?!Phase\s+\S)` a second time — the exact
348
439
  // duplication class ADR-3180 §7.2's widened guard exists to catch.
349
440
  const MILESTONE_HEADING_LINE_SOURCE = '^#{1,3}\\s+(?!Phase\\s+\\S)[^\\n]*';
441
+ /**
442
+ * #4304: classify one level-1..3 heading through the same milestone grammar
443
+ * the window locator owns. Phase headings are excluded first through the
444
+ * reader's own legacy/bracket discriminators, regardless of words or markers
445
+ * in their titles; `Failed Payment Recovery` and `Done ✅` remain phase
446
+ * titles, never milestone section boundaries. Version-bearing headings route
447
+ * through `listMilestoneHeadings`; version-less bracket headings route through
448
+ * `isBracketMilestoneBoundary`, the discriminator also used by the bracket
449
+ * fallback locator.
450
+ */
451
+ function isRecognizedMilestoneHeading(headingText, level) {
452
+ if (level < 1 || level > 3)
453
+ return false;
454
+ if (/^Phase\s+\S/i.test(headingText) || BRACKET_PHASE_TAIL_RE.test(headingText))
455
+ return false;
456
+ const line = `${'#'.repeat(level)} ${headingText}`;
457
+ return listMilestoneHeadings(line).length > 0
458
+ || isBracketMilestoneBoundary(headingText, level, null);
459
+ }
350
460
  /**
351
461
  * #3184: the sole milestone-heading locator. Boundary-matched on the version
352
462
  * token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
@@ -665,23 +775,32 @@ const PHASE_HEADING_TEXT_RE = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:
665
775
  * leading `#{1,6}` markers and surrounding whitespace already stripped
666
776
  * (`tokenizeHeadings`'s own `h.text`, or the equivalent for a line a caller
667
777
  * already knows is a `#{2,4}` heading) — is a phase heading by the readers'
668
- * own grammar. The single owner of this test; `hasPhaseEntries` below is
669
- * itself just the first caller.
778
+ * own grammar. The single owner of this test: `hasPhaseEntries` reaches it
779
+ * through `isPhaseEntryHeading` below, and the bracket roadmap migrator
780
+ * calls it directly.
670
781
  */
671
782
  function isPhaseHeadingText(headingText) {
672
783
  return PHASE_HEADING_TEXT_RE.test(headingText);
673
784
  }
785
+ /**
786
+ * Does one fence-excluded heading token use the reader's phase-entry grammar?
787
+ * Kept separate from heading depth so destructive consumers can apply the
788
+ * same bracket-plus-legacy recognition to tokens from `tokenizeHeadings`.
789
+ * The legacy arm is `isPhaseHeadingText`, that grammar's single owner.
790
+ */
791
+ function isPhaseEntryHeading(headingText, phaseIdConvention) {
792
+ if (isPhaseHeadingText(headingText))
793
+ return true;
794
+ return phaseIdConvention === 'bracket' && BRACKET_PHASE_ENTRY_HEADING_RE.test(headingText);
795
+ }
674
796
  function hasPhaseEntries(markdown, phaseIdConvention) {
675
797
  // #3641: the widened grammar engages ONLY when the resolved convention is
676
798
  // 'bracket' — a project that has not opted in runs the legacy pattern
677
799
  // alone, byte-identically.
678
- const bracketMode = phaseIdConvention === 'bracket';
679
800
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(markdown)) {
680
801
  if (h.level < 2 || h.level > 4)
681
802
  continue;
682
- if (isPhaseHeadingText(h.text))
683
- return true;
684
- if (bracketMode && BRACKET_PHASE_ENTRY_HEADING_RE.test(h.text))
803
+ if (isPhaseEntryHeading(h.text, phaseIdConvention))
685
804
  return true;
686
805
  }
687
806
  // #3184 review finding: the bullet fallback must be fence-aware too, or a
@@ -775,7 +894,7 @@ function hasPhaseListingTableHeader(content) {
775
894
  * narrower question — "which phase ids does this window declare" — where only
776
895
  * the 999 icebox range is excluded.
777
896
  */
778
- function scanMilestonePhaseIdSets(window, convention) {
897
+ function scanMilestonePhaseIdSets(window, convention, context) {
779
898
  const ids = new Set();
780
899
  const qualifiedIds = new Set();
781
900
  // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
@@ -786,7 +905,7 @@ function scanMilestonePhaseIdSets(window, convention) {
786
905
  // belonging to different milestones.
787
906
  const capturing = convention === 'bracket';
788
907
  const bracketGroups = capturing ? 1 : 0;
789
- const phaseHeadingPattern = new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
908
+ const phaseHeadingPattern = new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}(${context?.phaseNaming === 'custom' && convention !== 'bracket' ? phaseIdModule.phaseScanTokenSource(context) : '[\\w][\\w.-]*'})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
790
909
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(window)) {
791
910
  if (h.level < 2 || h.level > 4)
792
911
  continue;
@@ -944,6 +1063,87 @@ function classifyMilestoneWindow(input) {
944
1063
  const PHASE_HEADING_BLOCK_STRIP_RE = new RegExp(
945
1064
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` (OPTIONAL_PHASE_TAG_SOURCE) tolerates a pre-colon ( ) tag.
946
1065
  `^#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)}${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[^\\n]*(?:\\n(?!#{1,6}\\s)[^\\n]*)*\\n?`, 'gim');
1066
+ /**
1067
+ * #612/#4304: the bracket-fallback candidate search for a milestone heading
1068
+ * carrying no vX.Y version token at all — the ADR-612 canonical shape
1069
+ * (`## [GSD.09] Hidden`). Tried by a caller only when the legacy
1070
+ * version-string search (`locateMilestoneHeadings`) finds nothing and the
1071
+ * project is on the bracket convention. Extracted (#4304 PR-4) from
1072
+ * `extractCurrentMilestoneScoped`'s own inline block so it and
1073
+ * `currentMilestoneRawRanges` share one heading grammar instead of each
1074
+ * re-deriving the bracket intro pattern independently, the Generative Fix
1075
+ * Divergence class #2761 documents. Byte-identical logic to the block it replaces; moving it
1076
+ * changes no behavior.
1077
+ */
1078
+ function bracketFallbackHeadingMatches(content, version, bracketScopeConvention) {
1079
+ if (bracketScopeConvention !== 'bracket')
1080
+ return [];
1081
+ const vMatch = version.match(/^v(\d+)/i);
1082
+ const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
1083
+ if (!Number.isSafeInteger(milestoneInt))
1084
+ return [];
1085
+ // #2761 M3: the phase-id owner supplies both the bracket intro grammar
1086
+ // and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a
1087
+ // section whose phase headings the bracket grammar rejects.
1088
+ // #612: HeadingToken.offset is the line start, so require the
1089
+ // `#` there to preserve the old line-start anchor's indentation parity.
1090
+ // For every survivor, rebuilding [fullLine, fullLine] plus `.index`
1091
+ // retains the match shape and first-match order expected downstream.
1092
+ const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
1093
+ return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
1094
+ .filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
1095
+ .map((h) => {
1096
+ const lineEnd = content.indexOf('\n', h.offset);
1097
+ const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd);
1098
+ return Object.assign([fullLine, fullLine], { index: h.offset });
1099
+ });
1100
+ }
1101
+ /**
1102
+ * #612/#4304: the milestone-heading SELECTION fallback chain — prefers the
1103
+ * canonical legacy selector (`selectMilestoneHeading`), then falls back to
1104
+ * the gated bracket candidates (`headingMatches`, already populated by
1105
+ * `bracketFallbackHeadingMatches` when the legacy selector's own internal
1106
+ * `locateMilestoneHeadings` call finds nothing) when a name-only bracket
1107
+ * milestone carries no version token for that selector to find. Shared
1108
+ * (#4304 PR-4) by `extractCurrentMilestoneScoped` and
1109
+ * `currentMilestoneRawRanges` so both agree on which heading is "the active
1110
+ * one" for a version-less bracket milestone. `headingMatches` must be
1111
+ * non-empty (every caller checks this first).
1112
+ */
1113
+ function selectActiveMilestoneHeading(content, version, headingMatches) {
1114
+ return selectMilestoneHeading(content, version)
1115
+ ?? headingMatches.find((m) => !isClosedMilestoneHeading(m[1]))
1116
+ ?? headingMatches[0];
1117
+ }
1118
+ /**
1119
+ * #612/#4304: resolves the bracket-aware milestone SECTION END, the
1120
+ * SELECTED heading's own bracket id, and the `bracketBoundary` predicate
1121
+ * itself — the caller reuses all three afterward (`extractCurrentMilestoneScoped`'s
1122
+ * own "earliest milestone heading" preamble scan reuses `selectedBracketId`;
1123
+ * its "(Phase Details)" section-end lookup reuses `bracketBoundary`).
1124
+ * `bracketBoundaryActive` and `currentMilestoneHeadings` are supplied by the
1125
+ * caller (not recomputed here) because both callers need them again
1126
+ * afterward for their own, divergent purposes. Shared (#4304 PR-4) by
1127
+ * `extractCurrentMilestoneScoped` and `currentMilestoneRawRanges` so a
1128
+ * version-less bracket milestone heading (`## [GSD.09] Hidden`) bounds its
1129
+ * section identically for both, instead of one of them silently running the
1130
+ * LEGACY-only (vX.Y/emoji-only) boundary rule. Byte-identical logic to the
1131
+ * block it replaces; moving it changes no behavior.
1132
+ */
1133
+ function bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings) {
1134
+ // #2761 B1: a bracket heading bearing the selected milestone's own id is
1135
+ // a continuation, not a boundary. Derive the selected id once and compose
1136
+ // the shared discriminator with upstream's centralized section-end walk.
1137
+ const selectedBracketMatch = bracketBoundaryActive
1138
+ ? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i'))
1139
+ : null;
1140
+ const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null;
1141
+ const bracketBoundary = bracketBoundaryActive
1142
+ ? (heading) => isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId)
1143
+ : undefined;
1144
+ const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart, bracketBoundary, currentMilestoneHeadings);
1145
+ return { sectionEnd, selectedBracketId, bracketBoundary };
1146
+ }
947
1147
  /**
948
1148
  * #5038: single owner for classifying a `milestone:` scalar as STATE.md spells
949
1149
  * it. The frontmatter parser (FAILSAFE_SCHEMA) and the raw-regex readers both
@@ -964,6 +1164,28 @@ function classifyMilestoneScalar(raw) {
964
1164
  return { explicitNull: true, version: null };
965
1165
  return { explicitNull: false, version: trimmed };
966
1166
  }
1167
+ /**
1168
+ * #4998: STATE.md's `milestone:` scalar as the frontmatter parser DECODES it.
1169
+ * The raw `^milestone:` line read captured the on-disk YAML text — quotes and
1170
+ * escapes included — and that text was re-serialized as the value, adding one
1171
+ * escape layer per `state.*` write. Falls back to the legacy line read only
1172
+ * when there is no parseable frontmatter, or its `milestone` is not text. One
1173
+ * that parses without a `milestone` key names none: a `milestone:` line further
1174
+ * down (a fenced example) is not one (#4998 review, Minor 5).
1175
+ */
1176
+ function readStateMilestoneScalar(stateRaw) {
1177
+ const fm = rawFrontmatterMapping(stateRaw);
1178
+ if (fm) {
1179
+ if (!Object.prototype.hasOwnProperty.call(fm, 'milestone'))
1180
+ return null;
1181
+ if (typeof fm['milestone'] === 'string')
1182
+ return fm['milestone'];
1183
+ // A non-string value keeps the pre-#4998 line read, which finds this
1184
+ // frontmatter line first.
1185
+ }
1186
+ const m = stateRaw.match(/^milestone:\s*(.+)/m);
1187
+ return m ? m[1] : null;
1188
+ }
967
1189
  /**
968
1190
  * Extract the current milestone section from ROADMAP.md by positive lookup,
969
1191
  * carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
@@ -1003,9 +1225,9 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1003
1225
  const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
1004
1226
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
1005
1227
  if (stateRaw !== null) {
1006
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
1007
- if (milestoneMatch) {
1008
- const classified = classifyMilestoneScalar(milestoneMatch[1]);
1228
+ const milestoneScalar = readStateMilestoneScalar(stateRaw);
1229
+ if (milestoneScalar !== null) {
1230
+ const classified = classifyMilestoneScalar(milestoneScalar);
1009
1231
  version = classified.version;
1010
1232
  stateAssertsNoMilestone = classified.explicitNull;
1011
1233
  }
@@ -1056,26 +1278,8 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1056
1278
  }
1057
1279
  catch { /* unresolvable convention → preserve the legacy fallback */ }
1058
1280
  }
1059
- if (headingMatches.length === 0 && bracketScopeConvention === 'bracket') {
1060
- const vMatch = version.match(/^v(\d+)/i);
1061
- const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
1062
- if (Number.isSafeInteger(milestoneInt)) {
1063
- // #2761 M3: the phase-id owner supplies both the bracket intro grammar
1064
- // and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a
1065
- // section whose phase headings the bracket grammar rejects.
1066
- // #612 round-5: HeadingToken.offset is the line start, so require the
1067
- // `#` there to preserve the old line-start anchor's indentation parity.
1068
- // For every survivor, rebuilding [fullLine, fullLine] plus `.index`
1069
- // retains the match shape and first-match order expected downstream.
1070
- const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
1071
- headingMatches = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
1072
- .filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
1073
- .map((h) => {
1074
- const lineEnd = content.indexOf('\n', h.offset);
1075
- const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd);
1076
- return Object.assign([fullLine, fullLine], { index: h.offset });
1077
- });
1078
- }
1281
+ if (headingMatches.length === 0) {
1282
+ headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
1079
1283
  }
1080
1284
  if (headingMatches.length === 0) {
1081
1285
  const summaryMatch = content.match(summaryPattern);
@@ -1130,29 +1334,14 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1130
1334
  // #3184: selection collapses to the sole owner; `allMatches` is still needed
1131
1335
  // below (offsets, detailsMatch search), so only the selection itself routes
1132
1336
  // through `selectMilestoneHeading` rather than the whole block.
1133
- // Preserve the canonical legacy selector, then fall back to the gated
1134
- // bracket candidates above when a name-only bracket milestone carries no
1135
- // version token for that selector to find.
1136
- const selected = selectMilestoneHeading(content, version)
1137
- ?? allMatches.find((m) => !isClosed(m[1]))
1138
- ?? firstMatch;
1337
+ const selected = selectActiveMilestoneHeading(content, version, allMatches);
1139
1338
  const sectionStart = selected.index;
1140
- // #2761 B1: a bracket heading bearing the selected milestone's own id is
1141
- // a continuation, not a boundary. Derive the selected id once and compose
1142
- // the shared discriminator with upstream's centralized section-end walk.
1143
1339
  const bracketBoundaryActive = bracketScopeConvention === 'bracket';
1144
- const selectedBracketMatch = bracketBoundaryActive
1145
- ? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i'))
1146
- : null;
1147
- const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null;
1148
1340
  // #2761 B3: tokenize once for both the centralized section-end owner and
1149
1341
  // the bracket preamble scan below. This keeps both boundary decisions
1150
1342
  // fence-aware without restoring the local section walker retired by #3184.
1151
1343
  const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1152
- const bracketBoundary = bracketBoundaryActive
1153
- ? (heading) => isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId)
1154
- : undefined;
1155
- const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart, bracketBoundary, currentMilestoneHeadings);
1344
+ const { sectionEnd, selectedBracketId, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
1156
1345
  const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
1157
1346
  let earliestMilestoneIndex;
1158
1347
  if (!bracketBoundaryActive) {
@@ -1375,18 +1564,261 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1375
1564
  function extractCurrentMilestone(content, cwd, ws) {
1376
1565
  return extractCurrentMilestoneScoped(content, cwd, ws).value;
1377
1566
  }
1378
- function replaceInCurrentMilestone(content, pattern, replacement) {
1379
- const apply = (src) => typeof replacement === 'function'
1380
- ? src.replace(pattern, replacement)
1381
- : src.replace(pattern, replacement);
1382
- const lastDetailsClose = content.lastIndexOf('</details>');
1383
- if (lastDetailsClose === -1) {
1384
- return apply(content);
1567
+ /**
1568
+ * Same-length copy of `s` with HTML comments blanked. An unterminated `<!--`, or
1569
+ * one whose `-->` lies beyond the next heading, is bounded at the end of its own
1570
+ * line so a prose mention never hides later archives. Linear: `-->` and heading
1571
+ * lookups are cached while they stay ahead of the cursor.
1572
+ */
1573
+ function blankComments(s) {
1574
+ const heading = /\n#{1,6} /g;
1575
+ let out = '';
1576
+ let i = 0;
1577
+ let close = 0;
1578
+ let head = -2; // -2: heading not looked up yet (-1 means none ahead); offset 0 must still trigger the first lookup
1579
+ for (let o = s.indexOf('<!--'); o !== -1; o = s.indexOf('<!--', i)) {
1580
+ if (close !== -1 && close < o + 4)
1581
+ close = s.indexOf('-->', o + 4);
1582
+ if (head !== -1 && head < o) {
1583
+ heading.lastIndex = o;
1584
+ const m = heading.exec(s);
1585
+ head = m ? m.index : -1;
1586
+ }
1587
+ let end;
1588
+ if (close !== -1 && (head === -1 || close < head))
1589
+ end = close + 3;
1590
+ else {
1591
+ end = s.indexOf('\n', o);
1592
+ if (end === -1)
1593
+ end = s.length;
1594
+ }
1595
+ out += s.slice(i, o) + s.slice(o, end).replace(/[^\r\n]/g, ' ');
1596
+ i = end;
1597
+ }
1598
+ return out + s.slice(i);
1599
+ }
1600
+ const VERSION_TOKEN = /v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i;
1601
+ /** Same-length copy of `content` with fenced blocks, inline code spans and HTML comments blanked. */
1602
+ function blankCodeAndComments(content) {
1603
+ // Same-length copy with fenced blocks, inline code spans and HTML comments
1604
+ // blanked (code first: a backticked `<!--` must not open a comment), so a `<details>` mentioned in prose or an example never opens or
1605
+ // closes a block.
1606
+ const lines = content.split('\n');
1607
+ const blank = (t) => t.replace(/[^\r\n]/g, ' ');
1608
+ for (const f of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines)) {
1609
+ // An unclosed fence is bounded at the next heading, not EOF, so later archives stay visible.
1610
+ let last = f.closeLineIdx;
1611
+ if (last === -1) {
1612
+ last = lines.length - 1;
1613
+ for (let i = f.openLineIdx + 1; i < lines.length; i++)
1614
+ if (/^#{1,6} /.test(lines[i])) {
1615
+ last = i - 1;
1616
+ break;
1617
+ }
1618
+ }
1619
+ for (let i = f.openLineIdx; i <= last; i++)
1620
+ lines[i] = blank(lines[i]);
1621
+ }
1622
+ const joined = lines.join('\n');
1623
+ let codeBlanked = '';
1624
+ let at = 0;
1625
+ for (const sp of (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(joined)) {
1626
+ codeBlanked += joined.slice(at, sp.start) + blank(joined.slice(sp.start, sp.end));
1627
+ at = sp.end;
1385
1628
  }
1386
- const offset = lastDetailsClose + '</details>'.length;
1387
- const before = content.slice(0, offset);
1388
- const after = content.slice(offset);
1389
- return before + apply(after);
1629
+ return blankComments(codeBlanked + joined.slice(at));
1630
+ }
1631
+ /**
1632
+ * A closed milestone written as a plain heading section (no `<details>`) is archive too; the
1633
+ * active milestone's own `## v2.0 Part 2 ✅` sections are not.
1634
+ */
1635
+ function plainClosedSections(content, overlapsActive, activeVersion) {
1636
+ // A closed milestone written as a plain heading section (no `<details>`) is
1637
+ // archive too. It ends at the next heading of the same or shallower level, so
1638
+ // a following `## Progress` table stays visible.
1639
+ const headingTokens = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1640
+ const spans = [];
1641
+ for (let i = 0; i < headingTokens.length; i++) {
1642
+ const h = headingTokens[i];
1643
+ if (/^Phase\s+\S/i.test(h.text))
1644
+ continue;
1645
+ if (!isClosedMilestoneHeading(h.text) || extractMilestoneHeadingName(h.text) === null)
1646
+ continue;
1647
+ let next;
1648
+ for (let j = i + 1; j < headingTokens.length && !next; j++) {
1649
+ const t = headingTokens[j];
1650
+ if (t.level <= h.level && !/^Phase\s+\S/i.test(t.text))
1651
+ next = t;
1652
+ }
1653
+ // End before the newline that precedes `next`, so that heading stays at line start.
1654
+ const end = next ? next.offset - (content[next.offset - 2] === '\r' ? 2 : 1) : content.length;
1655
+ if (!overlapsActive(h.offset, end) && !(activeVersion && h.text.match(VERSION_TOKEN)?.[0].toLowerCase() === activeVersion))
1656
+ spans.push({ start: h.offset, end });
1657
+ }
1658
+ return spans;
1659
+ }
1660
+ /**
1661
+ * #5553: locate every archive span in `content` (`<details>` blocks and plain closed-milestone
1662
+ * sections) that does not wrap the active milestone; `inActive` is the subset overlapping it.
1663
+ */
1664
+ function scanArchiveSpans(content, active) {
1665
+ const activeVersion = active.length > 0 ? content.slice(active[0].start, active[0].start + 300).split('\n', 1)[0].match(VERSION_TOKEN)?.[0].toLowerCase() : undefined;
1666
+ // A block whose own <summary> marks a closed milestone is an archive wherever it sits.
1667
+ const closedSummary = (at) => {
1668
+ const m = /^<details\b[^>]{0,200}>[\s>]*<summary[^>]{0,200}>([^<]{0,300})<\/summary>/i.exec(content.slice(at, at + 800));
1669
+ return m !== null && isClosedMilestoneHeading(m[1]);
1670
+ };
1671
+ const scan = blankCodeAndComments(content);
1672
+ // Depth-aware: an outer block closes at its own `</details>`, so a nested
1673
+ // collapsed section inside an archive is masked with it.
1674
+ const spans = [];
1675
+ const inActive = [];
1676
+ const overlapsActive = (start, end) => active.some((r) => start < r.end && end > r.start);
1677
+ // Block-level tags only: a tag at line start (<=3 spaces, optionally inside a
1678
+ // blockquote `>` prefix) starts the scan of its line, and further tags on that
1679
+ // same line (a one-line `<details>...</details>`) count with it. Inline or
1680
+ // indented mentions elsewhere never count, and the bounded attribute run keeps
1681
+ // a hostile run of `<details ` linear.
1682
+ const lineStart = /^[ \t]{0,3}(?:>[ \t]{0,3})*(?=<\/?details\b)/gim;
1683
+ const anyTag = /<details\b[^>]{0,200}>|<\/details[ \t]*>/gi;
1684
+ const tags = [];
1685
+ for (let l = lineStart.exec(scan); l; l = lineStart.exec(scan)) {
1686
+ const from = l.index + l[0].length;
1687
+ const nl = scan.indexOf('\n', from);
1688
+ const lineEnd = nl === -1 ? scan.length : nl;
1689
+ anyTag.lastIndex = from;
1690
+ for (let t = anyTag.exec(scan); t && t.index < lineEnd; t = anyTag.exec(scan)) {
1691
+ tags.push({ at: t.index, len: t[0].length, closer: t[0][1] === '/' });
1692
+ }
1693
+ lineStart.lastIndex = Math.max(lineStart.lastIndex, lineEnd);
1694
+ }
1695
+ // Stack pairing: every closer matches its nearest opener, so each closed pair is
1696
+ // evaluated on its own (a nested archive inside an active wrapper is masked) and
1697
+ // an unmatched opener or closer is ignored instead of disabling later masking.
1698
+ const openers = [];
1699
+ const kept = [];
1700
+ let paired = false;
1701
+ let strayCloser = false;
1702
+ for (const tag of tags) {
1703
+ if (!tag.closer) {
1704
+ openers.push(tag.at);
1705
+ continue;
1706
+ }
1707
+ const start = openers.pop();
1708
+ if (start === undefined) {
1709
+ // A closer with nothing to pair after an earlier pair closed: that pair may have swallowed this block's own opener.
1710
+ if (paired)
1711
+ strayCloser = true;
1712
+ continue;
1713
+ }
1714
+ paired = true;
1715
+ const end = tag.at + tag.len;
1716
+ // Only a block wrapping an active heading stays editable; any other block is archive, with or without a closed <summary>.
1717
+ if (active.some((r) => start <= r.start && r.start < end))
1718
+ continue;
1719
+ const inRange = overlapsActive(start, end) && !closedSummary(start);
1720
+ // A collapsed block of the active range that holds the Progress table stays editable, so the row moves with the plan ticks.
1721
+ // A level-1 `# Progress` has always counted here (#5692: no behavior change), hence `levels`.
1722
+ if (inRange && (0, roadmap_progress_cjs_1.findProgressSection)(scan.slice(start, end), { levels: [1, 2] }) !== null) {
1723
+ kept.push({ start, end });
1724
+ continue;
1725
+ }
1726
+ spans.push({ start, end });
1727
+ if (inRange)
1728
+ inActive.push({ start, end });
1729
+ }
1730
+ // An opener left unpaired makes the structure ambiguous when it either opens a
1731
+ // closed-summary archive that does not close on its own line (its extent is unknown) or precedes a masked
1732
+ // block in the active range (it may be that block's real wrapper, with the
1733
+ // stray tag inside). A harmless stray opener is ignored.
1734
+ const eol = (o) => { const e = content.indexOf('\n', o); return e === -1 ? content.length : e; };
1735
+ const ambiguous = strayCloser || openers.some((o) => closedSummary(o) && !/<\/details/i.test(content.slice(o, eol(o)))) || (openers.length > 0 && [...inActive, ...kept].some((r) => r.start > openers[0]));
1736
+ spans.push(...plainClosedSections(content, overlapsActive, activeVersion));
1737
+ return { spans, inActive, ambiguous };
1738
+ }
1739
+ /**
1740
+ * #5553: hide every archived `<details>` block, and every closed milestone
1741
+ * written as a plain heading section, behind a placeholder; `restore` puts them
1742
+ * back unchanged. Tag case, attributes and archive position
1743
+ * (before OR after the active milestone) do not matter, unlike a "region after
1744
+ * the last `</details>`" cut. A block stays visible only when it wraps the
1745
+ * start of a resolved active milestone range (`<details open>` around the
1746
+ * active milestone); every other block is hidden, whatever its `<summary>`
1747
+ * (none, non-closed, closed), including a collapsed block inside the active
1748
+ * range. That is deliberate for THIS write path only; the read-side
1749
+ * `stripClosedMilestoneDetails` window (#3982/#1341) is unchanged. With no
1750
+ * resolvable active milestone every block is hidden, as the old
1751
+ * last-`</details>` cut did. `ambiguous` flags unpaired tags that hide where an
1752
+ * archive ends (the caller declines); `activeRange` is the active milestone's
1753
+ * range in `visible` coordinates.
1754
+ */
1755
+ function maskArchivedDetails(content, cwd) {
1756
+ if (!/<details\b/i.test(content) && !MILESTONE_CLOSED_MARKER_PATTERN.test(content)) {
1757
+ return { visible: content, restore: (edited) => edited, hiddenInActive: '', ambiguous: false, activeRange: null };
1758
+ }
1759
+ const resolved = currentMilestoneRawRanges(content, cwd);
1760
+ // A stale STATE can name a milestone whose own heading is closed (all headings closed): nothing is active, so everything shipped stays masked.
1761
+ const ranges = resolved && !isClosedMilestoneHeading(content.slice(resolved.primary.start).split('\n', 1)[0]) ? resolved : null;
1762
+ const active = ranges ? [ranges.primary, ...(ranges.details ? [ranges.details] : [])] : [];
1763
+ const { spans, inActive, ambiguous } = scanArchiveSpans(content, active);
1764
+ const blocks = [];
1765
+ // Unguessable per call: a plan filename or any other edited-in text cannot forge a marker.
1766
+ const nonce = (0, node_crypto_1.randomBytes)(8).toString('hex');
1767
+ const marker = (i) => `<!-- gsd-archived-details-${nonce}-${i} -->`;
1768
+ spans.sort((x, y) => x.start - y.start || y.end - x.end);
1769
+ let visible = '';
1770
+ let last = 0;
1771
+ const hidden = [];
1772
+ for (const span of spans) {
1773
+ if (span.start < last) {
1774
+ // Nested in an already-masked span: only a partial overlap extends it.
1775
+ if (span.end <= last)
1776
+ continue;
1777
+ span.start = last;
1778
+ }
1779
+ blocks.push(content.slice(span.start, span.end));
1780
+ visible += content.slice(last, span.start);
1781
+ const m = marker(blocks.length - 1);
1782
+ hidden.push({ start: span.start, end: span.end, vis: visible.length, len: m.length });
1783
+ visible += m;
1784
+ last = span.end;
1785
+ }
1786
+ visible += content.slice(last);
1787
+ // One pass, so a hostile ROADMAP with thousands of blocks stays linear.
1788
+ const markerRe = new RegExp(`<!-- gsd-archived-details-${nonce}-(\\d+) -->`, 'g');
1789
+ const restore = (edited) => {
1790
+ const seen = new Array(blocks.length).fill(0);
1791
+ let bad = false;
1792
+ const restored = edited.replace(markerRe, (m, i) => {
1793
+ const n = Number(i);
1794
+ if (n >= blocks.length || seen[n]++ > 0) {
1795
+ bad = true;
1796
+ return m;
1797
+ }
1798
+ return blocks[n];
1799
+ });
1800
+ // edit dropped or duplicated a placeholder: caller must refuse rather than lose or misplace an archive
1801
+ return bad || seen.includes(0) ? null : restored;
1802
+ };
1803
+ // Blocks hidden inside the active range stay unticked, but their rows still count as present.
1804
+ const hiddenInActive = inActive.map((r) => content.slice(r.start, r.end)).join('\n');
1805
+ // Original offset -> offset in `visible`.
1806
+ const toVisible = (p) => {
1807
+ let h;
1808
+ for (const x of hidden) {
1809
+ if (x.start < p)
1810
+ h = x;
1811
+ else
1812
+ break;
1813
+ }
1814
+ if (!h)
1815
+ return p;
1816
+ return p >= h.end ? h.vis + h.len + (p - h.end) : h.vis;
1817
+ };
1818
+ const activeRange = active.length > 0
1819
+ ? { start: toVisible(Math.min(...active.map((r) => r.start))), end: toVisible(Math.max(...active.map((r) => r.end))) }
1820
+ : null;
1821
+ return { visible, restore, hiddenInActive, ambiguous, activeRange };
1390
1822
  }
1391
1823
  /**
1392
1824
  * Resolve a single phase's detail-section heading (`### Phase N: …`, any level
@@ -1420,11 +1852,21 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
1420
1852
  * phase-heading level, so a level-bounded stop could fold a deeper sibling
1421
1853
  * heading (e.g. a `####` phase following a `###` phase) into this phase's
1422
1854
  * body and let `edit` reach into it.
1855
+ *
1856
+ * `levelBounded: true` instead keeps the phase's own non-phase `####` sub-sections
1857
+ * (a plan list under a sub-heading) in the body, still ending at the next heading
1858
+ * that opens another phase, of any level. Serves the presence check and the
1859
+ * plan-row tick writer.
1423
1860
  */
1424
- function withPhaseSection(content, phaseId, edit) {
1861
+ function withPhaseSection(content, phaseId, edit, levelBounded = false) {
1425
1862
  const src = phaseMarkdownRegexSource(phaseId);
1426
1863
  const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
1427
- return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), edit, { levelBounded: false });
1864
+ // levelBounded runs to the next same-or-higher heading, so cut at a deeper phase heading too.
1865
+ const ownBody = !levelBounded ? edit : (body) => {
1866
+ const cut = body.search(/^#{1,6}[ \t]+(?:\[[^\]\n]{1,200}\][ \t]*)?Phase\s+\S/m);
1867
+ return cut === -1 ? edit(body) : edit(body.slice(0, cut)) + body.slice(cut);
1868
+ };
1869
+ return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), ownBody, { levelBounded });
1428
1870
  }
1429
1871
  // ─── Roadmap phase lookup ─────────────────────────────────────────────────────
1430
1872
  // #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
@@ -1432,11 +1874,11 @@ function withPhaseSection(content, phaseId, edit) {
1432
1874
  // is captured in group 1, the name in group 2; the separator may be an em-dash,
1433
1875
  // en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
1434
1876
  // to count phases in a milestone that uses the bullet form.
1435
- const BULLET_PHASE_LINE_PATTERN = /^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im;
1877
+ const BULLET_PHASE_LINE_PATTERN = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*(.+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'im', { box: 'optional', bold: 'required' });
1436
1878
  /** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
1437
1879
  function bulletPhaseLineFor(phaseNum, phaseSource) {
1438
1880
  const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
1439
- return new RegExp(`^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`, 'im');
1881
+ return (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'im', { box: 'optional', bold: 'required' });
1440
1882
  }
1441
1883
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
1442
1884
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
@@ -1577,39 +2019,6 @@ function reportUnreadableRoadmap(err, roadmapPath) {
1577
2019
  return;
1578
2020
  warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
1579
2021
  }
1580
- // ─── Roadmap progress table (#1956/#2012 decoy avoidance) ─────────────────────
1581
- /**
1582
- * Locate ROADMAP.md's "Progress" table — the sole owner of the #2012
1583
- * decoy-avoidance scope for the `drift-guard phase-status` CLI seam (#1956).
1584
- *
1585
- * Scopes to the `## Progress` heading first (level-2, exact case-insensitive
1586
- * text `'progress'`, `{ levelBounded: true }`) via `collectSection` — the
1587
- * same CRLF-safe seam `stateCurrentPositionSlice` (state-document.cts) uses
1588
- * to scope STATE.md's `## Current Position` — so a differently-headed table
1589
- * that happens to share the same column names (e.g. an "Archive Notes"
1590
- * table) is never picked up instead of the real one (#2012). Falls back to
1591
- * scanning the WHOLE document when no `## Progress` heading exists, so a
1592
- * headingless milestone slice (#1445) still resolves rather than going
1593
- * uncheckable — the same fallback `deriveProgressFromRoadmap`
1594
- * (phase-lifecycle.cts) deliberately preserves.
1595
- *
1596
- * `deriveProgressFromRoadmap` independently expresses this same "scope to
1597
- * `## Progress`, else whole document" rule via its own regex-based scope
1598
- * (kept there deliberately rather than refactored onto this function — its
1599
- * blast radius is large). The two locators are therefore separate
1600
- * implementations of the same scoping rule and must agree about WHICH table
1601
- * is the Progress table; a parity test in
1602
- * tests/adr-22-plan-drift-guard.test.cjs asserts they do, per the repo's
1603
- * generative-fix-divergence guard.
1604
- *
1605
- * Returns the same shape `findTableWithColumns` returns (or `null`).
1606
- */
1607
- function findRoadmapProgressTable(roadmapContent) {
1608
- const isProgressHeading = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'progress';
1609
- const section = (0, markdown_sectionizer_cjs_1.collectSection)(roadmapContent, isProgressHeading, { levelBounded: true });
1610
- const scoped = section ? section.body : roadmapContent;
1611
- return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
1612
- }
1613
2022
  /**
1614
2023
  * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
1615
2024
  * milestone-name capture. Markdown headings commonly take the shape
@@ -1766,9 +2175,9 @@ function getMilestoneInfo(cwd) {
1766
2175
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1767
2176
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
1768
2177
  if (stateRaw !== null) {
1769
- const m = stateRaw.match(/^milestone:\s*(.+)/m);
1770
- if (m) {
1771
- const classified = classifyMilestoneScalar(m[1]);
2178
+ const milestoneScalar = readStateMilestoneScalar(stateRaw);
2179
+ if (milestoneScalar !== null) {
2180
+ const classified = classifyMilestoneScalar(milestoneScalar);
1772
2181
  explicitlyNoMilestone = classified.explicitNull;
1773
2182
  stateVersion = classified.version;
1774
2183
  }
@@ -1902,7 +2311,7 @@ function getMilestoneInfo(cwd) {
1902
2311
  * resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
1903
2312
  * pre-#2562 call site is unaffected.
1904
2313
  */
1905
- function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2314
+ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws, context) {
1906
2315
  const milestonePhaseNums = new Set();
1907
2316
  // #612: the milestone-QUALIFIED form (`{CODE}.{MM}-{PP}`) of each in-scope
1908
2317
  // bracket heading, kept in its OWN set — deliberately not in
@@ -1999,7 +2408,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
1999
2408
  // #3262 remains the single owner of the phase-set scan. #612 extends its
2000
2409
  // return with qualified bracket ids rather than restoring the superseded
2001
2410
  // inline duplicate this commit was originally written against.
2002
- const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention);
2411
+ const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention, context);
2003
2412
  for (const id of scanned.ids)
2004
2413
  milestonePhaseNums.add(id);
2005
2414
  for (const qualified of scanned.qualifiedIds)
@@ -2053,7 +2462,11 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2053
2462
  ? new RegExp(`^0*(\\d+[A-Za-z]?(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?)*(?:\\.\\d+)*)(?=-|$)`)
2054
2463
  // 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.
2055
2464
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
2465
+ const customKeys = context?.phaseNaming === 'custom' && headingConvention !== 'bracket'
2466
+ ? new Set([...milestonePhaseNums].map(id => phaseIdModule.phaseKeyFromToken(id, context))) : null;
2056
2467
  function isDirInMilestone(dirName) {
2468
+ if (customKeys)
2469
+ return customKeys.has(phaseIdModule.phaseKeyFromDir(dirName, headingConvention, context));
2057
2470
  // #612: the DIR side of this filter, selected by the same convention the
2058
2471
  // heading side selected. Without it the heading scan's new bracket reach was
2059
2472
  // half a fix: `milestonePhaseNums` became non-empty, so the pass-all degrade
@@ -2142,27 +2555,31 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2142
2555
  * Details section) — both consume the same `locateMilestoneHeadings` /
2143
2556
  * `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
2144
2557
  * keep in sync. Returns null when there is no versioned active milestone;
2145
- * callers then fall back to whole-content mutation (the prior behaviour).
2558
+ * callers then fall back to the shipped-block-masked whole content
2559
+ * (`editOutsideShippedBlocks`) via `editCurrentMilestone`, or to unscoped
2560
+ * whole-content handling where they call this directly (`phase.cts`
2561
+ * `phaseEntryInsertOffset`).
2146
2562
  *
2147
- * #2761 (round-2 review, Minor 3 — latent, currently harmless): this function
2148
- * consumes #3184's shared owners, but it does not pass the bracket-specific
2149
- * B1/B2 boundary predicate and still has no bracket-fallback SELECTION branch:
2150
- * it returns null when the version-string owner finds nothing, unlike
2151
- * `extractCurrentMilestoneScoped`. The read owner can therefore scope a
2152
- * bracket ROADMAP this write-range consumer still calls unscoped. Probed and
2153
- * confirmed harmless TODAY: this
2154
- * function's single consumer (`mutateMilestonePhase`, src/phase.cts) falls
2155
- * back to whole-content mutation when it returns null, and every mutation
2156
- * inside that caller is still `Phase`-labelled-only (not bracket-widened) per
2157
- * the changeset's own "READ-path opt-in until the migrator and write path
2158
- * land" — so a bracket ROADMAP's checkbox/heading patterns never match inside
2159
- * that fallback and nothing is mutated cross-milestone. The moment the write
2160
- * path is widened (PR-3+), this divergence becomes live: the whole-content
2161
- * fallback would become a cross-milestone writer, which is exactly what this
2162
- * note exists to prevent. Bracket-widen this function in lockstep with the
2163
- * write path landing, not before.
2563
+ * #2761 / #4304: bracket-widened
2564
+ * in lockstep with the write path landing, per this doc's own prior note.
2565
+ * `phaseIdConvention` is the same additive, optional trailing parameter
2566
+ * `extractCurrentMilestoneScoped` accepts — omitted (every pre-#4304 2-arg
2567
+ * call site), it compiles byte-identically to before: `bracketFallbackHeadingMatches`
2568
+ * and `bracketAwareMilestoneSection`'s bracket branch are both no-ops unless
2569
+ * explicitly told `'bracket'`, so a version-string match still short-circuits
2570
+ * both, and a non-bracket/unresolved convention still returns null exactly as
2571
+ * it always has. Now shares its heading-selection and section-end grammar
2572
+ * with `extractCurrentMilestoneScoped` (`bracketFallbackHeadingMatches`,
2573
+ * `selectActiveMilestoneHeading`, `bracketAwareMilestoneSection`) instead of
2574
+ * carrying its own unwidened copy, so a version-less bracket milestone
2575
+ * heading (`## [GSD.09] Hidden`) bounds a section for this function's callers
2576
+ * too, not only for the read path.
2577
+ * Consumers: `editCurrentMilestone` (`phase complete`'s `mutateMilestonePhase`,
2578
+ * `roadmap update-plan-progress`) and `phase.cts` `phaseEntryInsertOffset`. On the null
2579
+ * path `editCurrentMilestone` edits the ROADMAP with closed-milestone `<details>`
2580
+ * blocks masked, so no writer reaches into a shipped block.
2164
2581
  */
2165
- function currentMilestoneRawRanges(content, cwd) {
2582
+ function currentMilestoneRawRanges(content, cwd, phaseIdConvention) {
2166
2583
  if (!cwd)
2167
2584
  return null;
2168
2585
  let version = null;
@@ -2171,9 +2588,9 @@ function currentMilestoneRawRanges(content, cwd) {
2171
2588
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
2172
2589
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
2173
2590
  if (stateRaw !== null) {
2174
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
2175
- if (milestoneMatch) {
2176
- const classified = classifyMilestoneScalar(milestoneMatch[1]);
2591
+ const milestoneScalar = readStateMilestoneScalar(stateRaw);
2592
+ if (milestoneScalar !== null) {
2593
+ const classified = classifyMilestoneScalar(milestoneScalar);
2177
2594
  version = classified.version;
2178
2595
  stateAssertsNoMilestone = classified.explicitNull;
2179
2596
  }
@@ -2187,15 +2604,21 @@ function currentMilestoneRawRanges(content, cwd) {
2187
2604
  }
2188
2605
  if (!version)
2189
2606
  return null;
2190
- const headingMatches = locateMilestoneHeadings(content, version);
2607
+ const bracketScopeConvention = phaseIdConvention ?? null;
2608
+ let headingMatches = locateMilestoneHeadings(content, version);
2609
+ if (headingMatches.length === 0) {
2610
+ headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
2611
+ }
2191
2612
  if (headingMatches.length === 0)
2192
2613
  return null;
2193
2614
  const isClosed = isClosedMilestoneHeading;
2194
2615
  // #3184: selection collapses to the sole owner; `headingMatches` is still
2195
2616
  // needed below for the detailsMatch search over all headings.
2196
- const selected = selectMilestoneHeading(content, version);
2617
+ const selected = selectActiveMilestoneHeading(content, version, headingMatches);
2197
2618
  const sectionStart = selected.index ?? 0;
2198
- const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
2619
+ const bracketBoundaryActive = bracketScopeConvention === 'bracket';
2620
+ const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
2621
+ const { sectionEnd, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
2199
2622
  const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
2200
2623
  const detailsVersionBoundary = selectedVersionToken
2201
2624
  ? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
@@ -2207,10 +2630,160 @@ function currentMilestoneRawRanges(content, cwd) {
2207
2630
  let details = null;
2208
2631
  if (detailsMatch) {
2209
2632
  const detailsStart = detailsMatch.index ?? 0;
2210
- details = { start: detailsStart, end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart) };
2633
+ // #4304: pass the SAME bracketBoundary (and heading tokens)
2634
+ // bracketAwareMilestoneSection already derived for the primary range —
2635
+ // extractCurrentMilestoneScoped's own details lookup (line ~1312) has
2636
+ // always done this; this copy omitted both trailing arguments, so a
2637
+ // version-less bracket milestone heading (no v\d+.\d+/emoji marker) never
2638
+ // stopped this details window at the NEXT milestone's own "(Phase
2639
+ // Details)" section, and the two copies silently disagreed on the active
2640
+ // details window.
2641
+ details = {
2642
+ start: detailsStart,
2643
+ end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart, bracketBoundary, currentMilestoneHeadings),
2644
+ };
2211
2645
  }
2212
2646
  return { primary: { start: sectionStart, end: sectionEnd }, details };
2213
2647
  }
2648
+ /** `<details>` / `</details>` tags of `content` in document order (bare opener only: `<details open>` wraps the active milestone, it is not an archive). */
2649
+ function bareDetailsTags(content) {
2650
+ return [...content.matchAll(/<(\/?)details>/gi)].map((m) => ({ at: m.index, close: m[1] === '/' }));
2651
+ }
2652
+ /** Whether offset `idx` sits inside a bare `<details>` block that has not closed yet (`tags` from `bareDetailsTags`). */
2653
+ function insideBareDetails(tags, idx) {
2654
+ let lo = 0;
2655
+ let hi = tags.length;
2656
+ while (lo < hi) {
2657
+ const mid = (lo + hi) >> 1;
2658
+ if (tags[mid].at < idx)
2659
+ lo = mid + 1;
2660
+ else
2661
+ hi = mid;
2662
+ }
2663
+ return lo > 0 && !tags[lo - 1].close;
2664
+ }
2665
+ /** A phase heading or a bold phase-list entry: what makes a block a shipped/backlog milestone. */
2666
+ const ACTIVE_SCOPE_PHASE_ENTRY_SRC = `(?:^#{2,4}[ \\t]+Phase\\b|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ bold: 'required' })}Phase\\b)`;
2667
+ /**
2668
+ * The active milestone's writable scope as offsets into `content`.
2669
+ *
2670
+ * `start` is just past the last `</details>` that closes BEFORE the selected
2671
+ * milestone heading, so a backlog `</details>` AFTER it no longer hides it
2672
+ * (#5221) while phase/Progress content above the heading (#2947 preamble layout)
2673
+ * stays in scope. No resolved heading, or one that is itself closed (stale
2674
+ * STATE), uses the last `</details>` before the first phase entry outside any
2675
+ * open <details>, so a backlog `</details>` below the active phases does not
2676
+ * hide them.
2677
+ *
2678
+ * `end` is the first line-leading bare `<details>` AFTER that heading (or first
2679
+ * phase entry): a shipped block placed below the active milestone is never
2680
+ * writable, so a second run cannot tick the archived phase once the active one
2681
+ * is done. Writers that must reach a bottom `## Progress` past a backlog block
2682
+ * (`editProgressTableSlice`) use `start` alone. Tags match case-insensitively.
2683
+ * An archive whose `</details>` is missing cannot be told from a wrapper, so
2684
+ * `start` stays 0 for it (documented fail-open).
2685
+ */
2686
+ function activeScope(content, cwd) {
2687
+ const tags = bareDetailsTags(content);
2688
+ const headingAt = currentMilestoneRawRanges(content, cwd)?.primary.start;
2689
+ const open = headingAt !== undefined && !isClosedMilestoneHeading(content.slice(headingAt).split('\n', 1)[0]);
2690
+ let anchor;
2691
+ if (open) {
2692
+ anchor = headingAt;
2693
+ }
2694
+ else {
2695
+ const firstActive = [...content.matchAll(new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'gim'))]
2696
+ .find((m) => !insideBareDetails(tags, m.index));
2697
+ anchor = firstActive?.index ?? content.length;
2698
+ }
2699
+ let start = 0;
2700
+ for (const t of tags) {
2701
+ if (t.at >= anchor)
2702
+ break;
2703
+ if (t.close)
2704
+ start = t.at + '</details>'.length;
2705
+ }
2706
+ // Only a block that carries phase entries is a shipped/backlog milestone; a collapsible note inside the active milestone stays writable.
2707
+ const phaseEntry = new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'im');
2708
+ const below = tags.find((t, i) => {
2709
+ if (t.close || t.at <= anchor || !/^[ \t]*$/.test(content.slice(content.lastIndexOf('\n', t.at - 1) + 1, t.at)))
2710
+ return false;
2711
+ const close = tags.slice(i + 1).find((c) => c.close);
2712
+ return phaseEntry.test(content.slice(t.at, close ? close.at : content.length));
2713
+ });
2714
+ return { start, end: below ? below.at : content.length };
2715
+ }
2716
+ function activeScopeStart(content, cwd) {
2717
+ return activeScope(content, cwd).start;
2718
+ }
2719
+ /** [start,end) windows of the current milestone, document order; the whole ROADMAP when none resolves. */
2720
+ function currentMilestoneWindows(content, cwd) {
2721
+ const ranges = currentMilestoneRawRanges(content, cwd);
2722
+ if (!ranges)
2723
+ return [{ start: 0, end: content.length }];
2724
+ // `details` always starts at or after `primary.end` (see `currentMilestoneRawRanges`),
2725
+ // so this is document order: a "first window wins" caller means the primary one.
2726
+ return [ranges.primary, ranges.details].filter((w) => w !== null);
2727
+ }
2728
+ /**
2729
+ * Read-only counterpart of `editCurrentMilestone`: the text of each window,
2730
+ * with closed-milestone `<details>` blocks hidden exactly as the writers see it.
2731
+ */
2732
+ function readCurrentMilestone(content, cwd) {
2733
+ return currentMilestoneWindows(content, cwd).map((w) => maskShippedBlocks(content.slice(w.start, w.end)).masked);
2734
+ }
2735
+ /**
2736
+ * Run `edit` over the current milestone's raw window(s) from
2737
+ * `currentMilestoneRawRanges`, or, when no milestone resolves, over the whole
2738
+ * ROADMAP, each with closed-milestone `<details>` blocks hidden (#5543;
2739
+ * `editOutsideShippedBlocks`: a notes or phase-wrapper block stays editable).
2740
+ * `edit` is called once per window, in document order. The one milestone
2741
+ * scoping for per-phase writers (`phase complete`, `roadmap
2742
+ * update-plan-progress` #5483), so neither cuts at the last `</details>`.
2743
+ */
2744
+ function editCurrentMilestone(content, cwd, edit) {
2745
+ const windows = currentMilestoneWindows(content, cwd);
2746
+ const edited = windows.map((w) => editOutsideShippedBlocks(content.slice(w.start, w.end), edit));
2747
+ // Splice last-first so an earlier window's offsets are not shifted by a
2748
+ // length-changing mutation in a later window.
2749
+ return windows.reduceRight((acc, w, i) => acc.slice(0, w.start) + edited[i] + acc.slice(w.end), content);
2750
+ }
2751
+ /**
2752
+ * The checklist bullet of the phase whose number matches `phasePattern`
2753
+ * (`- [ ] **Phase N: …**`), as the one grammar shared by the Plans-count /
2754
+ * completion stamp and the plan-row ticks. `anyState` also matches a checked box.
2755
+ * Group 1 is everything through the opening `[` (indent and marker included),
2756
+ * group 2 the rest of the line from the closing `]`. The prefix (marker, box, bold token) is the phase-entry grammar's (#5764); the
2757
+ * tail stops before `\r` so a stamp appended to group 2 lands on the entry's
2758
+ * line, before its CRLF.
2759
+ */
2760
+ function phaseChecklistBullet(phasePattern, anyState) {
2761
+ return (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(`Phase[ \\t]+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s]|$)[^\\r\\n]*`, 'i', { state: anyState ? 'any' : 'unchecked' });
2762
+ }
2763
+ /** A checklist bullet of ANY phase: ends the rows nested under the previous one. */
2764
+ const ANY_PHASE_BULLET = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)('Phase[ \\t]', 'i');
2765
+ /**
2766
+ * [start,end) of the rows nested under a phase's checklist bullet: from the
2767
+ * line after `bullet` (a match of `phaseChecklistBullet` in `text`) up to a
2768
+ * blank line, a shallower line, or the next phase's bullet. Empty when the
2769
+ * bullet is the last line with no trailing newline.
2770
+ */
2771
+ function checklistBulletExtent(text, bullet) {
2772
+ // The bullet's own indent, read from its line: a caller's capture group 1 may
2773
+ // be the indent alone (which has no non-space and used to give -1, so a
2774
+ // shallower line never ended the extent) or everything through the `[`.
2775
+ const depth = text.slice(bullet.index).search(/\S/);
2776
+ const start = text.indexOf('\n', bullet.index) + 1;
2777
+ let end = start;
2778
+ while (start > 0 && end < text.length) {
2779
+ const next = text.indexOf('\n', end);
2780
+ const line = text.slice(end, next === -1 ? text.length : next);
2781
+ if (line.trim() === '' || line.search(/\S/) < depth || ANY_PHASE_BULLET.test(line))
2782
+ break;
2783
+ end = next === -1 ? text.length : next + 1;
2784
+ }
2785
+ return { start, end };
2786
+ }
2214
2787
  /**
2215
2788
  * #4731 — extract a bold-labeled phase field (Goal, Requirements, ...) reading
2216
2789
  * past hard-wrapped continuation lines. The roadmapper soft-wraps long fields
@@ -2275,21 +2848,32 @@ function extractPhaseFieldMultiline(section, label) {
2275
2848
  return [firstLine, ...contLines].join(' ').trim() || null;
2276
2849
  }
2277
2850
  module.exports = {
2851
+ activeScope,
2852
+ activeScopeStart,
2278
2853
  stripShippedMilestones,
2279
2854
  extractCurrentMilestone,
2280
2855
  extractCurrentMilestoneScoped,
2281
2856
  isMilestoneShippedInRoadmap,
2282
2857
  isMilestoneBoundedInRoadmap,
2283
- replaceInCurrentMilestone,
2858
+ maskArchivedDetails,
2284
2859
  getRoadmapPhaseInternal,
2285
2860
  getMilestoneInfo,
2286
2861
  classifyMilestoneScalar,
2862
+ readStateMilestoneScalar,
2287
2863
  getMilestonePhaseFilter,
2288
2864
  currentMilestoneRawRanges,
2289
2865
  withPhaseSection,
2866
+ editOutsideShippedBlocks,
2867
+ editCurrentMilestone,
2868
+ readCurrentMilestone,
2869
+ phaseChecklistBullet,
2870
+ checklistBulletExtent,
2290
2871
  computeMilestoneSectionEnd,
2291
2872
  locateMilestoneHeadings,
2292
2873
  listMilestoneHeadings,
2874
+ // #4304: historical mutation guards consume the window locator's
2875
+ // milestone-vs-phase decision before applying CLOSED markers.
2876
+ isRecognizedMilestoneHeading,
2293
2877
  selectMilestoneHeading,
2294
2878
  classifyMilestoneWindow,
2295
2879
  // #3184: the sole "give me this version's window" composition — see its
@@ -2301,9 +2885,6 @@ module.exports = {
2301
2885
  hasMilestoneSectioning,
2302
2886
  // #3642: the >=1 sibling buildStateFrontmatter's unbounded branch consumes.
2303
2887
  hasAnyMilestoneSection,
2304
- // #1956: sole owner of the #2012 decoy-avoidance scope for the
2305
- // `drift-guard phase-status` CLI seam.
2306
- findRoadmapProgressTable,
2307
2888
  // #3262 (write-time milestone-scope guard): the window phase-id scan owner
2308
2889
  // (consumed by getMilestonePhaseFilter above and the roadmap milestone-scope
2309
2890
  // CLI probe) and the free-text predicate the phase add/add-batch/insert
@@ -2317,6 +2898,23 @@ module.exports = {
2317
2898
  // owner (and its convention gate) instead of a private inline copy.
2318
2899
  extractPhaseFieldMultiline,
2319
2900
  hasPhaseEntries,
2901
+ isPhaseEntryHeading,
2902
+ // #4304: exported so `phase.cts`'s milestone-marker
2903
+ // enumeration (bracketRecognizedMilestoneMarkers) can recognize a
2904
+ // version-less bracket milestone heading through the SAME grammar the
2905
+ // window locator (bracketAwareMilestoneSection) already uses to decide
2906
+ // section boundaries, instead of a narrower, independently-typed copy.
2907
+ isBracketMilestoneBoundary,
2908
+ bracketFallbackHeadingMatches,
2909
+ // #4304: exported so `phase.cts`'s pre-mutation window guard
2910
+ // (bracketOwnedLineOutsideActiveWindow) can recognize a CLOSED/shipped
2911
+ // milestone heading through the SAME predicate `currentMilestoneRawRanges`
2912
+ // itself already uses to skip a closed heading when selecting the active
2913
+ // one, instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
2914
+ isClosedMilestoneHeading,
2915
+ // #4304: destructive ROADMAP writers classify details blocks through
2916
+ // the same closed-summary rule as stripClosedMilestoneDetails.
2917
+ isClosedMilestoneDetails,
2320
2918
  // #4144 round 5 Blocker 1: the readers' own phase-heading grammar, so the
2321
2919
  // bracket roadmap migrator's "phase-like but unparsed" refusal
2322
2920
  // (src/roadmap-upgrade.cts) can gate on it instead of a private, looser