@opengsd/gsd-core 1.10.0 → 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 (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -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, findUnsummarizedPlans } = 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, isSentinelPhaseId, 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
@@ -64,12 +70,12 @@ const verifyMod = require("./verify.cjs");
64
70
  const { readVerificationStatus } = verificationMod;
65
71
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
66
72
  const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
67
- const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
68
- const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } = planningWorkspace;
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");
69
77
  const { extractFrontmatter } = frontmatterMod;
70
- const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
71
- // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
72
- const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
78
+ const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
73
79
  // Any .md file with PLAN anywhere in the basename — diagnostic net
74
80
  const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
75
81
  const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
@@ -138,27 +144,6 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
138
144
  `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
139
145
  `See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
140
146
  }
141
- function extractCanonicalPlanId(filename) {
142
- const base = filename
143
- .replace(/-PLAN\.md$/i, '')
144
- .replace(/-SUMMARY\.md$/i, '')
145
- .replace(/\.md$/i, '');
146
- const parts = base.split('-').filter(Boolean);
147
- // #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
148
- // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
149
- // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
150
- const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
151
- // #2232: the PAIRED plan component is a zero-padded continuation segment
152
- // (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
153
- // bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
154
- // \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
155
- const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
156
- const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
157
- if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
158
- return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
159
- }
160
- return base;
161
- }
162
147
  function cmdPhasesList(cwd, options, raw) {
163
148
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
164
149
  const { type, phase, includeArchived } = options;
@@ -172,24 +157,56 @@ function cmdPhasesList(cwd, options, raw) {
172
157
  return;
173
158
  }
174
159
  try {
175
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
176
- let dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
177
- if (includeArchived) {
178
- const archived = getArchivedPhaseDirs(cwd);
179
- for (const a of archived) {
180
- dirs.push(`${a.name} [${a.milestone}]`);
181
- }
182
- }
183
- 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;
184
187
  if (phase) {
188
+ // LOOKUP (b): search the physical set, plus archived when asked.
189
+ const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
185
190
  const normalized = normalizePhaseName(phase);
186
- 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];
187
196
  if (!match) {
188
197
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
189
198
  return;
190
199
  }
191
200
  dirs = [match];
192
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
+ }
193
210
  if (type) {
194
211
  const files = [];
195
212
  const warnings = [];
@@ -198,13 +215,31 @@ function cmdPhasesList(cwd, options, raw) {
198
215
  const dirFiles = node_fs_1.default.readdirSync(dirPath);
199
216
  let filtered;
200
217
  if (type === 'plans') {
201
- 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);
202
237
  const w = describeNonCanonicalPlans(dirFiles, filtered);
203
238
  if (w)
204
239
  warnings.push(`${dir}: ${w}`);
205
240
  }
206
241
  else if (type === 'summaries') {
207
- filtered = dirFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
242
+ filtered = scanPhasePlans(dirPath).summaryFiles;
208
243
  }
209
244
  else {
210
245
  filtered = dirFiles;
@@ -215,13 +250,18 @@ function cmdPhasesList(cwd, options, raw) {
215
250
  files,
216
251
  count: files.length,
217
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,
218
256
  };
219
257
  if (warnings.length)
220
258
  result['warning'] = warnings.join(' | ');
221
259
  output(result, raw, files.join('\n'));
222
260
  return;
223
261
  }
224
- 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'));
225
265
  }
226
266
  catch (e) {
227
267
  const msg = e instanceof Error ? e.message : String(e);
@@ -237,8 +277,8 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
237
277
  if (node_fs_1.default.existsSync(phasesDir)) {
238
278
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
239
279
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
240
- baseExists = dirs.some((d) => phaseTokenMatches(d, normalized));
241
- 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+)`);
242
282
  for (const dir of dirs) {
243
283
  const match = dir.match(dirPattern);
244
284
  if (match)
@@ -351,6 +391,13 @@ function cmdFindPhase(cwd, phase, raw) {
351
391
  phase_name: null,
352
392
  plans: [],
353
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,
354
401
  searched_directories: [],
355
402
  };
356
403
  const searchDirs = [];
@@ -381,7 +428,10 @@ function cmdFindPhase(cwd, phase, raw) {
381
428
  // #2237: fail loud when multiple directories match the same bare phase
382
429
  // number — prevents cross-project file writes when unrelated projects
383
430
  // share a .planning/phases/ tree.
384
- 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);
385
435
  if (matches.length === 0)
386
436
  continue;
387
437
  if (matches.length > 1) {
@@ -398,9 +448,29 @@ function cmdFindPhase(cwd, phase, raw) {
398
448
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
399
449
  const phaseDir = node_path_1.default.join(searchDir, match);
400
450
  const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
401
- const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
402
- const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
403
- 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);
404
474
  const result = {
405
475
  found: true,
406
476
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir), match)),
@@ -408,6 +478,20 @@ function cmdFindPhase(cwd, phase, raw) {
408
478
  phase_name: phaseName,
409
479
  plans,
410
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,
411
495
  };
412
496
  if (planNamingWarning)
413
497
  result['warning'] = planNamingWarning;
@@ -499,40 +583,96 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
499
583
  const normalized = normalizePhaseName(phase);
500
584
  let phaseDir = null;
501
585
  let phaseDirName = null;
586
+ let ambiguousMatches = null;
502
587
  try {
503
588
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
504
589
  const dirs = entries
505
590
  .filter((e) => e.isDirectory())
506
591
  .map((e) => e.name)
507
592
  .sort((a, b) => comparePhaseNum(a, b));
508
- const match = dirs.find((d) => phaseTokenMatches(d, normalized));
509
- if (match) {
510
- phaseDir = node_path_1.default.join(phasesDir, match);
511
- 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];
512
604
  }
513
605
  }
514
606
  catch {
515
607
  // phases dir doesn't exist
516
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
+ }
517
618
  if (!phaseDir) {
518
619
  output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
519
620
  return;
520
621
  }
521
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.
522
626
  const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
523
- const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
524
- const summaryFiles = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
525
- const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
526
- const completedPlanIds = new Set(summaryFiles.flatMap((s) => {
527
- const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
528
- const canonical = extractCanonicalPlanId(s);
529
- return canonical === exact ? [exact] : [exact, canonical];
530
- }));
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));
531
669
  // #2830: reverse lookup from a completed plan's id (exact or canonical) to
532
670
  // the actual summary filename, so a plan's own SUMMARY frontmatter can be
533
671
  // read for its `status`. Shared builder (also used by phase-locator.cts's
534
672
  // searchPhaseInDir) so the two can never disagree about which summary
535
- // belongs to which plan.
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.
536
676
  const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
537
677
  // ── Pass 1: parse each plan file ─────────────────────────────────────────
538
678
  const rawPlans = [];
@@ -566,7 +706,18 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
566
706
  // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
567
707
  filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
568
708
  }
569
- 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);
570
721
  // #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
571
722
  // a designed stop still writes a completion record, just one whose status
572
723
  // says "halted" rather than "complete". Only look up the summary file
@@ -582,6 +733,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
582
733
  autonomous,
583
734
  objective: extractObjective(content) || fm['objective'] || null,
584
735
  filesModified,
736
+ agentHint,
585
737
  taskCount,
586
738
  hasSummary,
587
739
  halted,
@@ -664,6 +816,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
664
816
  autonomous: rawPlan.autonomous,
665
817
  objective: rawPlan.objective,
666
818
  files_modified: rawPlan.filesModified,
819
+ agent_hint: rawPlan.agentHint,
667
820
  task_count: rawPlan.taskCount,
668
821
  has_summary: rawPlan.hasSummary,
669
822
  // #2830: additive fields — halted is this plan's OWN status; blocked_by
@@ -716,10 +869,54 @@ function describeGoalShapedTitle(description) {
716
869
  return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
717
870
  `as the phase title; consider a short title with the detail moved to **Goal:**.`);
718
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
+ }
719
915
  function cmdPhaseAdd(cwd, description, raw, customId) {
720
916
  if (!description) {
721
917
  error('description required for phase add');
722
918
  }
919
+ assertDescriptionPreservesMilestoneScope(description, 'phase add');
723
920
  const config = loadConfig(cwd);
724
921
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
725
922
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -755,12 +952,14 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
755
952
  let m;
756
953
  while ((m = headerPattern.exec(content)) !== null) {
757
954
  const num = parseInt(m[1], 10);
758
- 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))
759
957
  usedPhaseNums.add(num);
760
958
  }
761
959
  while ((m = bulletPattern.exec(content)) !== null) {
762
960
  const num = parseInt(m[1], 10);
763
- 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))
764
963
  usedPhaseNums.add(num);
765
964
  }
766
965
  // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
@@ -772,7 +971,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
772
971
  if (!match)
773
972
  continue;
774
973
  const num = parseInt(match[1], 10);
775
- 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))
776
976
  usedPhaseNums.add(num);
777
977
  }
778
978
  }
@@ -792,14 +992,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
792
992
  ? ''
793
993
  : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
794
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`;
795
- let updatedContent;
796
- const lastSeparator = rawContent.lastIndexOf('\n---');
797
- if (lastSeparator > 0) {
798
- updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
799
- }
800
- else {
801
- updatedContent = rawContent + phaseEntry;
802
- }
995
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd);
996
+ const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
803
997
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
804
998
  return { newPhaseId: _newPhaseId, dirName: _dirName };
805
999
  });
@@ -820,6 +1014,12 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
820
1014
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
821
1015
  error('descriptions array required for phase add-batch');
822
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
+ }
823
1023
  const config = loadConfig(cwd);
824
1024
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
825
1025
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -837,7 +1037,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
837
1037
  let m;
838
1038
  while ((m = phasePattern.exec(content)) !== null) {
839
1039
  const num = parseInt(m[1], 10);
840
- 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))
841
1042
  continue;
842
1043
  if (num > maxPhase)
843
1044
  maxPhase = num;
@@ -850,7 +1051,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
850
1051
  if (!match)
851
1052
  continue;
852
1053
  const num = parseInt(match[1], 10);
853
- 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))
854
1056
  continue;
855
1057
  if (num > maxPhase)
856
1058
  maxPhase = num;
@@ -878,11 +1080,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
878
1080
  ? ''
879
1081
  : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
880
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`;
881
- const lastSeparator = rawContent.lastIndexOf('\n---');
882
- rawContent =
883
- lastSeparator > 0
884
- ? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
885
- : rawContent + phaseEntry;
1083
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1084
+ rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
886
1085
  added.push({
887
1086
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
888
1087
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
@@ -901,6 +1100,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
901
1100
  if (!afterPhase || !description) {
902
1101
  error('after-phase and description required for phase insert');
903
1102
  }
1103
+ assertDescriptionPreservesMilestoneScope(description, 'phase insert');
904
1104
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
905
1105
  if (!node_fs_1.default.existsSync(roadmapPath)) {
906
1106
  error('ROADMAP.md not found');
@@ -949,7 +1149,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
949
1149
  error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
950
1150
  }
951
1151
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
952
- 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+)`);
953
1153
  for (const dir of dirs) {
954
1154
  const dm = dir.match(decimalPattern);
955
1155
  if (dm)
@@ -977,15 +1177,30 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
977
1177
  const phaseLabel = useBold
978
1178
  ? `**Phase ${_decimalPhase}: ${description}**`
979
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.
980
1187
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
981
- 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');
982
1197
  const bulletMatchResult = rawContent.match(targetBulletPattern);
983
1198
  if (!bulletMatchResult) {
984
1199
  error(`Could not find Phase ${afterPhase} bullet line`);
985
1200
  }
986
1201
  const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
987
1202
  const afterBullet = rawContent.slice(bulletLineEnd);
988
- 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);
989
1204
  let insertIdx;
990
1205
  if (nextBulletMatch) {
991
1206
  insertIdx = bulletLineEnd + nextBulletMatch.index;
@@ -1005,7 +1220,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1005
1220
  }
1006
1221
  const headerIdx = rawContent.indexOf(headerMatch[0]);
1007
1222
  const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
1008
- 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);
1009
1224
  let insertIdx;
1010
1225
  if (nextPhaseMatch) {
1011
1226
  insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
@@ -1059,9 +1274,33 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1059
1274
  }
1060
1275
  return { renamedDirs, renamedFiles };
1061
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
+ }
1062
1300
  function renameIntegerPhases(phasesDir, removedInt) {
1063
1301
  const renamedDirs = [];
1064
1302
  const renamedFiles = [];
1303
+ const renamedFileCollisions = [];
1065
1304
  const dirs = readSubdirectories(phasesDir, true);
1066
1305
  const toRename = dirs
1067
1306
  .map((dir) => {
@@ -1069,7 +1308,8 @@ function renameIntegerPhases(phasesDir, removedInt) {
1069
1308
  if (!m)
1070
1309
  return null;
1071
1310
  const dirInt = parseInt(m[1], 10);
1072
- 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)
1073
1313
  ? {
1074
1314
  dir,
1075
1315
  oldInt: dirInt,
@@ -1090,21 +1330,77 @@ function renameIntegerPhases(phasesDir, removedInt) {
1090
1330
  const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
1091
1331
  const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
1092
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}`;
1093
1344
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
1094
1345
  renamedDirs.push({ from: item.dir, to: newDirName });
1095
1346
  for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
1347
+ let matchedPrefix = null;
1096
1348
  if (f.startsWith(oldPrefix)) {
1097
- const newFileName = newPrefix + f.slice(oldPrefix.length);
1098
- (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);
1099
1394
  renamedFiles.push({ from: f, to: newFileName });
1100
1395
  }
1101
1396
  }
1102
1397
  }
1103
- return { renamedDirs, renamedFiles };
1398
+ return { renamedDirs, renamedFiles, renamedFileCollisions };
1104
1399
  }
1105
1400
  function decrementRoadmapPhaseNumber(raw, removedInt) {
1106
1401
  const num = parseInt(raw, 10);
1107
- 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))
1108
1404
  return raw;
1109
1405
  return String(num - 1);
1110
1406
  }
@@ -1113,13 +1409,15 @@ function decrementRoadmapPhaseToken(raw, removedInt) {
1113
1409
  if (!match)
1114
1410
  return raw;
1115
1411
  const num = parseInt(match[1], 10);
1116
- 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))
1117
1414
  return raw;
1118
1415
  return `${num - 1}${match[2] || ''}`;
1119
1416
  }
1120
1417
  function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
1121
1418
  const num = parseInt(raw, 10);
1122
- 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))
1123
1421
  return raw;
1124
1422
  return String(num - 1).padStart(raw.length, '0');
1125
1423
  }
@@ -1160,7 +1458,15 @@ function findDataRowLine(sectionText, dataRowIndex) {
1160
1458
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
1161
1459
  withPlanningLock(cwd, () => {
1162
1460
  let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1163
- 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}`;
1164
1470
  // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
1165
1471
  // detail section INCLUDING its own heading line. Migrated onto deleteSection
1166
1472
  // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
@@ -1172,9 +1478,9 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1172
1478
  // such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
1173
1479
  // away everything after it — including a trailing `## Progress` heading and
1174
1480
  // its tracking table.
1175
- 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');
1176
1482
  content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
1177
- 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'), '');
1178
1484
  // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
1179
1485
  // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
1180
1486
  // of updateTableCell). Scoped to the `## Progress` section — mirroring
@@ -1201,7 +1507,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1201
1507
  const matchRemovedProgressRow = (row) => {
1202
1508
  const firstCellRaw = (Object.values(row)[0] ?? '').trim();
1203
1509
  if (isDecimal) {
1204
- return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1510
+ return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1205
1511
  }
1206
1512
  const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
1207
1513
  if (!leadingMatch || leadingMatch[2])
@@ -1216,7 +1522,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1216
1522
  if (!isDecimal) {
1217
1523
  // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
1218
1524
  // is re-emitted verbatim — a tagged later phase still gets renumbered.
1219
- 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}`);
1220
1526
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
1221
1527
  // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
1222
1528
  // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
@@ -1278,7 +1584,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1278
1584
  if (!m)
1279
1585
  return false;
1280
1586
  const num = parseInt(m[1], 10);
1281
- 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))
1282
1589
  return false;
1283
1590
  processedOrdinalRows.add(index);
1284
1591
  matchedRowIndex = index;
@@ -1291,7 +1598,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1291
1598
  const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
1292
1599
  const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
1293
1600
  const padMatch = targetLine
1294
- ? 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)
1295
1602
  : null;
1296
1603
  const leadPad = padMatch ? padMatch[1] : ' ';
1297
1604
  const trailPad = padMatch ? padMatch[2] : ' ';
@@ -1310,6 +1617,33 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1310
1617
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
1311
1618
  });
1312
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
+ }
1313
1647
  function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1314
1648
  if (!targetPhase)
1315
1649
  error('phase number required for phase remove');
@@ -1321,24 +1655,55 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1321
1655
  const isDecimal = targetPhase.includes('.');
1322
1656
  const force = options.force || false;
1323
1657
  const subdirs = readSubdirectories(phasesDir, true);
1324
- 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;
1325
1680
  if (targetDir && !force) {
1326
- const files = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, targetDir));
1327
- const summaries = files.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1328
- if (summaries.length > 0) {
1329
- 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.`);
1330
1688
  }
1331
1689
  }
1332
1690
  if (targetDir)
1333
1691
  node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, targetDir), { recursive: true, force: true });
1334
1692
  let renamedDirs = [];
1335
1693
  let renamedFiles = [];
1694
+ let renamedFileCollisions = [];
1336
1695
  try {
1337
- const renamed = isDecimal
1338
- ? renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10))
1339
- : renameIntegerPhases(phasesDir, parseInt(normalized, 10));
1340
- renamedDirs = renamed.renamedDirs;
1341
- 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
+ }
1342
1707
  }
1343
1708
  catch (e) {
1344
1709
  // #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
@@ -1367,13 +1732,15 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1367
1732
  let modified = stateContent;
1368
1733
  const totalRaw = stateExtractField(modified, 'Total Phases');
1369
1734
  if (totalRaw) {
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.
1370
1738
  modified =
1371
- stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
1372
- modified;
1739
+ stateReplaceField(modified, 'Total Phases', String(Math.max(0, parseInt(totalRaw, 10) - 1))) || modified;
1373
1740
  }
1374
1741
  const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1375
1742
  if (ofMatch) {
1376
- modified = modified.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`);
1377
1744
  }
1378
1745
  // #2640: if neither body field was found, the transform is a no-op.
1379
1746
  // readModifyWriteStateMd's no-op guard (#948) would then skip the
@@ -1386,16 +1753,33 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1386
1753
  if (targetDir && modified === stateContent) {
1387
1754
  // subdirs was read before the deletion; excluding the removed target
1388
1755
  // gives the remaining count. Renumbering changes names but not count.
1389
- const remainingPhases = subdirs.filter((d) => phaseTokenMatches(d, normalized) === false).length;
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);
1390
1771
  if (totalRaw) {
1391
1772
  modified =
1392
1773
  stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
1393
1774
  }
1394
1775
  else {
1395
- // No 'Total Phases:' field in the body — append one so the no-op
1396
- // guard sees a diff. syncStateFrontmatter will then rebuild the
1397
- // frontmatter progress.* block from the real disk/ROADMAP count.
1398
- modified = `Total Phases: ${remainingPhases}\n` + modified;
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}`);
1399
1783
  }
1400
1784
  }
1401
1785
  return modified;
@@ -1406,6 +1790,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1406
1790
  directory_deleted: targetDir,
1407
1791
  renamed_directories: renamedDirs,
1408
1792
  renamed_files: renamedFiles,
1793
+ renamed_file_collisions: renamedFileCollisions,
1409
1794
  roadmap_updated: true,
1410
1795
  state_updated: stateUpdated,
1411
1796
  }, raw);
@@ -1467,11 +1852,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1467
1852
  // init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
1468
1853
  // explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
1469
1854
  const availableWorkstreams = listAvailableWorkstreams(cwd);
1470
- 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);
1471
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
+ }
1472
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. ` +
1473
1874
  `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} first. ` +
1474
- `Available workstreams: ${availableWorkstreams.join(', ')}`);
1875
+ `Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
1475
1876
  }
1476
1877
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1477
1878
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
@@ -1490,6 +1891,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1490
1891
  : 0;
1491
1892
  let requirementsUpdated = false;
1492
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 = [];
1493
1906
  // #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
1494
1907
  // roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
1495
1908
  // below (inside withPlanningLock) alongside the warnings[] entry, so a
@@ -1564,7 +1977,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1564
1977
  }
1565
1978
  try {
1566
1979
  const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
1567
- 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)) {
1568
1985
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
1569
1986
  if (/result: pending/.test(content))
1570
1987
  warnings.push(`${file}: has pending tests`);
@@ -1575,7 +1992,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1575
1992
  if (/status: diagnosed/.test(content))
1576
1993
  warnings.push(`${file}: has diagnosed gaps`);
1577
1994
  }
1578
- 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)) {
1579
1996
  const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
1580
1997
  const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
1581
1998
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
@@ -1639,7 +2056,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1639
2056
  let nextPhaseNum = null;
1640
2057
  let nextPhaseName = null;
1641
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;
1642
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
+ }
1643
2078
  // #2617: pass the project's runtime so the blocked-completion error below
1644
2079
  // suggests the command surface this runtime actually installs
1645
2080
  // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
@@ -1786,7 +2221,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1786
2221
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1787
2222
  if (!planId)
1788
2223
  continue;
1789
- const planEscaped = escapeRegex(planId);
2224
+ const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
1790
2225
  const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
1791
2226
  b = b.replace(planCheckboxPattern, '$1x$2');
1792
2227
  }
@@ -1862,7 +2297,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1862
2297
  .filter(Boolean)
1863
2298
  .filter((r) => REQ_ID_SHAPE_RE.test(r));
1864
2299
  for (const reqId of citedReqIds) {
1865
- const reqEscaped = escapeRegex(reqId);
2300
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
1866
2301
  // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
1867
2302
  // #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
1868
2303
  // cmdRequirementsMarkComplete). Capture the pre-flip content; if a
@@ -2069,7 +2504,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2069
2504
  // row actually matched is registered — not a ghost — regardless of
2070
2505
  // which section (deferred or not) it lives under.
2071
2506
  const reqIsRegisteredAnywhere = (id) => {
2072
- const reqEscaped = escapeRegex(id);
2507
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
2073
2508
  // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
2074
2509
  // insensitive: existence check, not the write's space-only match.
2075
2510
  if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
@@ -2106,17 +2541,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2106
2541
  }
2107
2542
  }
2108
2543
  try {
2109
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
2110
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
2111
- const dirs = entries
2112
- .filter((e) => e.isDirectory())
2113
- .map((e) => e.name)
2114
- .filter(isDirInMilestone)
2115
- .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;
2116
2552
  for (const dir of dirs) {
2117
2553
  const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
2118
2554
  if (dm) {
2119
- 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]))
2120
2557
  continue;
2121
2558
  if (comparePhaseNum(dm[1], phaseNum) > 0) {
2122
2559
  nextPhaseNum = dm[1];
@@ -2158,9 +2595,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2158
2595
  let pm;
2159
2596
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
2160
2597
  // #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
2161
- // already skips 999 dirs on disk; stage 2's heading scan must not
2162
- // advance into backlog headings. Mirrors the /^999(?:\.|$)/ guard
2163
- // stage 1 uses at line 2536, but via isSentinelPhaseId for both ranges.
2598
+ // already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
2599
+ // stage 2's heading scan must not advance into backlog headings either.
2164
2600
  if (isSentinelPhaseId(pm[1]))
2165
2601
  continue;
2166
2602
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
@@ -2196,7 +2632,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2196
2632
  // pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
2197
2633
  // between the box and "Phase", a required `:`) so unrelated checklist lines
2198
2634
  // that merely mention "Phase N" don't match.
2199
- 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) {
2200
2646
  try {
2201
2647
  const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
2202
2648
  const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
@@ -2243,11 +2689,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2243
2689
  // to the STATE.md Transition Module. The ~90-line inline RMW callback
2244
2690
  // that lived here is the pure `completePhaseCore` in
2245
2691
  // src/state-transition.cts, backed by the field-classification table.
2246
- // `updatePerformanceMetricsSection` + `syncStateFrontmatter` stay in
2247
- // this adapter: they are section-table / disk-scan concerns, not
2248
- // classified fields, and `syncStateFrontmatter` is the post-sync this
2249
- // transaction needs (it does NOT go through readModifyWriteStateMd
2250
- // 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).
2251
2699
  const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
2252
2700
  phaseDisplayNameFromSlug(nextPhaseName);
2253
2701
  const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
@@ -2269,7 +2717,55 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2269
2717
  // the intent; pass it as authoritative so the sync's prose
2270
2718
  // re-derivation cannot rewrite current_phase_name to the name's own
2271
2719
  // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
2272
- 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
+ }
2273
2769
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
2274
2770
  }
2275
2771
  writePlanningFileSet(writes);
@@ -2280,6 +2776,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2280
2776
  else {
2281
2777
  runPhaseCompleteTransaction();
2282
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);
2283
2784
  return null;
2284
2785
  });
2285
2786
  if (verificationBlocked) {
@@ -2332,6 +2833,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2332
2833
  warnings,
2333
2834
  has_warnings: warnings.length > 0,
2334
2835
  verification_stale_check_indeterminate: staleCheckIndeterminate,
2836
+ milestone_conflict: milestoneConflict,
2837
+ preservation_warnings: preservationWarnings,
2335
2838
  };
2336
2839
  output(result, raw);
2337
2840
  }
@@ -2353,7 +2856,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
2353
2856
  // paths without re-discovering the phase directory themselves.
2354
2857
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
2355
2858
  const planScanMod = require("./plan-scan.cjs");
2356
- const { scanPhasePlans } = planScanMod;
2859
+ const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
2357
2860
  function cmdPhaseListPlans(cwd, phaseNum, raw) {
2358
2861
  if (!phaseNum) {
2359
2862
  error('phase number required for phase list-plans');