@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
@@ -29,16 +29,22 @@ const configLoaderMod = require("./config-loader.cjs");
29
29
  const { loadConfig } = configLoaderMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
31
31
  const coreUtilsMod = require("./core-utils.cjs");
32
- const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
32
+ // #2528: `extractCanonicalPlanId` used to exist here as a byte-identical second
33
+ // copy, and this PR had to patch BOTH with the same rewind rule — the exact
34
+ // generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
35
+ // copy, which was already the leaf owner, so there is no second surface left to
36
+ // drift and no parity test needed to police one.
37
+ const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
33
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
39
  const phaseIdMod = require("./phase-id.cjs");
35
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
40
+ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
41
+ const pattern_cjs_1 = require("./pattern.cjs");
36
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
43
  const phaseLocatorMod = require("./phase-locator.cjs");
38
- const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
44
+ const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
39
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
40
46
  const roadmapParserMod = require("./roadmap-parser.cjs");
41
- const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection } = roadmapParserMod;
47
+ const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
42
48
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
43
49
  const planningWorkspace = require("./planning-workspace.cjs");
44
50
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -62,11 +68,14 @@ const verificationMod = require("./verification.cjs");
62
68
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- verify.cjs is an export= CommonJS module
63
69
  const verifyMod = require("./verify.cjs");
64
70
  const { readVerificationStatus } = verificationMod;
65
- const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } = planningWorkspace;
71
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
72
+ const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
73
+ const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
74
+ const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
75
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
76
+ const milestoneLockMod = require("./milestone-lock.cjs");
66
77
  const { extractFrontmatter } = frontmatterMod;
67
- const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
68
- // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
69
- const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
78
+ const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
70
79
  // Any .md file with PLAN anywhere in the basename — diagnostic net
71
80
  const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
72
81
  const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
@@ -135,27 +144,6 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
135
144
  `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
136
145
  `See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
137
146
  }
138
- function extractCanonicalPlanId(filename) {
139
- const base = filename
140
- .replace(/-PLAN\.md$/i, '')
141
- .replace(/-SUMMARY\.md$/i, '')
142
- .replace(/\.md$/i, '');
143
- const parts = base.split('-').filter(Boolean);
144
- // #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
145
- // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
146
- // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
147
- const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
148
- // #2232: the PAIRED plan component is a zero-padded continuation segment
149
- // (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
150
- // bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
151
- // \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
152
- const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
153
- const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
154
- if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
155
- return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
156
- }
157
- return base;
158
- }
159
147
  function cmdPhasesList(cwd, options, raw) {
160
148
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
161
149
  const { type, phase, includeArchived } = options;
@@ -169,24 +157,56 @@ function cmdPhasesList(cwd, options, raw) {
169
157
  return;
170
158
  }
171
159
  try {
172
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
173
- let dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
174
- if (includeArchived) {
175
- const archived = getArchivedPhaseDirs(cwd);
176
- for (const a of archived) {
177
- dirs.push(`${a.name} [${a.milestone}]`);
178
- }
179
- }
180
- dirs.sort((a, b) => comparePhaseNum(a, b));
160
+ // #3185 (ADR-3180 Decision 1): only the ENUMERATION routes through the
161
+ // single owner. The two other modes below ask genuinely DIFFERENT
162
+ // questions and are exempt by documented reason, never by a file
163
+ // allowlist (ADR-3180 Decision 4a):
164
+ //
165
+ // --phase <n> locating ONE phase by token is phase LOCATION, a
166
+ // question src/phase-locator.cts already owns via
167
+ // findPhaseInternal/searchPhaseInDir. Scoping it to
168
+ // the current milestone would make an out-of-window
169
+ // phase report "Phase not found".
170
+ // --include-archived archived directories are BY DEFINITION from other
171
+ // milestones; filtering them through the CURRENT
172
+ // milestone window would return nothing at all.
173
+ //
174
+ // Generalizing #3183's rule ("a diagnostic about file NAMING wants the
175
+ // physical set; only a question about outstanding WORK wants the live
176
+ // set"): a LOOKUP wants the physical set; only "which phases belong to
177
+ // this milestone" wants the scoped set.
178
+ const archivedLabels = includeArchived
179
+ ? getArchivedPhaseDirs(cwd).map((a) => `${a.name} [${a.milestone}]`)
180
+ : [];
181
+ let dirs;
182
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
183
+ // can tell a genuinely-empty milestone from one it could not scope. Only
184
+ // the ENUMERATION path scopes anything; the LOOKUP path below has no
185
+ // enumeration to report a scope for.
186
+ let phaseScope = null;
181
187
  if (phase) {
188
+ // LOOKUP (b): search the physical set, plus archived when asked.
189
+ const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
182
190
  const normalized = normalizePhaseName(phase);
183
- const match = dirs.find((d) => phaseTokenMatches(d, normalized));
191
+ // The pool is #3185's (physical set + archived); the matcher is this
192
+ // PR's. `dirs` is deliberately not read here: on this base it is not
193
+ // assigned until the branch below picks a match.
194
+ const { matches } = matchPhaseDirs(lookupPool, normalized);
195
+ const match = matches[0];
184
196
  if (!match) {
185
197
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
186
198
  return;
187
199
  }
188
200
  dirs = [match];
189
201
  }
202
+ else {
203
+ // ENUMERATION (a): milestone-scoped and sentinel-filtered, plus
204
+ // archived when asked (c).
205
+ const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
206
+ phaseScope = enumerated.scope;
207
+ dirs = [...enumerated.value, ...archivedLabels];
208
+ dirs.sort((a, b) => comparePhaseNum(a, b));
209
+ }
190
210
  if (type) {
191
211
  const files = [];
192
212
  const warnings = [];
@@ -195,13 +215,31 @@ function cmdPhasesList(cwd, options, raw) {
195
215
  const dirFiles = node_fs_1.default.readdirSync(dirPath);
196
216
  let filtered;
197
217
  if (type === 'plans') {
198
- filtered = dirFiles.filter(isCanonicalPlanFile);
218
+ // #3183: this is a "what plan files physically exist" query (this
219
+ // IS the file-listing command), not a live-completion question, so
220
+ // it uses the single owner's allPlanFiles (root+nested, INCLUDING
221
+ // status: superseded) rather than a root-only readdirSync filter
222
+ // that also missed nested plans.
223
+ //
224
+ // #2893 (regression fix): `allPlanFiles` also carries
225
+ // `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
226
+ // permissive for live-plan COUNTING elsewhere — see
227
+ // plan-count-single-owner.test.cjs). That fallback silently
228
+ // recognized a non-canonically-named file (e.g.
229
+ // `01-PLAN-01-foundation.md`) as "matched", which defeated this
230
+ // command's #2893 naming-convention diagnostic entirely (no
231
+ // warning, file listed as if valid). Intersect with the STRICT
232
+ // `isCanonicalPlanFile` predicate so this diagnostic — and the
233
+ // `files` list this command actually returns — only ever
234
+ // recognizes the canonical root/nested forms, exactly like the
235
+ // pre-#3183 behavior this feature was built and tested against.
236
+ filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
199
237
  const w = describeNonCanonicalPlans(dirFiles, filtered);
200
238
  if (w)
201
239
  warnings.push(`${dir}: ${w}`);
202
240
  }
203
241
  else if (type === 'summaries') {
204
- filtered = dirFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
242
+ filtered = scanPhasePlans(dirPath).summaryFiles;
205
243
  }
206
244
  else {
207
245
  filtered = dirFiles;
@@ -212,13 +250,18 @@ function cmdPhasesList(cwd, options, raw) {
212
250
  files,
213
251
  count: files.length,
214
252
  phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
253
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
254
+ // can tell a genuinely-empty milestone from one it could not scope.
255
+ phase_scope: phaseScope,
215
256
  };
216
257
  if (warnings.length)
217
258
  result['warning'] = warnings.join(' | ');
218
259
  output(result, raw, files.join('\n'));
219
260
  return;
220
261
  }
221
- output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
262
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
263
+ // can tell a genuinely-empty milestone from one it could not scope.
264
+ output({ directories: dirs, count: dirs.length, phase_scope: phaseScope }, raw, dirs.join('\n'));
222
265
  }
223
266
  catch (e) {
224
267
  const msg = e instanceof Error ? e.message : String(e);
@@ -234,8 +277,8 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
234
277
  if (node_fs_1.default.existsSync(phasesDir)) {
235
278
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
236
279
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
237
- baseExists = dirs.some((d) => phaseTokenMatches(d, normalized));
238
- const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
280
+ baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
281
+ const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
239
282
  for (const dir of dirs) {
240
283
  const match = dir.match(dirPattern);
241
284
  if (match)
@@ -348,6 +391,13 @@ function cmdFindPhase(cwd, phase, raw) {
348
391
  phase_name: null,
349
392
  plans: [],
350
393
  summaries: [],
394
+ // #3218: scalar counts alongside the arrays above. Left `null` (not `0`)
395
+ // when the phase can't be resolved at all — a fabricated `0` here would
396
+ // read identically to "phase exists with zero plans", which is a real,
397
+ // distinct answer (see the `status: superseded` case below).
398
+ plan_count: null,
399
+ summary_count: null,
400
+ plan_count_all: null,
351
401
  searched_directories: [],
352
402
  };
353
403
  const searchDirs = [];
@@ -378,7 +428,10 @@ function cmdFindPhase(cwd, phase, raw) {
378
428
  // #2237: fail loud when multiple directories match the same bare phase
379
429
  // number — prevents cross-project file writes when unrelated projects
380
430
  // share a .planning/phases/ tree.
381
- const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
431
+ // #2528: selection delegates to the canonical two-pass matcher (exact
432
+ // token match, then the bare-integer leading-digit-run fallback) shared
433
+ // with the locator and the phase-plan-index scan.
434
+ const { matches } = matchPhaseDirs(dirs, normalized);
382
435
  if (matches.length === 0)
383
436
  continue;
384
437
  if (matches.length > 1) {
@@ -395,9 +448,29 @@ function cmdFindPhase(cwd, phase, raw) {
395
448
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
396
449
  const phaseDir = node_path_1.default.join(searchDir, match);
397
450
  const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
398
- const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
399
- const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
400
- const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
451
+ // #3183: canonical, live (superseded-excluded) plan/summary sets
452
+ // (root+nested) from the single owner, rather than a root-only
453
+ // isCanonicalPlanFile filter + hand-rolled summary filter.
454
+ //
455
+ // #2893 (regression fix): both `plans` and the naming-diagnostic
456
+ // "matched" set are further intersected with the STRICT
457
+ // `isCanonicalPlanFile` predicate — scanPhasePlans's own
458
+ // planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
459
+ // fallback (deliberately permissive for live-plan COUNTING elsewhere),
460
+ // which silently recognized a non-canonically-named file (e.g.
461
+ // `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
462
+ // command's #2893 naming-convention diagnostic (no warning, offender
463
+ // listed in `plans` as if valid).
464
+ const phaseScan = scanPhasePlans(phaseDir);
465
+ const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
466
+ const summaries = phaseScan.summaryFiles.slice().sort();
467
+ // describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
468
+ // to supersession — compare against allPlanFiles (every plan-shaped file
469
+ // the owner recognizes, canonical or not) rather than the live-only
470
+ // `plans`, so a superseded-but-canonically-named plan is not misreported
471
+ // as a naming violation.
472
+ const canonicalAllPlanFiles = phaseScan.allPlanFiles.filter(isCanonicalPlanFile);
473
+ const planNamingWarning = describeNonCanonicalPlans(phaseFiles, canonicalAllPlanFiles);
401
474
  const result = {
402
475
  found: true,
403
476
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir), match)),
@@ -405,6 +478,20 @@ function cmdFindPhase(cwd, phase, raw) {
405
478
  phase_name: phaseName,
406
479
  plans,
407
480
  summaries,
481
+ // #3218: scalar counts additive alongside `plans[]`/`summaries[]`,
482
+ // which stay unchanged for existing consumers. Naming mirrors
483
+ // `roadmap.analyze`'s `plan_count`/`summary_count` (live, i.e.
484
+ // status:superseded EXCLUDED — same set as `plans`/`summaries`
485
+ // above) so the two surfaces read alike. `plan_count_all` is the
486
+ // PHYSICAL count — every canonically-named plan file on disk,
487
+ // status:superseded INCLUDED, same set `planNamingWarning` above
488
+ // diffs against (`canonicalAllPlanFiles`). The `_all` suffix
489
+ // deliberately echoes `scanPhasePlans`'s own `allPlanFiles` field so
490
+ // a reader can trace the name back to its source rather than guess
491
+ // which of two similarly-named integers is the filtered one.
492
+ plan_count: plans.length,
493
+ summary_count: summaries.length,
494
+ plan_count_all: canonicalAllPlanFiles.length,
408
495
  };
409
496
  if (planNamingWarning)
410
497
  result['warning'] = planNamingWarning;
@@ -421,9 +508,26 @@ function extractObjective(content) {
421
508
  const m = content.match(/<objective>\s*\n?\s*(.+)/);
422
509
  return m ? m[1].trim() : null;
423
510
  }
511
+ /**
512
+ * Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
513
+ * (case-folded exact match, falling back to canonical-id matching). Returns
514
+ * `null` when the token does not resolve to any plan in this phase (a typo
515
+ * or a cross-phase reference) — every call site treats that as "ignore this
516
+ * edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
517
+ * resolution, the `depends_on` display mapping, and (#2830) the
518
+ * halt-propagation node resolution, so the three can never disagree about
519
+ * which token resolves to which plan.
520
+ */
521
+ function resolveDependencyId(dep, planMap, canonicalToId) {
522
+ const lower = dep.toLowerCase();
523
+ return planMap.has(lower) ? planMap.get(lower).id : (canonicalToId.get(lower) ?? null);
524
+ }
424
525
  // O(V + E). Assigns each in-phase plan its longest-path topological level over the
425
- // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number }.
426
- // visited < rawPlans.length signals a dependency cycle.
526
+ // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
527
+ // order: string[] }. visited < rawPlans.length signals a dependency cycle. `order` (#2830) is
528
+ // the exact dequeue order this pass already produces — a valid topological order — passed to
529
+ // computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
530
+ // algorithm a second time over the same graph.
427
531
  function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
428
532
  const level = new Map();
429
533
  const inDeg = new Map();
@@ -434,10 +538,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
434
538
  if (!adj.has(p.id))
435
539
  adj.set(p.id, []);
436
540
  for (const dep of p.dependsOn) {
437
- const depLower = dep.toLowerCase();
438
- const resolvedDep = planMap.has(depLower)
439
- ? planMap.get(depLower).id
440
- : canonicalToId.get(depLower);
541
+ const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
441
542
  if (!resolvedDep)
442
543
  continue;
443
544
  if (!adj.has(resolvedDep))
@@ -472,7 +573,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
472
573
  }
473
574
  }
474
575
  }
475
- return { level, visited };
576
+ return { level, visited, order: queue };
476
577
  }
477
578
  function cmdPhasePlanIndex(cwd, phase, raw) {
478
579
  if (!phase) {
@@ -482,35 +583,97 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
482
583
  const normalized = normalizePhaseName(phase);
483
584
  let phaseDir = null;
484
585
  let phaseDirName = null;
586
+ let ambiguousMatches = null;
485
587
  try {
486
588
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
487
589
  const dirs = entries
488
590
  .filter((e) => e.isDirectory())
489
591
  .map((e) => e.name)
490
592
  .sort((a, b) => comparePhaseNum(a, b));
491
- const match = dirs.find((d) => phaseTokenMatches(d, normalized));
492
- if (match) {
493
- phaseDir = node_path_1.default.join(phasesDir, match);
494
- phaseDirName = match;
593
+ // #2528: selection delegates to the canonical two-pass matcher shared with
594
+ // the locator and the find-phase scan (this site previously first-matched
595
+ // with `.find()` and had no multi-match guard — the #2237 fail-loud rule
596
+ // now applies here too, so the three resolution paths cannot disagree).
597
+ const { matches } = matchPhaseDirs(dirs, normalized);
598
+ if (matches.length > 1) {
599
+ ambiguousMatches = matches;
600
+ }
601
+ else if (matches.length === 1) {
602
+ phaseDir = node_path_1.default.join(phasesDir, matches[0]);
603
+ phaseDirName = matches[0];
495
604
  }
496
605
  }
497
606
  catch {
498
607
  // phases dir doesn't exist
499
608
  }
609
+ if (ambiguousMatches) {
610
+ output({
611
+ phase: normalized,
612
+ error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
613
+ ambiguous_matches: ambiguousMatches,
614
+ plans: [], waves: {}, incomplete: [], has_checkpoints: false,
615
+ }, raw);
616
+ return;
617
+ }
500
618
  if (!phaseDir) {
501
- output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
619
+ output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
502
620
  return;
503
621
  }
504
622
  void phaseDirName; // used only to set phaseDir above
623
+ // phaseFiles stays root-only readdirSync — it feeds only
624
+ // describeNonCanonicalPlans's near-miss naming diagnostic below, which is
625
+ // advisory text, not a counted/scheduled file set.
505
626
  const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
506
- const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
507
- const summaryFiles = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
508
- const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
509
- const completedPlanIds = new Set(summaryFiles.flatMap((s) => {
510
- const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
511
- const canonical = extractCanonicalPlanId(s);
512
- return canonical === exact ? [exact] : [exact, canonical];
513
- }));
627
+ // #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
628
+ // plan/summary sets (root+nested, status: superseded EXCLUDED) from the
629
+ // single owner. This fixes two real bugs in the wave/dependency index this
630
+ // function builds: (1) a superseded plan used to still get scheduled into
631
+ // an execution wave, and (2) a phase using the #3139 nested `plans/`
632
+ // layout used to report ZERO plans (root-only readdirSync, no `plans/`
633
+ // join).
634
+ // #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
635
+ // predicate — scanPhasePlans's own planFiles/allPlanFiles carry
636
+ // `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
637
+ // live-plan COUNTING elsewhere), which silently scheduled a
638
+ // non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
639
+ // here and defeated this command's #2893 naming-convention diagnostic (no
640
+ // warning). Restores the pre-#3183, tested behavior: only canonical
641
+ // root/nested filenames are ever counted or scheduled by this command.
642
+ const phaseScan = scanPhasePlans(phaseDir);
643
+ const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
644
+ const summaryFiles = phaseScan.summaryFiles;
645
+ // describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
646
+ // supersession — compare against allPlanFiles (every plan-shaped file the
647
+ // owner recognizes, canonical or not) rather than the live-only planFiles,
648
+ // so a superseded-but-canonically-named plan is not misreported as a
649
+ // naming violation.
650
+ const planNamingWarning = describeNonCanonicalPlans(phaseFiles, phaseScan.allPlanFiles.filter(isCanonicalPlanFile));
651
+ // #3183: completion pairing via the canonical findUnsummarizedPlans
652
+ // (shares its `summaryCandidates` matching rule with countMatchedSummaries,
653
+ // and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
654
+ // `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
655
+ // extractCanonicalPlanId, which only ever handled the root-canonical
656
+ // `-PLAN.md`/`-SUMMARY.md` naming form.
657
+ //
658
+ // #3345: the summary list is filtered through the SAME shared predicate
659
+ // scanPhasePlans filters its countable set with
660
+ // (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
661
+ // `status: blocked` reads as NO completion record here — has_summary false,
662
+ // the plan lands in `incomplete` — exactly matching the count side. Fail-open
663
+ // on a SUMMARY with no status key / unreadable file (filename fallback);
664
+ // `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
665
+ // below still indexes EVERY summary on disk because the halted lookup is a
666
+ // file resolution for reading status, not a completion pairing.
667
+ const countableSummaryFiles = summaryFiles.filter((f) => !isSummaryFileBlocked(node_path_1.default.join(phaseDir, f)));
668
+ const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
669
+ // #2830: reverse lookup from a completed plan's id (exact or canonical) to
670
+ // the actual summary filename, so a plan's own SUMMARY frontmatter can be
671
+ // read for its `status`. Shared builder (also used by phase-locator.cts's
672
+ // searchPhaseInDir) so the two can never disagree about which summary
673
+ // belongs to which plan. This is a FILE resolution for reading halted
674
+ // status, not a completion-count pairing rule, so it is unaffected by the
675
+ // #3183 pairing migration above.
676
+ const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
514
677
  // ── Pass 1: parse each plan file ─────────────────────────────────────────
515
678
  const rawPlans = [];
516
679
  for (const planFile of planFiles) {
@@ -543,7 +706,26 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
543
706
  // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
544
707
  filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
545
708
  }
546
- const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
709
+ // #1689: optional per-plan specialist executor hint. Read verbatim here; the
710
+ // orchestrator resolves it against the active runtime's agent dir at dispatch
711
+ // time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
712
+ // gsd-executor when the field is unset or the named agent does not resolve.
713
+ let agentHint = null;
714
+ const fmAgentHint = fm['agent_hint'];
715
+ if (fmAgentHint !== undefined) {
716
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
717
+ const hintStr = String(fmAgentHint).trim();
718
+ agentHint = hintStr !== '' ? hintStr : null;
719
+ }
720
+ const hasSummary = !unsummarizedPlanFiles.has(planFile);
721
+ // #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
722
+ // a designed stop still writes a completion record, just one whose status
723
+ // says "halted" rather than "complete". Only look up the summary file
724
+ // when one exists; there is nothing to read otherwise.
725
+ const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(extractCanonicalPlanId(planFile));
726
+ const halted = hasSummary && summaryFile !== undefined
727
+ ? isSummaryFileHalted(node_path_1.default.join(phaseDir, summaryFile))
728
+ : false;
547
729
  rawPlans.push({
548
730
  id: planId,
549
731
  declaredWave,
@@ -551,8 +733,10 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
551
733
  autonomous,
552
734
  objective: extractObjective(content) || fm['objective'] || null,
553
735
  filesModified,
736
+ agentHint,
554
737
  taskCount,
555
738
  hasSummary,
739
+ halted,
556
740
  });
557
741
  }
558
742
  // ── Pass 2: topological level assignment via depends_on DAG ──────────────
@@ -568,26 +752,47 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
568
752
  }
569
753
  const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
570
754
  const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
571
- const { level, visited } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
755
+ const { level, visited, order } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
572
756
  if (visited < rawPlans.length) {
573
757
  const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
574
758
  error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
575
759
  return;
576
760
  }
761
+ // #2830: single shared halt-propagation pass, reusing the SAME id
762
+ // resolution (planMap/canonicalToId) AND the SAME topological order
763
+ // (`order`, computeDependencyLevels's own Kahn's-algorithm dequeue
764
+ // sequence) — passed as `precomputedOrder` so computeHaltPropagation does
765
+ // NOT run Kahn's algorithm a second time over this graph.
766
+ const haltNodes = rawPlans.map((p) => ({
767
+ id: p.id,
768
+ resolvedDependsOn: p.dependsOn
769
+ .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
770
+ .filter((id) => id !== null),
771
+ halted: p.halted,
772
+ }));
773
+ const { blockedBy } = computeHaltPropagation(haltNodes, order);
577
774
  // ── Pass 3: determine lowest bucket key and build output ─────────────────
578
775
  const anyWaveZero = rawPlans.some((p) => p.declaredWave === 0);
579
776
  const levelOffset = anyWaveZero ? 0 : 1;
580
777
  const plans = [];
581
778
  const waves = {};
582
779
  const incomplete = [];
780
+ const runnable = [];
583
781
  let hasCheckpoints = false;
584
782
  const warnings = [];
585
783
  for (const rawPlan of rawPlans) {
586
784
  if (!rawPlan.autonomous) {
587
785
  hasCheckpoints = true;
588
786
  }
787
+ const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
589
788
  if (!rawPlan.hasSummary) {
590
789
  incomplete.push(rawPlan.id);
790
+ // #2830: the runnable-only view — incomplete AND not transitively
791
+ // blocked by a halted upstream plan. Additive alongside `incomplete`,
792
+ // which keeps its existing "no SUMMARY yet" meaning unchanged.
793
+ if (blockedByIds.length === 0) {
794
+ runnable.push(rawPlan.id);
795
+ }
591
796
  }
592
797
  const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
593
798
  const effectiveWave = computedWave;
@@ -597,6 +802,13 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
597
802
  const plan = {
598
803
  id: rawPlan.id,
599
804
  wave: effectiveWave,
805
+ // DELIBERATELY not `resolveDependencyId`: the emitted field is a DISPLAY
806
+ // mapping, not the DAG resolution. It rewrites a dep only when it names a
807
+ // plan directly (planMap) and otherwise passes it through verbatim — a
808
+ // short canonical prefix like `24-01` stays `24-01` rather than becoming
809
+ // `24-01-auth-hardening`. #3785 pins that contract. Full resolution via
810
+ // canonicalToId is used for the wave DAG and #2830 halt propagation only;
811
+ // routing this line through it too silently changed the output shape.
600
812
  depends_on: rawPlan.dependsOn.map((dep) => {
601
813
  const lower = String(dep).toLowerCase();
602
814
  return planMap.has(lower) ? planMap.get(lower).id : dep;
@@ -604,8 +816,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
604
816
  autonomous: rawPlan.autonomous,
605
817
  objective: rawPlan.objective,
606
818
  files_modified: rawPlan.filesModified,
819
+ agent_hint: rawPlan.agentHint,
607
820
  task_count: rawPlan.taskCount,
608
821
  has_summary: rawPlan.hasSummary,
822
+ // #2830: additive fields — halted is this plan's OWN status; blocked_by
823
+ // names the halted plan(s) transitively upstream of it (empty when not
824
+ // blocked). Neither mutates has_summary/incomplete's existing meaning.
825
+ halted: rawPlan.halted,
826
+ blocked_by: blockedByIds,
609
827
  };
610
828
  plans.push(plan);
611
829
  const waveKey = String(effectiveWave);
@@ -619,6 +837,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
619
837
  plans,
620
838
  waves,
621
839
  incomplete,
840
+ runnable,
622
841
  has_checkpoints: hasCheckpoints,
623
842
  };
624
843
  if (planNamingWarning)
@@ -650,10 +869,54 @@ function describeGoalShapedTitle(description) {
650
869
  return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
651
870
  `as the phase title; consider a short title with the detail moved to **Goal:**.`);
652
871
  }
872
+ /**
873
+ * #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
874
+ * entry should be inserted — at the end of the active phase list, scoped to the
875
+ * CURRENT MILESTONE so the entry can never land before a trailing `---` in
876
+ * shipped/history/backlog material (the file's last `---` on a long roadmap
877
+ * sits deep in archive). When no current milestone can be resolved (no
878
+ * STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
879
+ * legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
880
+ * their existing behavior.
881
+ */
882
+ function phaseEntryInsertOffset(rawContent, cwd) {
883
+ const ranges = currentMilestoneRawRanges(rawContent, cwd);
884
+ if (!ranges) {
885
+ const legacy = rawContent.lastIndexOf('\n---');
886
+ return legacy > 0 ? legacy : rawContent.length;
887
+ }
888
+ const window = rawContent.slice(ranges.primary.start, ranges.primary.end);
889
+ const lastSeparator = window.lastIndexOf('\n---');
890
+ return lastSeparator > 0 ? ranges.primary.start + lastSeparator : ranges.primary.end;
891
+ }
892
+ /**
893
+ * #3262 (write-time milestone-scope guard): the phase-creation and
894
+ * phase-insertion entry templates interpolate the caller's `description`
895
+ * verbatim into `### Phase N: ${description}`. A description embedding a
896
+ * level 1-3 heading that carries a milestone marker (version token,
897
+ * ✅/📋/🚧/🔄, or the word "Milestone") would splice a heading that TERMINATES
898
+ * the current milestone window (`computeMilestoneSectionEnd`) and silently
899
+ * drops every later phase out of the derived milestone phase set. Reject
900
+ * before any write or phase-directory creation — the fail-loud sibling of
901
+ * the edit-phase workflow's depends_on gate. The predicate itself
902
+ * (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
903
+ * so ordinary descriptions and the phase's own numbered heading never trip it.
904
+ */
905
+ function assertDescriptionPreservesMilestoneScope(description, command) {
906
+ const offending = findMilestoneScopeHeadingLines(description);
907
+ if (offending.length === 0)
908
+ return;
909
+ error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
910
+ `the current milestone window and silently drop later phases out of the milestone scope. ` +
911
+ `Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
912
+ `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
913
+ `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
914
+ }
653
915
  function cmdPhaseAdd(cwd, description, raw, customId) {
654
916
  if (!description) {
655
917
  error('description required for phase add');
656
918
  }
919
+ assertDescriptionPreservesMilestoneScope(description, 'phase add');
657
920
  const config = loadConfig(cwd);
658
921
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
659
922
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -689,12 +952,14 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
689
952
  let m;
690
953
  while ((m = headerPattern.exec(content)) !== null) {
691
954
  const num = parseInt(m[1], 10);
692
- if (num !== 999)
955
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
956
+ if (!isSentinelPhaseId(num))
693
957
  usedPhaseNums.add(num);
694
958
  }
695
959
  while ((m = bulletPattern.exec(content)) !== null) {
696
960
  const num = parseInt(m[1], 10);
697
- if (num !== 999)
961
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
962
+ if (!isSentinelPhaseId(num))
698
963
  usedPhaseNums.add(num);
699
964
  }
700
965
  // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
@@ -706,7 +971,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
706
971
  if (!match)
707
972
  continue;
708
973
  const num = parseInt(match[1], 10);
709
- if (num !== 999)
974
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
975
+ if (!isSentinelPhaseId(num))
710
976
  usedPhaseNums.add(num);
711
977
  }
712
978
  }
@@ -726,14 +992,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
726
992
  ? ''
727
993
  : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
728
994
  const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
729
- let updatedContent;
730
- const lastSeparator = rawContent.lastIndexOf('\n---');
731
- if (lastSeparator > 0) {
732
- updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
733
- }
734
- else {
735
- updatedContent = rawContent + phaseEntry;
736
- }
995
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd);
996
+ const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
737
997
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
738
998
  return { newPhaseId: _newPhaseId, dirName: _dirName };
739
999
  });
@@ -754,6 +1014,12 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
754
1014
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
755
1015
  error('descriptions array required for phase add-batch');
756
1016
  }
1017
+ // #3262: validate every description BEFORE the lock — the batch is
1018
+ // all-or-nothing, so one offending description must reject the whole batch
1019
+ // with no ROADMAP write and no phase directories created.
1020
+ for (const description of descriptions) {
1021
+ assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
1022
+ }
757
1023
  const config = loadConfig(cwd);
758
1024
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
759
1025
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -771,7 +1037,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
771
1037
  let m;
772
1038
  while ((m = phasePattern.exec(content)) !== null) {
773
1039
  const num = parseInt(m[1], 10);
774
- if (num === 999)
1040
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1041
+ if (isSentinelPhaseId(num))
775
1042
  continue;
776
1043
  if (num > maxPhase)
777
1044
  maxPhase = num;
@@ -784,7 +1051,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
784
1051
  if (!match)
785
1052
  continue;
786
1053
  const num = parseInt(match[1], 10);
787
- if (num === 999)
1054
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1055
+ if (isSentinelPhaseId(num))
788
1056
  continue;
789
1057
  if (num > maxPhase)
790
1058
  maxPhase = num;
@@ -812,11 +1080,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
812
1080
  ? ''
813
1081
  : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
814
1082
  const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
815
- const lastSeparator = rawContent.lastIndexOf('\n---');
816
- rawContent =
817
- lastSeparator > 0
818
- ? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
819
- : rawContent + phaseEntry;
1083
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1084
+ rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
820
1085
  added.push({
821
1086
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
822
1087
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
@@ -835,6 +1100,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
835
1100
  if (!afterPhase || !description) {
836
1101
  error('after-phase and description required for phase insert');
837
1102
  }
1103
+ assertDescriptionPreservesMilestoneScope(description, 'phase insert');
838
1104
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
839
1105
  if (!node_fs_1.default.existsSync(roadmapPath)) {
840
1106
  error('ROADMAP.md not found');
@@ -883,7 +1149,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
883
1149
  error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
884
1150
  }
885
1151
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
886
- const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
1152
+ const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
887
1153
  for (const dir of dirs) {
888
1154
  const dm = dir.match(decimalPattern);
889
1155
  if (dm)
@@ -911,15 +1177,30 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
911
1177
  const phaseLabel = useBold
912
1178
  ? `**Phase ${_decimalPhase}: ${description}**`
913
1179
  : `Phase ${_decimalPhase}: ${description}`;
1180
+ // #3413 review fix: bulletEntry stays hardcoded '\n'. The on-disk EOL
1181
+ // is decided at write time by platformWriteSync's normalizeContent /
1182
+ // _normalizeMd (shell-command-projection.cts), which unconditionally
1183
+ // converts \r\n -> \n for any .md target — so whatever terminator is
1184
+ // used here in memory is erased before the file is ever written, and
1185
+ // templating it via detectEol(rawContent) was inert dead code. '\n'
1186
+ // matches what platformWriteSync enforces anyway.
914
1187
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
915
- const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1188
+ // #3413: was `[^\n]*`, which on CRLF content swallows the line's
1189
+ // trailing \r into the match, shifting bulletLineEnd to land BETWEEN
1190
+ // the \r and \n of the original CRLF pair — a pure splice-POSITION
1191
+ // bug on the not-yet-write-normalized CRLF read (independent of the
1192
+ // final on-disk EOL, which platformWriteSync always forces to LF for
1193
+ // .md targets regardless). Widening to [^\r\n]* stops the match at the
1194
+ // true line-content boundary so bulletLineEnd lands cleanly before the
1195
+ // terminator.
1196
+ const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
916
1197
  const bulletMatchResult = rawContent.match(targetBulletPattern);
917
1198
  if (!bulletMatchResult) {
918
1199
  error(`Could not find Phase ${afterPhase} bullet line`);
919
1200
  }
920
1201
  const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
921
1202
  const afterBullet = rawContent.slice(bulletLineEnd);
922
- const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
1203
+ const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
923
1204
  let insertIdx;
924
1205
  if (nextBulletMatch) {
925
1206
  insertIdx = bulletLineEnd + nextBulletMatch.index;
@@ -939,7 +1220,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
939
1220
  }
940
1221
  const headerIdx = rawContent.indexOf(headerMatch[0]);
941
1222
  const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
942
- const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
1223
+ const nextPhaseMatch = afterHeader.match(/\r?\n#{2,4}\s+Phase\s+\d[\d.]*/i);
943
1224
  let insertIdx;
944
1225
  if (nextPhaseMatch) {
945
1226
  insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
@@ -993,9 +1274,33 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
993
1274
  }
994
1275
  return { renamedDirs, renamedFiles };
995
1276
  }
1277
+ /**
1278
+ * Find a free name to move an occupying file aside to, on collision, so the
1279
+ * intended rename can proceed without destroying either file. Appends the
1280
+ * literal `.orphaned` suffix to the whole existing filename (never `.md`,
1281
+ * so no phase-directory scan predicate — all of which filter on
1282
+ * `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
1283
+ * the displaced file back up as any phase's artifact). Falls back to a
1284
+ * numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
1285
+ * itself is taken, bounded at 100 attempts so a pathological directory
1286
+ * cannot loop forever; returns null if no free name is found within that
1287
+ * bound, letting the caller fall back to skip-and-report.
1288
+ */
1289
+ function findOrphanedDisplacementName(dir, fileName) {
1290
+ const base = `${fileName}.orphaned`;
1291
+ if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, base)))
1292
+ return base;
1293
+ for (let n = 2; n <= 100; n++) {
1294
+ const candidate = `${base}.${n}`;
1295
+ if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, candidate)))
1296
+ return candidate;
1297
+ }
1298
+ return null;
1299
+ }
996
1300
  function renameIntegerPhases(phasesDir, removedInt) {
997
1301
  const renamedDirs = [];
998
1302
  const renamedFiles = [];
1303
+ const renamedFileCollisions = [];
999
1304
  const dirs = readSubdirectories(phasesDir, true);
1000
1305
  const toRename = dirs
1001
1306
  .map((dir) => {
@@ -1003,7 +1308,8 @@ function renameIntegerPhases(phasesDir, removedInt) {
1003
1308
  if (!m)
1004
1309
  return null;
1005
1310
  const dirInt = parseInt(m[1], 10);
1006
- return dirInt > removedInt && dirInt !== 999
1311
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1312
+ return dirInt > removedInt && !isSentinelPhaseId(dirInt)
1007
1313
  ? {
1008
1314
  dir,
1009
1315
  oldInt: dirInt,
@@ -1024,21 +1330,77 @@ function renameIntegerPhases(phasesDir, removedInt) {
1024
1330
  const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
1025
1331
  const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
1026
1332
  const newDirName = `${newPrefix}-${item.slug}`;
1333
+ // WARNING-3 (#3511 review): the directory match above accepts an
1334
+ // UNPADDED leading number (`\d+`), so a supported rename can pair a
1335
+ // 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
1336
+ // `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
1337
+ // (oldPrefix always 2-padded) misses that file: it becomes desynced from
1338
+ // its now-renamed directory and the phase reads `missing`. Try the
1339
+ // UNPADDED old-prefix form as a fallback so such an artifact renames
1340
+ // alongside its directory. A trailing-digit boundary check keeps the
1341
+ // unpadded form from over-matching a DIFFERENT phase's file (unpadded
1342
+ // prefix "1" must not match "10-…").
1343
+ const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
1027
1344
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
1028
1345
  renamedDirs.push({ from: item.dir, to: newDirName });
1029
1346
  for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
1347
+ let matchedPrefix = null;
1030
1348
  if (f.startsWith(oldPrefix)) {
1031
- const newFileName = newPrefix + f.slice(oldPrefix.length);
1032
- (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
1349
+ matchedPrefix = oldPrefix;
1350
+ }
1351
+ else if (oldPrefixUnpadded !== oldPrefix &&
1352
+ f.startsWith(oldPrefixUnpadded) &&
1353
+ // Token-boundary check: the character immediately after the unpadded
1354
+ // prefix must be a separator (`-`, `.`) or end-of-name, not any
1355
+ // non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
1356
+ // too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
1357
+ // wholly unrelated file whose name merely starts with the digit).
1358
+ (f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))) {
1359
+ matchedPrefix = oldPrefixUnpadded;
1360
+ }
1361
+ if (matchedPrefix) {
1362
+ const newFileName = newPrefix + f.slice(matchedPrefix.length);
1363
+ const destPath = node_path_1.default.join(phasesDir, newDirName, newFileName);
1364
+ // Collision guard: the padded and unpadded prefix forms can both
1365
+ // resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
1366
+ // `9-VERIFICATION.md` in one directory both target
1367
+ // `08-VERIFICATION.md`), and a stray cross-phase file can already sit
1368
+ // at the destination name (e.g. a leftover `08-VERIFICATION.md`
1369
+ // belonging to a DIFFERENT phase, inside phase 9's directory).
1370
+ // Renaming blindly over an existing target silently destroys
1371
+ // whichever file loses; skipping the rename instead lets the stray
1372
+ // outrank the phase's own renamed artifact once it lands at the
1373
+ // canonical name. Neither is acceptable: move the OCCUPYING file
1374
+ // aside first (never overwrite, never skip the real rename), then
1375
+ // complete the intended rename so the phase's own artifact takes the
1376
+ // canonical name. This also handles a target that was already
1377
+ // claimed by an EARLIER file in this same pass, since that earlier
1378
+ // rename already created it on disk.
1379
+ if (node_fs_1.default.existsSync(destPath)) {
1380
+ const displacedName = findOrphanedDisplacementName(node_path_1.default.join(phasesDir, newDirName), newFileName);
1381
+ if (displacedName === null) {
1382
+ // No free displacement name within the bounded search — fall
1383
+ // back to skip-and-report rather than looping or overwriting.
1384
+ renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
1385
+ continue;
1386
+ }
1387
+ (0, shell_command_projection_cjs_1.retryRenameSync)(destPath, node_path_1.default.join(phasesDir, newDirName, displacedName));
1388
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
1389
+ renamedFiles.push({ from: f, to: newFileName });
1390
+ renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
1391
+ continue;
1392
+ }
1393
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
1033
1394
  renamedFiles.push({ from: f, to: newFileName });
1034
1395
  }
1035
1396
  }
1036
1397
  }
1037
- return { renamedDirs, renamedFiles };
1398
+ return { renamedDirs, renamedFiles, renamedFileCollisions };
1038
1399
  }
1039
1400
  function decrementRoadmapPhaseNumber(raw, removedInt) {
1040
1401
  const num = parseInt(raw, 10);
1041
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1402
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1403
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1042
1404
  return raw;
1043
1405
  return String(num - 1);
1044
1406
  }
@@ -1047,13 +1409,15 @@ function decrementRoadmapPhaseToken(raw, removedInt) {
1047
1409
  if (!match)
1048
1410
  return raw;
1049
1411
  const num = parseInt(match[1], 10);
1050
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1412
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1413
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1051
1414
  return raw;
1052
1415
  return `${num - 1}${match[2] || ''}`;
1053
1416
  }
1054
1417
  function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
1055
1418
  const num = parseInt(raw, 10);
1056
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1419
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1420
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1057
1421
  return raw;
1058
1422
  return String(num - 1).padStart(raw.length, '0');
1059
1423
  }
@@ -1094,7 +1458,15 @@ function findDataRowLine(sectionText, dataRowIndex) {
1094
1458
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
1095
1459
  withPlanningLock(cwd, () => {
1096
1460
  let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1097
- const escaped = escapeRegex(targetPhase);
1461
+ const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
1462
+ // #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
1463
+ // of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
1464
+ // user's remove query is usually unpadded (`1.1`) — and integer headings
1465
+ // legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
1466
+ // makes the token padding-insensitive in both directions without widening
1467
+ // to other ids: the token stays anchored between `Phase\s+`/line-start and
1468
+ // `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
1469
+ const padTolerant = `0*${escaped}`;
1098
1470
  // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
1099
1471
  // detail section INCLUDING its own heading line. Migrated onto deleteSection
1100
1472
  // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
@@ -1106,9 +1478,9 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1106
1478
  // such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
1107
1479
  // away everything after it — including a trailing `## Progress` heading and
1108
1480
  // its tracking table.
1109
- const phaseHeadingRe = new RegExp(`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1481
+ const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1110
1482
  content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
1111
- content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
1483
+ content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
1112
1484
  // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
1113
1485
  // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
1114
1486
  // of updateTableCell). Scoped to the `## Progress` section — mirroring
@@ -1135,7 +1507,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1135
1507
  const matchRemovedProgressRow = (row) => {
1136
1508
  const firstCellRaw = (Object.values(row)[0] ?? '').trim();
1137
1509
  if (isDecimal) {
1138
- return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1510
+ return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1139
1511
  }
1140
1512
  const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
1141
1513
  if (!leadingMatch || leadingMatch[2])
@@ -1150,7 +1522,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1150
1522
  if (!isDecimal) {
1151
1523
  // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
1152
1524
  // is re-emitted verbatim — a tagged later phase still gets renumbered.
1153
- content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
1525
+ content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\r\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
1154
1526
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
1155
1527
  // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
1156
1528
  // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
@@ -1212,7 +1584,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1212
1584
  if (!m)
1213
1585
  return false;
1214
1586
  const num = parseInt(m[1], 10);
1215
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1587
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1588
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1216
1589
  return false;
1217
1590
  processedOrdinalRows.add(index);
1218
1591
  matchedRowIndex = index;
@@ -1225,7 +1598,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1225
1598
  const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
1226
1599
  const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
1227
1600
  const padMatch = targetLine
1228
- ? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
1601
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
1229
1602
  : null;
1230
1603
  const leadPad = padMatch ? padMatch[1] : ' ';
1231
1604
  const trailPad = padMatch ? padMatch[2] : ' ';
@@ -1244,6 +1617,33 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1244
1617
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
1245
1618
  });
1246
1619
  }
1620
+ /**
1621
+ * #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
1622
+ * the leading frontmatter block's closing `---` fence — so a body field never
1623
+ * lands before the opening fence. The former whole-content prepend
1624
+ * (`field + content`) put the line ABOVE the opening `---`, and
1625
+ * syncStateFrontmatter then treated the scrambled fence structure as TWO
1626
+ * frontmatter blocks, rebuilding a derived one on top of the original
1627
+ * (milestone_name from a ROADMAP heading, total_phases counting the removed
1628
+ * phase, a stray 'Total Phases: 0' between fences). A file with no leading
1629
+ * frontmatter is all body: the field goes to content start, preserving the
1630
+ * former behavior for that shape.
1631
+ */
1632
+ function insertStateBodyFieldAtTop(content, fieldLine) {
1633
+ // Split AND join on bare '\n' so CRLF line endings stay attached to their
1634
+ // own lines — each '\r' remains the tail of the line it terminated, where
1635
+ // the trimmed fence compare still matches it. (#3572 review: splitting on
1636
+ // '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
1637
+ const lines = content.split('\n');
1638
+ if ((lines[0] ?? '').trim() === '---') {
1639
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
1640
+ if (closeIdx !== -1) {
1641
+ lines.splice(closeIdx + 1, 0, '', fieldLine);
1642
+ return lines.join('\n');
1643
+ }
1644
+ }
1645
+ return fieldLine + '\n' + content;
1646
+ }
1247
1647
  function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1248
1648
  if (!targetPhase)
1249
1649
  error('phase number required for phase remove');
@@ -1255,24 +1655,55 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1255
1655
  const isDecimal = targetPhase.includes('.');
1256
1656
  const force = options.force || false;
1257
1657
  const subdirs = readSubdirectories(phasesDir, true);
1258
- const targetDir = subdirs.find((d) => phaseTokenMatches(d, normalized)) || null;
1658
+ // #2237/#2528: every other resolution path refuses to choose between multiple
1659
+ // directories claiming one phase number. This one is the DESTRUCTIVE path, so
1660
+ // taking `matches[0]` silently is strictly worse than anywhere else: it turns
1661
+ // "resolve nothing" into "delete one of two candidates, unrecoverably, and
1662
+ // renumber every phase after it". Refuse before any file is touched.
1663
+ const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
1664
+ if (phaseDirMatches.length > 1) {
1665
+ output({
1666
+ removed: null,
1667
+ error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
1668
+ + `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
1669
+ + 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
1670
+ ambiguous_matches: phaseDirMatches,
1671
+ directory_deleted: null,
1672
+ renamed_directories: [],
1673
+ renamed_files: [],
1674
+ roadmap_updated: false,
1675
+ state_updated: false,
1676
+ }, raw);
1677
+ return;
1678
+ }
1679
+ const targetDir = phaseDirMatches[0] || null;
1259
1680
  if (targetDir && !force) {
1260
- const files = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, targetDir));
1261
- const summaries = files.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1262
- if (summaries.length > 0) {
1263
- error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
1681
+ // #3183: canonical summary set (root+nested) from the single owner —
1682
+ // a root-only readdirSync filter left nested (#3139 layout) summaries
1683
+ // invisible, letting a phase with completed nested work be deleted
1684
+ // without --force.
1685
+ const summaryCount = scanPhasePlans(node_path_1.default.join(phasesDir, targetDir)).summaryFiles.length;
1686
+ if (summaryCount > 0) {
1687
+ error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
1264
1688
  }
1265
1689
  }
1266
1690
  if (targetDir)
1267
1691
  node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, targetDir), { recursive: true, force: true });
1268
1692
  let renamedDirs = [];
1269
1693
  let renamedFiles = [];
1694
+ let renamedFileCollisions = [];
1270
1695
  try {
1271
- const renamed = isDecimal
1272
- ? renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10))
1273
- : renameIntegerPhases(phasesDir, parseInt(normalized, 10));
1274
- renamedDirs = renamed.renamedDirs;
1275
- renamedFiles = renamed.renamedFiles;
1696
+ if (isDecimal) {
1697
+ const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
1698
+ renamedDirs = renamed.renamedDirs;
1699
+ renamedFiles = renamed.renamedFiles;
1700
+ }
1701
+ else {
1702
+ const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
1703
+ renamedDirs = renamed.renamedDirs;
1704
+ renamedFiles = renamed.renamedFiles;
1705
+ renamedFileCollisions = renamed.renamedFileCollisions;
1706
+ }
1276
1707
  }
1277
1708
  catch (e) {
1278
1709
  // #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
@@ -1289,19 +1720,69 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1289
1720
  }
1290
1721
  updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1291
1722
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1723
+ let stateUpdated = false;
1292
1724
  if (node_fs_1.default.existsSync(statePath)) {
1293
- readModifyWriteStateMd(statePath, (stateContent) => {
1294
- const totalRaw = stateExtractField(stateContent, 'Total Phases');
1725
+ // #2640: report whether STATE.md content actually changed, not just file
1726
+ // existence (fs.existsSync was trivially true). Also ensure the body
1727
+ // transform produces a diff so readModifyWriteStateMd's no-op guard
1728
+ // (#948) doesn't skip the frontmatter resync — without that, the
1729
+ // progress.* frontmatter block stays stale when the body has no
1730
+ // 'Total Phases:' or 'of N' phrase.
1731
+ stateUpdated = readModifyWriteStateMd(statePath, (stateContent) => {
1732
+ let modified = stateContent;
1733
+ const totalRaw = stateExtractField(modified, 'Total Phases');
1295
1734
  if (totalRaw) {
1296
- stateContent =
1297
- stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
1298
- stateContent;
1735
+ // #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
1736
+ // an earlier remove whose dir-count was 0) must not decrement to -1 on
1737
+ // the next removal.
1738
+ modified =
1739
+ stateReplaceField(modified, 'Total Phases', String(Math.max(0, parseInt(totalRaw, 10) - 1))) || modified;
1299
1740
  }
1300
- const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1741
+ const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1301
1742
  if (ofMatch) {
1302
- stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
1743
+ modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`);
1303
1744
  }
1304
- return stateContent;
1745
+ // #2640: if neither body field was found, the transform is a no-op.
1746
+ // readModifyWriteStateMd's no-op guard (#948) would then skip the
1747
+ // frontmatter resync, leaving progress.* stale. Force a body diff
1748
+ // ONLY when a phase directory was actually removed (targetDir !== null)
1749
+ // so the guard passes and syncStateFrontmatter rebuilds the frontmatter
1750
+ // from the post-deletion disk/ROADMAP state. Without the targetDir gate,
1751
+ // a no-op removal (ROADMAP-only phase, no directory) would inject a
1752
+ // spurious 'Total Phases:' line into a body that intentionally lacked one.
1753
+ if (targetDir && modified === stateContent) {
1754
+ // subdirs was read before the deletion; excluding the removed target
1755
+ // gives the remaining count. Renumbering changes names but not count.
1756
+ //
1757
+ // #2528: exclude the directory that was ACTUALLY deleted, by identity,
1758
+ // rather than re-deriving "which dir was the target" from the query.
1759
+ // The two are not the same predicate here: `targetDir` comes from
1760
+ // `matchPhaseDirs`, whose bare-integer fallback resolves digit-leading
1761
+ // dirs (`05-80-20-cleanup` for query `5`) that `phaseTokenMatches`
1762
+ // reports as non-matching — so a token re-derivation would count the
1763
+ // just-deleted directory as still present and write a `Total Phases`
1764
+ // one too high. Identity is also what the comment above already
1765
+ // claims this filter does, and the block is gated on targetDir.
1766
+ // (#3572 note: this body field counts DIRECTORIES on disk; the
1767
+ // frontmatter progress.* block is rebuilt by syncStateFrontmatter
1768
+ // from the post-removal ROADMAP — the two counts legitimately differ
1769
+ // when phases exist in ROADMAP without directories.)
1770
+ const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
1771
+ if (totalRaw) {
1772
+ modified =
1773
+ stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
1774
+ }
1775
+ else {
1776
+ // No 'Total Phases:' field in the body — insert one at the start of
1777
+ // the BODY so the no-op guard sees a diff. #3572: the former
1778
+ // whole-content prepend landed the line BEFORE the opening '---'
1779
+ // fence and corrupted STATE.md into two frontmatter blocks.
1780
+ // syncStateFrontmatter will still rebuild the frontmatter
1781
+ // progress.* block from the real disk/ROADMAP count.
1782
+ modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
1783
+ }
1784
+ }
1785
+ return modified;
1305
1786
  }, cwd);
1306
1787
  }
1307
1788
  output({
@@ -1309,8 +1790,9 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1309
1790
  directory_deleted: targetDir,
1310
1791
  renamed_directories: renamedDirs,
1311
1792
  renamed_files: renamedFiles,
1793
+ renamed_file_collisions: renamedFileCollisions,
1312
1794
  roadmap_updated: true,
1313
- state_updated: node_fs_1.default.existsSync(statePath),
1795
+ state_updated: stateUpdated,
1314
1796
  }, raw);
1315
1797
  }
1316
1798
  function writePlanningFileSet(writes) {
@@ -1370,11 +1852,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1370
1852
  // init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
1371
1853
  // explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
1372
1854
  const availableWorkstreams = listAvailableWorkstreams(cwd);
1373
- const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
1855
+ // #3579 root-cause fix: this is a check, not a consuming read — use the
1856
+ // non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
1857
+ // here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
1858
+ // which would misreport a present-but-bad marker as no marker at all.
1859
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
1374
1860
  if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
1861
+ // #3579: getActiveWorkstream now inherits a pointer-less session's read
1862
+ // from the shared .planning/active-workstream marker, so reaching this
1863
+ // branch with a marker actually present means the marker EXISTED but
1864
+ // didn't resolve (invalid name, or its workstream dir is gone) — a
1865
+ // materially different situation from "nothing was ever set" and one
1866
+ // that deserves its own diagnostic instead of the generic message below.
1867
+ const diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
1868
+ if (diagnosis.present) {
1869
+ error(`phase.complete requires a workstream in workstream mode — the active-workstream marker names '${diagnosis.value}', but it did not resolve: ${describeUnresolvedWorkstreamReason(diagnosis.reason)}. Root STATE.md/ROADMAP.md (likely stale) would be written otherwise. ` +
1870
+ `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} to point it at an existing workstream. ` +
1871
+ `Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED, { marker_value: diagnosis.value, marker_reason: diagnosis.reason });
1872
+ }
1375
1873
  error(`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
1376
1874
  `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} first. ` +
1377
- `Available workstreams: ${availableWorkstreams.join(', ')}`);
1875
+ `Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
1378
1876
  }
1379
1877
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1380
1878
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
@@ -1393,10 +1891,97 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1393
1891
  : 0;
1394
1892
  let requirementsUpdated = false;
1395
1893
  const warnings = [];
1894
+ // ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
1895
+ // composition's preservation stage restores a curated frontmatter value
1896
+ // over a disagreeing derived one, that divergence is surfaced here rather
1897
+ // than silently absorbed. Structured (field + reason), not prose, so a
1898
+ // caller can assert on the value rather than regex a rendered message.
1899
+ // Named `preservation_warnings`, NOT `warnings`: `warnings` above is
1900
+ // already a prose `string[]` on this exact command — reusing it for a
1901
+ // structured `{field, reason}[]` shape would be the "Generative Fix
1902
+ // Divergence" anti-pattern (two sibling fields, same name, different
1903
+ // element types). Mirrors `cmdMilestoneComplete`'s identical field
1904
+ // (milestone.cts).
1905
+ const preservationWarnings = [];
1906
+ // #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
1907
+ // roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
1908
+ // below (inside withPlanningLock) alongside the warnings[] entry, so a
1909
+ // caller can assert on the typed field instead of the warning's prose.
1910
+ let staleCheckIndeterminate = false;
1396
1911
  const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
1912
+ // #2648: fail-closed plan-coverage gate. phase.complete used to gate ONLY on a
1913
+ // single *-VERIFICATION.md status, so a phase could close "complete" while an
1914
+ // arbitrary number of its plans — including plans a lock/recovery decision
1915
+ // silently dropped — had no completion record (a confirmed production incident
1916
+ // closed a phase with 6/30 plans unexecuted, including its entire final UI
1917
+ // scope, with every tool-reported signal green). Now refuse completion when any
1918
+ // plan lacks a matching *-SUMMARY.md, UNLESS that plan is explicitly retired
1919
+ // via machine-readable `status: superseded` frontmatter (the #2349 marker).
1920
+ //
1921
+ // scanPhasePlans is the superseded-AWARE counter (it drops status: superseded
1922
+ // plans from planFiles before returning), so a deliberately-retired plan never
1923
+ // appears in the unsummarized set and never blocks completion — closing the
1924
+ // Goodhart hole (delete a SUMMARY to raise the %) without regressing the
1925
+ // legitimate lock/recovery pattern (retire a plan instead of executing it).
1926
+ // This is evaluated BEFORE the verification-gate transaction below so a
1927
+ // plan-coverage refusal fails fast without mutating ROADMAP/STATE. The count
1928
+ // path (cmdPhaseComplete's own planCount/summaryCount above) is NOT superseded-
1929
+ // aware (it comes from findPhaseInternal/phase-locator.cts); that is fine for
1930
+ // DISPLAY (the X/Y cell) but must not be the gate — the gate needs the
1931
+ // superseded-adjusted set so retired plans don't re-block the very phases the
1932
+ // marker exists to unblock. Matches roadmap.cts's already-correct-but-unenforced
1933
+ // `summaryCount >= planCount` predicate, now enforced at the completion seam.
1934
+ const coverageScan = scanPhasePlans(phaseFullDir);
1935
+ // #2648 security: fail CLOSED when the phase directory cannot be read.
1936
+ // scanPhasePlans deliberately swallows readdirSync errors and returns an empty
1937
+ // plan set ({planFiles: []}), which is indistinguishable from a readable empty
1938
+ // phase. For a COVERAGE gate that is the wrong posture: "I could not read the
1939
+ // plans" must mean "I cannot prove coverage," not "all plans are summarized" —
1940
+ // otherwise any I/O failure (permissions, ENOTDIR, EBUSY on Windows, a dir
1941
+ // present in ROADMAP.md but missing/unreadable on disk) silently re-opens the
1942
+ // exact hole this gate exists to close. Distinguish the two: a readable
1943
+ // directory with zero plans is a legitimately complete empty phase; an
1944
+ // UNREADABLE directory is a fail-closed refusal. Mirrors cmdPhaseInsert's own
1945
+ // readdirSync-fail-closed posture (a swallow there used to risk writing a
1946
+ // colliding phase number).
1947
+ try {
1948
+ node_fs_1.default.readdirSync(phaseFullDir);
1949
+ }
1950
+ catch (readErr) {
1951
+ error(`Phase ${phaseNum} cannot be completed: its plan directory is unreadable (${phaseInfo['directory']}: ${readErr.code || readErr.message}), so plan coverage cannot be verified. Restore read access and retry — a coverage gate that passes when it cannot read the plans is no gate at all (#2648).`, ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE);
1952
+ }
1953
+ const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
1954
+ if (unsummarizedPlans.length > 0) {
1955
+ // Sanitize plan filenames before interpolation: they come raw from
1956
+ // readdirSync and could carry C0 control chars / DEL (a committable filename
1957
+ // could spoof the terminal in plain-error mode). Strip them so the message is
1958
+ // safe to print regardless of --json-errors. Path traversal sequences are not
1959
+ // a code-execution vector here (printed only, never reopened from the message).
1960
+ const sanitize = (name) => name.replace(/[\u0000-\u001f\u007f]/g, '?');
1961
+ const listed = unsummarizedPlans.slice(0, 20).map(sanitize).join(', ');
1962
+ const more = unsummarizedPlans.length > 20 ? ` (and ${unsummarizedPlans.length - 20} more)` : '';
1963
+ // Audit surface (#2648 review M1): name how many plans were excluded as
1964
+ // superseded so a reviewer can see WHICH work was declared retired, not just
1965
+ // that some plans are missing summaries. The status: superseded marker is a
1966
+ // committable, review-time-trusted bypass; surfacing its count keeps that
1967
+ // bypass visible rather than silent.
1968
+ const phaseInfoPlanCount = Array.isArray(phaseInfo['plans']) ? phaseInfo['plans'].length : 0;
1969
+ const supersededCount = coverageScan.planFiles.length === 0 ? 0 : Math.max(0, phaseInfoPlanCount - coverageScan.planFiles.length);
1970
+ const supersededNote = supersededCount > 0
1971
+ ? ` ${supersededCount} plan(s) excluded as status: superseded (retired).`
1972
+ : '';
1973
+ error(`Phase ${phaseNum} cannot be completed: ${unsummarizedPlans.length} plan(s) have no completion record (*-SUMMARY.md): ${listed}${more}.` +
1974
+ supersededNote +
1975
+ ` Execute the plans and write their summaries, or retire a plan with machine-readable \`status: superseded\` frontmatter (#2349) if it was deliberately dropped — a retired plan is excluded from this gate. ` +
1976
+ `Completing a phase with unexecuted plans is what lost an entire promised deliverable silently (#2648).`, ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE);
1977
+ }
1397
1978
  try {
1398
1979
  const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
1399
- for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) {
1980
+ // #3511: scope this advisory pre-scan to THIS phase's own token so a
1981
+ // stray, cross-phase, or ad-hoc file cannot name a warning against a
1982
+ // phase it does not belong to.
1983
+ const phaseFullDirBaseName = node_path_1.default.basename(phaseFullDir);
1984
+ for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseFullDirBaseName)) {
1400
1985
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
1401
1986
  if (/result: pending/.test(content))
1402
1987
  warnings.push(`${file}: has pending tests`);
@@ -1407,7 +1992,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1407
1992
  if (/status: diagnosed/.test(content))
1408
1993
  warnings.push(`${file}: has diagnosed gaps`);
1409
1994
  }
1410
- for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
1995
+ for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
1411
1996
  const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
1412
1997
  const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
1413
1998
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
@@ -1471,11 +2056,42 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1471
2056
  let nextPhaseNum = null;
1472
2057
  let nextPhaseName = null;
1473
2058
  let isLastPhase = true;
2059
+ // #3311: typed conflict descriptor surfaced on the result JSON alongside the
2060
+ // warnings[] entry below (same parity pattern as
2061
+ // verification_stale_check_indeterminate).
2062
+ let milestoneConflict = null;
1474
2063
  const verificationBlocked = withPlanningLock(cwd, () => {
2064
+ // #3311: completing a phase while a live milestone claim (phase + session)
2065
+ // holds a DIFFERENT phase means two sessions are working two phases against
2066
+ // the single Current Position slot. Warn via the established warnings[]
2067
+ // channel (rendered by execute-phase.md's "If has_warnings is true" step)
2068
+ // rather than blocking — the claim may simply be stale-but-live.
2069
+ milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
2070
+ if (milestoneConflict) {
2071
+ const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
2072
+ const actor = milestoneConflict.session ?? 'an unknown (headless) session';
2073
+ warnings.push(`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
2074
+ `${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
2075
+ `STATE.md's Current Position is a single slot; verify it before trusting it`);
2076
+ milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
2077
+ }
1475
2078
  // #2617: pass the project's runtime so the blocked-completion error below
1476
2079
  // suggests the command surface this runtime actually installs
1477
2080
  // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
1478
2081
  const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
2082
+ // #3057 B3: the staleness check inside readVerificationStatus can itself
2083
+ // fail (fs / scanPhasePlans / clock error), in which case `status` above
2084
+ // was routed as if nothing were stale (unchanged fail-open routing) — but
2085
+ // that must not be silently identical to a check that actually ran and
2086
+ // found nothing stale. Join the SAME advisory channel the UAT/VERIFICATION
2087
+ // pre-scan above already uses (`warnings[]`, rendered by execute-phase.md's
2088
+ // "If has_warnings is true" step) rather than inventing a new one. This
2089
+ // only fires on the non-blocking path (status resolves to 'passed' despite
2090
+ // the indeterminate check) — the blocked path below carries its own note.
2091
+ if (verificationStatus.staleCheckIndeterminate) {
2092
+ staleCheckIndeterminate = true;
2093
+ warnings.push(`verification staleness check could not complete for phase ${phaseNum} — routed as not-stale, but this was not actually verified (#3057)`);
2094
+ }
1479
2095
  if (verificationStatus.status !== 'passed') {
1480
2096
  return verificationStatus;
1481
2097
  }
@@ -1605,7 +2221,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1605
2221
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1606
2222
  if (!planId)
1607
2223
  continue;
1608
- const planEscaped = escapeRegex(planId);
2224
+ const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
1609
2225
  const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
1610
2226
  b = b.replace(planCheckboxPattern, '$1x$2');
1611
2227
  }
@@ -1681,8 +2297,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1681
2297
  .filter(Boolean)
1682
2298
  .filter((r) => REQ_ID_SHAPE_RE.test(r));
1683
2299
  for (const reqId of citedReqIds) {
1684
- const reqEscaped = escapeRegex(reqId);
1685
- reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
2300
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
2301
+ // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
2302
+ // #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
2303
+ // cmdRequirementsMarkComplete). Capture the pre-flip content; if a
2304
+ // traceability row EXISTS for this ID below but its Status write is rejected
2305
+ // (Out/Deferred/Blocked), the checkbox is rolled back so the two surfaces
2306
+ // cannot silently diverge. A requirement recorded as deferred must not read
2307
+ // as shipped.
2308
+ const checkboxRe = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
2309
+ const beforeCheckbox = reqContent;
2310
+ reqContent = reqContent.replace(checkboxRe, '$1x$2');
2311
+ const checkboxFlipped = reqContent !== beforeCheckbox;
1686
2312
  // Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
1687
2313
  // ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
1688
2314
  // row by its FIRST cell's value (the requirement-ID column) regardless
@@ -1698,16 +2324,33 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1698
2324
  // requirement's write. The "only flip Pending/In Progress ->
1699
2325
  // Complete" gate is folded into the newValue callback so one
1700
2326
  // updateTableCell call both probes and writes.
1701
- const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) =>
1702
- // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
1703
- // gaps_found response) can complete without hand-editing the table.
1704
- /^(?:pending|in progress|gaps found)$/i.test(current.trim()) ? ' Complete ' : current);
2327
+ // #2945: track tableHit (did the callback actually CHANGE the value?) so the
2328
+ // checkbox rollback below can distinguish "row existed and accepted" from
2329
+ // "row existed and rejected".
2330
+ let tableHit = false;
2331
+ const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
2332
+ // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
2333
+ // gaps_found response) can complete without hand-editing the table.
2334
+ if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
2335
+ tableHit = true;
2336
+ return ' Complete ';
2337
+ }
2338
+ return current;
2339
+ });
1705
2340
  if (reqUpdate.ok) {
1706
2341
  reqContent = reqUpdate.value;
1707
2342
  }
1708
2343
  else if (!isPlaceholderReqId(reqId)) {
1709
2344
  traceabilityWriteMisses.push(reqId);
1710
2345
  }
2346
+ // #2945 defect-2 (port of milestone.cts:200-210): if a row EXISTS for this
2347
+ // ID but its Status write was rejected (row reads Out/Deferred/Blocked,
2348
+ // which the callback returned unchanged), roll the checkbox back so the
2349
+ // checkbox and the row cannot silently diverge. reqUpdate.ok === a row
2350
+ // matched (existence probe); !tableHit === the callback did not advance it.
2351
+ if (checkboxFlipped && reqUpdate.ok && !tableHit) {
2352
+ reqContent = beforeCheckbox;
2353
+ }
1711
2354
  }
1712
2355
  }
1713
2356
  // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
@@ -1861,7 +2504,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1861
2504
  // row actually matched is registered — not a ghost — regardless of
1862
2505
  // which section (deferred or not) it lives under.
1863
2506
  const reqIsRegisteredAnywhere = (id) => {
1864
- const reqEscaped = escapeRegex(id);
2507
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
1865
2508
  // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
1866
2509
  // insensitive: existence check, not the write's space-only match.
1867
2510
  if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
@@ -1898,17 +2541,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1898
2541
  }
1899
2542
  }
1900
2543
  try {
1901
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1902
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1903
- const dirs = entries
1904
- .filter((e) => e.isDirectory())
1905
- .map((e) => e.name)
1906
- .filter(isDirInMilestone)
1907
- .sort((a, b) => comparePhaseNum(a, b));
2544
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to
2545
+ // the CURRENT milestone" — routed through the canonical owner
2546
+ // instead of a hand-rolled readdirSync + isDirInMilestone filter
2547
+ // (which also never excluded sentinels on its own, unlike the
2548
+ // owner; the per-directory isSentinelPhaseId check below stays as a
2549
+ // defensive second check against the REGEX-EXTRACTED token, which
2550
+ // is not necessarily identical to the raw directory name).
2551
+ const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
1908
2552
  for (const dir of dirs) {
1909
2553
  const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
1910
2554
  if (dm) {
1911
- if (/^999(?:\.|$)/.test(dm[1]))
2555
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2556
+ if (isSentinelPhaseId(dm[1]))
1912
2557
  continue;
1913
2558
  if (comparePhaseNum(dm[1], phaseNum) > 0) {
1914
2559
  nextPhaseNum = dm[1];
@@ -1949,6 +2594,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1949
2594
  const phasePattern = new RegExp(`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
1950
2595
  let pm;
1951
2596
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
2597
+ // #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
2598
+ // already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
2599
+ // stage 2's heading scan must not advance into backlog headings either.
2600
+ if (isSentinelPhaseId(pm[1]))
2601
+ continue;
1952
2602
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
1953
2603
  nextPhaseNum = pm[1];
1954
2604
  nextPhaseName = pm[2]
@@ -1982,7 +2632,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1982
2632
  // pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
1983
2633
  // between the box and "Phase", a required `:`) so unrelated checklist lines
1984
2634
  // that merely mention "Phase N" don't match.
1985
- if (isLastPhase && roadmapContent !== null) {
2635
+ // #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
2636
+ // the next actionable phase?" vs "is this the last phase?"), so it must not
2637
+ // be gated on their answer. Gating on isLastPhase let a merely-positionally
2638
+ // next higher heading (stage 2) permanently mask a genuinely-outstanding
2639
+ // lower phase — stage 2 cleared isLastPhase and this scan never ran. The
2640
+ // scan already refuses anything not strictly lower than the completed phase
2641
+ // (plus sentinels, #2949), so running it unconditionally cannot manufacture
2642
+ // a wrong answer: when no lower phase is outstanding it finds nothing and
2643
+ // stages 1-2's pick stands unchanged; in the masking case isLastPhase is
2644
+ // already false, so the last-phase signal has no reachable regression.
2645
+ if (roadmapContent !== null) {
1986
2646
  try {
1987
2647
  const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
1988
2648
  const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
@@ -1990,7 +2650,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1990
2650
  let lowestOutstanding = null;
1991
2651
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
1992
2652
  const isChecked = cbm[1].toLowerCase() === 'x';
1993
- if (!isChecked && comparePhaseNum(cbm[2], phaseNum) < 0) {
2653
+ // #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
2654
+ // comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
2655
+ // backlog row sorts below every real phase and is wrongly selected as next_phase,
2656
+ // corrupting STATE.md and desyncing current_phase from current_phase_name.
2657
+ // isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
2658
+ // real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
2659
+ // still selected, preserving #2028's out-of-order-completion behavior.
2660
+ if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
1994
2661
  if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
1995
2662
  lowestOutstanding = {
1996
2663
  num: cbm[2],
@@ -2022,11 +2689,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2022
2689
  // to the STATE.md Transition Module. The ~90-line inline RMW callback
2023
2690
  // that lived here is the pure `completePhaseCore` in
2024
2691
  // src/state-transition.cts, backed by the field-classification table.
2025
- // `updatePerformanceMetricsSection` + `syncStateFrontmatter` stay in
2026
- // this adapter: they are section-table / disk-scan concerns, not
2027
- // classified fields, and `syncStateFrontmatter` is the post-sync this
2028
- // transaction needs (it does NOT go through readModifyWriteStateMd
2029
- // because STATE.md is committed atomically with ROADMAP/REQUIREMENTS).
2692
+ // `updatePerformanceMetricsSection` stays in this adapter: it is a
2693
+ // section-table / disk-scan concern, not a classified field. The
2694
+ // sync + post-sync preservation this transaction needs runs via the
2695
+ // single write-seam composition, `syncAndPreserveStateMd` (it does
2696
+ // NOT go through readModifyWriteStateMd because STATE.md is
2697
+ // committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
2698
+ // #3374 / #3469).
2030
2699
  const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
2031
2700
  phaseDisplayNameFromSlug(nextPhaseName);
2032
2701
  const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
@@ -2039,7 +2708,6 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2039
2708
  summaryCount,
2040
2709
  }, {
2041
2710
  clock: clock_cjs_1.realClock,
2042
- progressProvider: () => null, // completePhase derives progress from the roadmap, not disk
2043
2711
  roadmapProvider: () => roadmapContent,
2044
2712
  sourcePath: statePath,
2045
2713
  });
@@ -2049,7 +2717,55 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2049
2717
  // the intent; pass it as authoritative so the sync's prose
2050
2718
  // re-derivation cannot rewrite current_phase_name to the name's own
2051
2719
  // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
2052
- stateContent = syncStateFrontmatter(stateContent, cwd, nextPhaseDisplayName ? { current_phase_name: nextPhaseDisplayName } : undefined);
2720
+ // #3350: PAIR the override. When STATE.md's body carries no Current
2721
+ // Phase / Phase field to re-derive from (narrative prose), the #905
2722
+ // preserve guard in syncStateFrontmatter keeps the OLD frontmatter
2723
+ // current_phase while the authoritative current_phase_name advances —
2724
+ // leaving the two fields describing different phases. Pin BOTH to the
2725
+ // resolved next phase in that case. When the body DOES carry the field
2726
+ // (completePhaseCore just rewrote it), stay name-only so the body's
2727
+ // richer `N of T (name)` derived shape survives the sync.
2728
+ const fmBody = frontmatterMod.stripFrontmatter(stateContent);
2729
+ const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
2730
+ stateExtractField(fmBody, 'Phase') != null;
2731
+ const authoritativeFm = nextPhaseDisplayName
2732
+ ? bodyHasPhaseField || !nextPhaseNum
2733
+ ? { current_phase_name: nextPhaseDisplayName }
2734
+ : {
2735
+ current_phase: String(nextPhaseNum),
2736
+ current_phase_name: nextPhaseDisplayName,
2737
+ }
2738
+ : undefined;
2739
+ // ADR-3408 §8.3 / #3469: this deliberately bypasses
2740
+ // readModifyWriteStateMd (STATE.md is committed atomically with
2741
+ // ROADMAP/REQUIREMENTS), so it calls the single write-seam
2742
+ // composition (`syncAndPreserveStateMd`) directly instead of
2743
+ // assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
2744
+ // itself — a call site re-assembling the pair, even with every step
2745
+ // calling an owner, is the exact re-derivation §8.3 forbids by name
2746
+ // (Phase 2 found this shape live here). The composition runs
2747
+ // snapshots from the on-disk pre-image (originalStateContent) and
2748
+ // the transformed content, table-driven applyStatePreservation, then
2749
+ // the #2736 authoritative re-assert (which restores the #3350
2750
+ // pairing override the preserve-always restore may have reverted).
2751
+ // resync=true is the lifecycle-transition posture (progress
2752
+ // recomputed from disk; only the preserve-when-unchanged deltas
2753
+ // apply). Fields the transition legitimately rewrote (Status, Phase,
2754
+ // Stopped At via completePhaseCore's #3374 continuity line) have
2755
+ // changed body sources, so their deltas do not fire.
2756
+ // ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
2757
+ // command reports what it preserved, following `cmdMilestoneComplete`'s
2758
+ // shape (milestone.cts) — the same composition, the same out-param,
2759
+ // the same visibility contract.
2760
+ const divergedFields = [];
2761
+ stateContent = syncAndPreserveStateMd(originalStateContent, stateContent, statePath, cwd, {
2762
+ resync: true,
2763
+ authoritativeFm,
2764
+ divergedFields,
2765
+ });
2766
+ for (const field of divergedFields) {
2767
+ preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
2768
+ }
2053
2769
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
2054
2770
  }
2055
2771
  writePlanningFileSet(writes);
@@ -2060,13 +2776,29 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2060
2776
  else {
2061
2777
  runPhaseCompleteTransaction();
2062
2778
  }
2779
+ // #3311: a successful completion of the CLAIMED phase releases the
2780
+ // milestone claim — regardless of which session completes it (an
2781
+ // orchestrator cleaning up after a dead session must not be blocked by the
2782
+ // dead session's own claim). No-ops when the claim names another phase.
2783
+ milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
2063
2784
  return null;
2064
2785
  });
2065
2786
  if (verificationBlocked) {
2066
2787
  const nextStep = verificationBlocked.next_command
2067
2788
  ? ` Next: ${verificationBlocked.next_command}`
2068
2789
  : '';
2069
- error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE);
2790
+ // #3057 B3: purely additive to the message text — does not change WHETHER
2791
+ // this blocks (verificationBlocked was already truthy) or the
2792
+ // ERROR_REASON, only whether the operator can see the staleness check
2793
+ // itself did not complete. The same fact is also attached as a typed
2794
+ // field (`verification_stale_check_indeterminate`) on the JSON-error-mode
2795
+ // payload so a test can assert on it by value instead of regexing this
2796
+ // human-readable note.
2797
+ const staleCheckIndeterminate = verificationBlocked.staleCheckIndeterminate === true;
2798
+ const indeterminateNote = staleCheckIndeterminate
2799
+ ? ' (staleness check could not complete — see #3057)'
2800
+ : '';
2801
+ error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE, { verification_stale_check_indeterminate: staleCheckIndeterminate });
2070
2802
  }
2071
2803
  let autoPruned = false;
2072
2804
  try {
@@ -2100,6 +2832,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2100
2832
  auto_pruned: autoPruned,
2101
2833
  warnings,
2102
2834
  has_warnings: warnings.length > 0,
2835
+ verification_stale_check_indeterminate: staleCheckIndeterminate,
2836
+ milestone_conflict: milestoneConflict,
2837
+ preservation_warnings: preservationWarnings,
2103
2838
  };
2104
2839
  output(result, raw);
2105
2840
  }
@@ -2121,7 +2856,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
2121
2856
  // paths without re-discovering the phase directory themselves.
2122
2857
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
2123
2858
  const planScanMod = require("./plan-scan.cjs");
2124
- const { scanPhasePlans } = planScanMod;
2859
+ const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
2125
2860
  function cmdPhaseListPlans(cwd, phaseNum, raw) {
2126
2861
  if (!phaseNum) {
2127
2862
  error('phase number required for phase list-plans');