@opengsd/gsd-core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -21,24 +21,34 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
21
21
  };
22
22
  const node_fs_1 = __importDefault(require("node:fs"));
23
23
  const node_path_1 = __importDefault(require("node:path"));
24
+ const node_child_process_1 = require("node:child_process");
24
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
25
26
  const ioMod = require("./io.cjs");
26
- const { output, error, ERROR_REASON } = ioMod;
27
+ const { output, error, ERROR_REASON, formatDiagnosticToken } = ioMod;
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const stateContract = require("./state-contract.cjs");
30
+ const { publishStateContract } = stateContract;
27
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
28
32
  const configLoaderMod = require("./config-loader.cjs");
29
33
  const { loadConfig } = configLoaderMod;
30
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
31
35
  const coreUtilsMod = require("./core-utils.cjs");
32
- const { toPosixPath, generateSlugInternal, readSubdirectories, findUnsummarizedPlans } = coreUtilsMod;
36
+ // #2528: `extractCanonicalPlanId` used to exist here as a byte-identical second
37
+ // copy, and this PR had to patch BOTH with the same rewind rule — the exact
38
+ // generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
39
+ // copy, which was already the leaf owner, so there is no second surface left to
40
+ // drift and no parity test needed to police one.
41
+ const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, normalizeLineEndings, } = coreUtilsMod;
33
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
43
  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;
44
+ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
45
+ const pattern_cjs_1 = require("./pattern.cjs");
36
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
47
  const phaseLocatorMod = require("./phase-locator.cjs");
38
- const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
48
+ const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
39
49
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
40
50
  const roadmapParserMod = require("./roadmap-parser.cjs");
41
- const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection } = roadmapParserMod;
51
+ const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
42
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
43
53
  const planningWorkspace = require("./planning-workspace.cjs");
44
54
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -64,12 +74,15 @@ const verifyMod = require("./verify.cjs");
64
74
  const { readVerificationStatus } = verificationMod;
65
75
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
66
76
  const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
67
- const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
68
- const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } = planningWorkspace;
77
+ const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
78
+ const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
79
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
80
+ const milestoneLockMod = require("./milestone-lock.cjs");
81
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
82
+ const planDocumentMod = require("./plan-document.cjs");
83
+ const { parsePlanDocument, planIdFromFile } = planDocumentMod;
69
84
  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';
85
+ const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
73
86
  // Any .md file with PLAN anywhere in the basename — diagnostic net
74
87
  const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
75
88
  const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
@@ -138,27 +151,6 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
138
151
  `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
139
152
  `See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
140
153
  }
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
154
  function cmdPhasesList(cwd, options, raw) {
163
155
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
164
156
  const { type, phase, includeArchived } = options;
@@ -172,24 +164,56 @@ function cmdPhasesList(cwd, options, raw) {
172
164
  return;
173
165
  }
174
166
  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));
167
+ // #3185 (ADR-3180 Decision 1): only the ENUMERATION routes through the
168
+ // single owner. The two other modes below ask genuinely DIFFERENT
169
+ // questions and are exempt by documented reason, never by a file
170
+ // allowlist (ADR-3180 Decision 4a):
171
+ //
172
+ // --phase <n> locating ONE phase by token is phase LOCATION, a
173
+ // question src/phase-locator.cts already owns via
174
+ // findPhaseInternal/searchPhaseInDir. Scoping it to
175
+ // the current milestone would make an out-of-window
176
+ // phase report "Phase not found".
177
+ // --include-archived archived directories are BY DEFINITION from other
178
+ // milestones; filtering them through the CURRENT
179
+ // milestone window would return nothing at all.
180
+ //
181
+ // Generalizing #3183's rule ("a diagnostic about file NAMING wants the
182
+ // physical set; only a question about outstanding WORK wants the live
183
+ // set"): a LOOKUP wants the physical set; only "which phases belong to
184
+ // this milestone" wants the scoped set.
185
+ const archivedLabels = includeArchived
186
+ ? getArchivedPhaseDirs(cwd).map((a) => `${a.name} [${a.milestone}]`)
187
+ : [];
188
+ let dirs;
189
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
190
+ // can tell a genuinely-empty milestone from one it could not scope. Only
191
+ // the ENUMERATION path scopes anything; the LOOKUP path below has no
192
+ // enumeration to report a scope for.
193
+ let phaseScope = null;
184
194
  if (phase) {
195
+ // LOOKUP (b): search the physical set, plus archived when asked.
196
+ const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
185
197
  const normalized = normalizePhaseName(phase);
186
- const match = dirs.find((d) => phaseTokenMatches(d, normalized));
198
+ // The pool is #3185's (physical set + archived); the matcher is this
199
+ // PR's. `dirs` is deliberately not read here: on this base it is not
200
+ // assigned until the branch below picks a match.
201
+ const { matches } = matchPhaseDirs(lookupPool, normalized);
202
+ const match = matches[0];
187
203
  if (!match) {
188
204
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
189
205
  return;
190
206
  }
191
207
  dirs = [match];
192
208
  }
209
+ else {
210
+ // ENUMERATION (a): milestone-scoped and sentinel-filtered, plus
211
+ // archived when asked (c).
212
+ const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
213
+ phaseScope = enumerated.scope;
214
+ dirs = [...enumerated.value, ...archivedLabels];
215
+ dirs.sort((a, b) => comparePhaseNum(a, b));
216
+ }
193
217
  if (type) {
194
218
  const files = [];
195
219
  const warnings = [];
@@ -198,13 +222,31 @@ function cmdPhasesList(cwd, options, raw) {
198
222
  const dirFiles = node_fs_1.default.readdirSync(dirPath);
199
223
  let filtered;
200
224
  if (type === 'plans') {
201
- filtered = dirFiles.filter(isCanonicalPlanFile);
225
+ // #3183: this is a "what plan files physically exist" query (this
226
+ // IS the file-listing command), not a live-completion question, so
227
+ // it uses the single owner's allPlanFiles (root+nested, INCLUDING
228
+ // status: superseded) rather than a root-only readdirSync filter
229
+ // that also missed nested plans.
230
+ //
231
+ // #2893 (regression fix): `allPlanFiles` also carries
232
+ // `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
233
+ // permissive for live-plan COUNTING elsewhere — see
234
+ // plan-count-single-owner.test.cjs). That fallback silently
235
+ // recognized a non-canonically-named file (e.g.
236
+ // `01-PLAN-01-foundation.md`) as "matched", which defeated this
237
+ // command's #2893 naming-convention diagnostic entirely (no
238
+ // warning, file listed as if valid). Intersect with the STRICT
239
+ // `isCanonicalPlanFile` predicate so this diagnostic — and the
240
+ // `files` list this command actually returns — only ever
241
+ // recognizes the canonical root/nested forms, exactly like the
242
+ // pre-#3183 behavior this feature was built and tested against.
243
+ filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
202
244
  const w = describeNonCanonicalPlans(dirFiles, filtered);
203
245
  if (w)
204
246
  warnings.push(`${dir}: ${w}`);
205
247
  }
206
248
  else if (type === 'summaries') {
207
- filtered = dirFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
249
+ filtered = scanPhasePlans(dirPath).summaryFiles;
208
250
  }
209
251
  else {
210
252
  filtered = dirFiles;
@@ -215,13 +257,18 @@ function cmdPhasesList(cwd, options, raw) {
215
257
  files,
216
258
  count: files.length,
217
259
  phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
260
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
261
+ // can tell a genuinely-empty milestone from one it could not scope.
262
+ phase_scope: phaseScope,
218
263
  };
219
264
  if (warnings.length)
220
265
  result['warning'] = warnings.join(' | ');
221
266
  output(result, raw, files.join('\n'));
222
267
  return;
223
268
  }
224
- output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
269
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
270
+ // can tell a genuinely-empty milestone from one it could not scope.
271
+ output({ directories: dirs, count: dirs.length, phase_scope: phaseScope }, raw, dirs.join('\n'));
225
272
  }
226
273
  catch (e) {
227
274
  const msg = e instanceof Error ? e.message : String(e);
@@ -237,8 +284,8 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
237
284
  if (node_fs_1.default.existsSync(phasesDir)) {
238
285
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
239
286
  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+)`);
287
+ baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
288
+ const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
242
289
  for (const dir of dirs) {
243
290
  const match = dir.match(dirPattern);
244
291
  if (match)
@@ -351,6 +398,13 @@ function cmdFindPhase(cwd, phase, raw) {
351
398
  phase_name: null,
352
399
  plans: [],
353
400
  summaries: [],
401
+ // #3218: scalar counts alongside the arrays above. Left `null` (not `0`)
402
+ // when the phase can't be resolved at all — a fabricated `0` here would
403
+ // read identically to "phase exists with zero plans", which is a real,
404
+ // distinct answer (see the `status: superseded` case below).
405
+ plan_count: null,
406
+ summary_count: null,
407
+ plan_count_all: null,
354
408
  searched_directories: [],
355
409
  };
356
410
  const searchDirs = [];
@@ -381,7 +435,10 @@ function cmdFindPhase(cwd, phase, raw) {
381
435
  // #2237: fail loud when multiple directories match the same bare phase
382
436
  // number — prevents cross-project file writes when unrelated projects
383
437
  // share a .planning/phases/ tree.
384
- const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
438
+ // #2528: selection delegates to the canonical two-pass matcher (exact
439
+ // token match, then the bare-integer leading-digit-run fallback) shared
440
+ // with the locator and the phase-plan-index scan.
441
+ const { matches } = matchPhaseDirs(dirs, normalized);
385
442
  if (matches.length === 0)
386
443
  continue;
387
444
  if (matches.length > 1) {
@@ -398,9 +455,29 @@ function cmdFindPhase(cwd, phase, raw) {
398
455
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
399
456
  const phaseDir = node_path_1.default.join(searchDir, match);
400
457
  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);
458
+ // #3183: canonical, live (superseded-excluded) plan/summary sets
459
+ // (root+nested) from the single owner, rather than a root-only
460
+ // isCanonicalPlanFile filter + hand-rolled summary filter.
461
+ //
462
+ // #2893 (regression fix): both `plans` and the naming-diagnostic
463
+ // "matched" set are further intersected with the STRICT
464
+ // `isCanonicalPlanFile` predicate — scanPhasePlans's own
465
+ // planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
466
+ // fallback (deliberately permissive for live-plan COUNTING elsewhere),
467
+ // which silently recognized a non-canonically-named file (e.g.
468
+ // `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
469
+ // command's #2893 naming-convention diagnostic (no warning, offender
470
+ // listed in `plans` as if valid).
471
+ const phaseScan = scanPhasePlans(phaseDir);
472
+ const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
473
+ const summaries = phaseScan.summaryFiles.slice().sort();
474
+ // describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
475
+ // to supersession — compare against allPlanFiles (every plan-shaped file
476
+ // the owner recognizes, canonical or not) rather than the live-only
477
+ // `plans`, so a superseded-but-canonically-named plan is not misreported
478
+ // as a naming violation.
479
+ const canonicalAllPlanFiles = phaseScan.allPlanFiles.filter(isCanonicalPlanFile);
480
+ const planNamingWarning = describeNonCanonicalPlans(phaseFiles, canonicalAllPlanFiles);
404
481
  const result = {
405
482
  found: true,
406
483
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir), match)),
@@ -408,6 +485,20 @@ function cmdFindPhase(cwd, phase, raw) {
408
485
  phase_name: phaseName,
409
486
  plans,
410
487
  summaries,
488
+ // #3218: scalar counts additive alongside `plans[]`/`summaries[]`,
489
+ // which stay unchanged for existing consumers. Naming mirrors
490
+ // `roadmap.analyze`'s `plan_count`/`summary_count` (live, i.e.
491
+ // status:superseded EXCLUDED — same set as `plans`/`summaries`
492
+ // above) so the two surfaces read alike. `plan_count_all` is the
493
+ // PHYSICAL count — every canonically-named plan file on disk,
494
+ // status:superseded INCLUDED, same set `planNamingWarning` above
495
+ // diffs against (`canonicalAllPlanFiles`). The `_all` suffix
496
+ // deliberately echoes `scanPhasePlans`'s own `allPlanFiles` field so
497
+ // a reader can trace the name back to its source rather than guess
498
+ // which of two similarly-named integers is the filtered one.
499
+ plan_count: plans.length,
500
+ summary_count: summaries.length,
501
+ plan_count_all: canonicalAllPlanFiles.length,
411
502
  };
412
503
  if (planNamingWarning)
413
504
  result['warning'] = planNamingWarning;
@@ -420,23 +511,73 @@ function cmdFindPhase(cwd, phase, raw) {
420
511
  }
421
512
  output(notFound, raw, '');
422
513
  }
423
- function extractObjective(content) {
424
- const m = content.match(/<objective>\s*\n?\s*(.+)/);
425
- return m ? m[1].trim() : null;
426
- }
427
514
  /**
428
515
  * Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
429
- * (case-folded exact match, falling back to canonical-id matching). Returns
516
+ * (case-folded exact match, falling back to canonical-id matching, falling
517
+ * back to the in-phase short-form plan number — #3897 rung 4). Returns
430
518
  * `null` when the token does not resolve to any plan in this phase (a typo
431
519
  * or a cross-phase reference) — every call site treats that as "ignore this
432
520
  * edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
433
- * resolution, the `depends_on` display mapping, and (#2830) the
434
- * halt-propagation node resolution, so the three can never disagree about
435
- * which token resolves to which plan.
521
+ * resolution and (#2830) the halt-propagation node resolution, so the two can
522
+ * never disagree about which token resolves to which plan. NOT used by the
523
+ * `depends_on` display mapping (#3785/N3) — that stays a passthrough by
524
+ * design; see the comment at its call site.
525
+ *
526
+ * `shortFormToId` (#3897 rung 4, ADR-3473 §8.9) is the third tier, consulted
527
+ * only when neither `planMap` nor `canonicalToId` resolves the token. It is
528
+ * optional so any caller that has not been threaded through yet (there are
529
+ * none left in this file) degrades to the pre-#3897 two-tier behavior rather
530
+ * than throwing on a missing argument.
436
531
  */
437
- function resolveDependencyId(dep, planMap, canonicalToId) {
532
+ function resolveDependencyId(dep, planMap, canonicalToId, shortFormToId) {
438
533
  const lower = dep.toLowerCase();
439
- return planMap.has(lower) ? planMap.get(lower).id : (canonicalToId.get(lower) ?? null);
534
+ if (planMap.has(lower))
535
+ return planMap.get(lower).id;
536
+ if (canonicalToId.has(lower))
537
+ return canonicalToId.get(lower);
538
+ return shortFormToId?.get(lower) ?? null;
539
+ }
540
+ // #3897 rung 4 (ADR-3473 §8.9) — builds the third depends_on resolution tier:
541
+ // a map from an in-phase BARE PLAN NUMBER (e.g. "01") to the plan id whose
542
+ // canonical id ends with that number. Recovered from the retired SDK lineage
543
+ // (sdk/src/query/phase.ts at 11918dcc3^) with ONE deliberate narrowing: the
544
+ // lost implementation indexed ANY trailing dash-segment of a canonical id,
545
+ // with no constraint that the segment be a plan NUMBER — so a phase
546
+ // containing both `09-FIX-auth-PLAN.md` and `09-GAP-auth-PLAN.md` (canonical
547
+ // id `09-FIX-auth`, trailing segment "auth") would silently bind
548
+ // `depends_on: ["auth"]` to whichever sorted first, fabricating a
549
+ // wave-affecting DAG edge with ZERO warning — a mis-resolved edge, which is
550
+ // worse than a dropped one (found in isolated correctness review, #3897).
551
+ // `docs/reference/plan-md.md` already documents this tier as resolving "the
552
+ // bare plan number", so requiring `/^\d+$/` on the trailing segment is a
553
+ // strict narrowing onto the tier's OWN documented contract, not a behavior
554
+ // change for any legitimate input. Do NOT restore the unconstrained
555
+ // lastDash-slice "to match the recovered original" — the original was wrong
556
+ // here; this rung deliberately departs from it in this one respect, and only
557
+ // this one. Everything else — the `lastDash` bound, first-write-wins,
558
+ // lowercasing — is kept exactly as recovered:
559
+ // - first write wins, deterministic because rawPlans is passed in sorted
560
+ // plan-file order (D4/T44) and this loop iterates in that same order;
561
+ // - a canonical id with no dash (`lastDash === -1` or `lastDash === 0`,
562
+ // e.g. "24" or "-01") or a trailing dash (`lastDash === canonical.length
563
+ // - 1`, e.g. "09-") is never indexed (D5).
564
+ // Exported so callers can build this map once and so tests assert against
565
+ // this REAL implementation rather than a hand-rolled copy that could
566
+ // silently disagree with it after a future change here (CLAUDE.md's
567
+ // generative-fix-divergence rule).
568
+ function buildShortFormToId(rawPlans) {
569
+ const shortFormToId = new Map();
570
+ for (const p of rawPlans) {
571
+ const canonical = extractCanonicalPlanId(p.id);
572
+ const lastDash = canonical.lastIndexOf('-');
573
+ if (lastDash > 0 && lastDash < canonical.length - 1) {
574
+ const shortForm = canonical.slice(lastDash + 1).toLowerCase();
575
+ if (/^\d+$/.test(shortForm) && !shortFormToId.has(shortForm)) {
576
+ shortFormToId.set(shortForm, p.id);
577
+ }
578
+ }
579
+ }
580
+ return shortFormToId;
440
581
  }
441
582
  // O(V + E). Assigns each in-phase plan its longest-path topological level over the
442
583
  // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
@@ -444,19 +585,32 @@ function resolveDependencyId(dep, planMap, canonicalToId) {
444
585
  // the exact dequeue order this pass already produces — a valid topological order — passed to
445
586
  // computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
446
587
  // algorithm a second time over the same graph.
447
- function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
588
+ //
589
+ // `shortFormToId` (#3897 rung 4, optional — see resolveDependencyId) is threaded through so a
590
+ // bare in-phase plan-number token (`depends_on: ["01"]`) resolves as a real DAG edge instead of
591
+ // being dropped and silently collapsing the dependent plan to wave 1 (D3).
592
+ function computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId) {
448
593
  const level = new Map();
449
594
  const inDeg = new Map();
450
595
  const adj = new Map();
596
+ // #3427 / ADR-3473 §8.5: a depends_on token that resolves via NONE of the
597
+ // three tiers (planMap, canonicalToId, shortFormToId) is a dropped edge.
598
+ // Naming it here (rather than silently `continue`-ing past it) lets
599
+ // cmdPhasePlanIndex surface the token's own warning instead of
600
+ // manufacturing a wave-mismatch verdict from the resulting damaged graph
601
+ // (#3427).
602
+ const unresolved = [];
451
603
  for (const p of rawPlans) {
452
604
  if (!inDeg.has(p.id))
453
605
  inDeg.set(p.id, 0);
454
606
  if (!adj.has(p.id))
455
607
  adj.set(p.id, []);
456
608
  for (const dep of p.dependsOn) {
457
- const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
458
- if (!resolvedDep)
609
+ const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId, shortFormToId);
610
+ if (!resolvedDep) {
611
+ unresolved.push({ plan: p.id, token: String(dep) });
459
612
  continue;
613
+ }
460
614
  if (!adj.has(resolvedDep))
461
615
  adj.set(resolvedDep, []);
462
616
  adj.get(resolvedDep).push(p.id);
@@ -489,7 +643,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
489
643
  }
490
644
  }
491
645
  }
492
- return { level, visited, order: queue };
646
+ return { level, visited, order: queue, unresolved };
493
647
  }
494
648
  function cmdPhasePlanIndex(cwd, phase, raw) {
495
649
  if (!phase) {
@@ -499,74 +653,109 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
499
653
  const normalized = normalizePhaseName(phase);
500
654
  let phaseDir = null;
501
655
  let phaseDirName = null;
656
+ let ambiguousMatches = null;
502
657
  try {
503
658
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
504
659
  const dirs = entries
505
660
  .filter((e) => e.isDirectory())
506
661
  .map((e) => e.name)
507
662
  .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;
663
+ // #2528: selection delegates to the canonical two-pass matcher shared with
664
+ // the locator and the find-phase scan (this site previously first-matched
665
+ // with `.find()` and had no multi-match guard — the #2237 fail-loud rule
666
+ // now applies here too, so the three resolution paths cannot disagree).
667
+ const { matches } = matchPhaseDirs(dirs, normalized);
668
+ if (matches.length > 1) {
669
+ ambiguousMatches = matches;
670
+ }
671
+ else if (matches.length === 1) {
672
+ phaseDir = node_path_1.default.join(phasesDir, matches[0]);
673
+ phaseDirName = matches[0];
512
674
  }
513
675
  }
514
676
  catch {
515
677
  // phases dir doesn't exist
516
678
  }
679
+ if (ambiguousMatches) {
680
+ output({
681
+ phase: normalized,
682
+ error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
683
+ ambiguous_matches: ambiguousMatches,
684
+ plans: [], waves: {}, incomplete: [], has_checkpoints: false,
685
+ }, raw);
686
+ return;
687
+ }
517
688
  if (!phaseDir) {
518
689
  output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
519
690
  return;
520
691
  }
521
692
  void phaseDirName; // used only to set phaseDir above
693
+ // phaseFiles stays root-only readdirSync — it feeds only
694
+ // describeNonCanonicalPlans's near-miss naming diagnostic below, which is
695
+ // advisory text, not a counted/scheduled file set.
522
696
  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
- }));
697
+ // #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
698
+ // plan/summary sets (root+nested, status: superseded EXCLUDED) from the
699
+ // single owner. This fixes two real bugs in the wave/dependency index this
700
+ // function builds: (1) a superseded plan used to still get scheduled into
701
+ // an execution wave, and (2) a phase using the #3139 nested `plans/`
702
+ // layout used to report ZERO plans (root-only readdirSync, no `plans/`
703
+ // join).
704
+ // #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
705
+ // predicate — scanPhasePlans's own planFiles/allPlanFiles carry
706
+ // `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
707
+ // live-plan COUNTING elsewhere), which silently scheduled a
708
+ // non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
709
+ // here and defeated this command's #2893 naming-convention diagnostic (no
710
+ // warning). Restores the pre-#3183, tested behavior: only canonical
711
+ // root/nested filenames are ever counted or scheduled by this command.
712
+ const phaseScan = scanPhasePlans(phaseDir);
713
+ const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
714
+ const summaryFiles = phaseScan.summaryFiles;
715
+ // describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
716
+ // supersession — compare against allPlanFiles (every plan-shaped file the
717
+ // owner recognizes, canonical or not) rather than the live-only planFiles,
718
+ // so a superseded-but-canonically-named plan is not misreported as a
719
+ // naming violation.
720
+ const planNamingWarning = describeNonCanonicalPlans(phaseFiles, phaseScan.allPlanFiles.filter(isCanonicalPlanFile));
721
+ // #3183: completion pairing via the canonical findUnsummarizedPlans
722
+ // (shares its `summaryCandidates` matching rule with countMatchedSummaries,
723
+ // and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
724
+ // `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
725
+ // extractCanonicalPlanId, which only ever handled the root-canonical
726
+ // `-PLAN.md`/`-SUMMARY.md` naming form.
727
+ //
728
+ // #3345: the summary list is filtered through the SAME shared predicate
729
+ // scanPhasePlans filters its countable set with
730
+ // (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
731
+ // `status: blocked` reads as NO completion record here — has_summary false,
732
+ // the plan lands in `incomplete` — exactly matching the count side. Fail-open
733
+ // on a SUMMARY with no status key / unreadable file (filename fallback);
734
+ // `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
735
+ // below still indexes EVERY summary on disk because the halted lookup is a
736
+ // file resolution for reading status, not a completion pairing.
737
+ const countableSummaryFiles = summaryFiles.filter((f) => !isSummaryFileBlocked(node_path_1.default.join(phaseDir, f)));
738
+ const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
531
739
  // #2830: reverse lookup from a completed plan's id (exact or canonical) to
532
740
  // the actual summary filename, so a plan's own SUMMARY frontmatter can be
533
741
  // read for its `status`. Shared builder (also used by phase-locator.cts's
534
742
  // searchPhaseInDir) so the two can never disagree about which summary
535
- // belongs to which plan.
743
+ // belongs to which plan. This is a FILE resolution for reading halted
744
+ // status, not a completion-count pairing rule, so it is unaffected by the
745
+ // #3183 pairing migration above.
536
746
  const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
537
747
  // ── Pass 1: parse each plan file ─────────────────────────────────────────
538
748
  const rawPlans = [];
539
749
  for (const planFile of planFiles) {
540
- const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
750
+ const planId = planIdFromFile(planFile);
541
751
  const planPath = node_path_1.default.join(phaseDir, planFile);
542
752
  const content = node_fs_1.default.readFileSync(planPath, 'utf-8');
543
- // Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic.
544
- const fm = extractFrontmatter(content, planPath);
545
- const xmlTasks = content.match(/<task[\s>]/gi) || [];
546
- const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
547
- const taskCount = xmlTasks.length || mdTasks.length;
548
- const parsedWave = parseInt(fm['wave'], 10);
549
- const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
550
- let dependsOn = [];
551
- const fmDeps = fm['depends_on'];
552
- if (Array.isArray(fmDeps)) {
553
- dependsOn = fmDeps.map(String);
554
- }
555
- else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
556
- dependsOn = [fmDeps];
557
- }
558
- let autonomous = true;
559
- if (fm['autonomous'] !== undefined) {
560
- // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
561
- autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
562
- }
563
- let filesModified = [];
564
- const fmFiles = fm['files_modified'] || fm['files-modified'];
565
- if (fmFiles) {
566
- // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
567
- filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
568
- }
569
- const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
753
+ // #2790: plan-body parsing is owned by the shared Plan Document Module, so
754
+ // this command and the read-only `planning.inspect` query cannot drift on
755
+ // what a plan document says. planPath is still passed so a truncated
756
+ // PLAN.md names the file in the #1882 diagnostic.
757
+ const planDoc = parsePlanDocument(content, planPath);
758
+ const hasSummary = !unsummarizedPlanFiles.has(planFile);
570
759
  // #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
571
760
  // a designed stop still writes a completion record, just one whose status
572
761
  // says "halted" rather than "complete". Only look up the summary file
@@ -577,12 +766,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
577
766
  : false;
578
767
  rawPlans.push({
579
768
  id: planId,
580
- declaredWave,
581
- dependsOn,
582
- autonomous,
583
- objective: extractObjective(content) || fm['objective'] || null,
584
- filesModified,
585
- taskCount,
769
+ declaredWave: planDoc.declaredWave,
770
+ dependsOn: planDoc.dependsOn,
771
+ autonomous: planDoc.autonomous,
772
+ objective: planDoc.objective,
773
+ filesModified: planDoc.filesModified,
774
+ filesDeleted: planDoc.filesDeleted,
775
+ agentHint: planDoc.agentHint,
776
+ taskCount: planDoc.taskCount,
586
777
  hasSummary,
587
778
  halted,
588
779
  });
@@ -600,7 +791,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
600
791
  }
601
792
  const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
602
793
  const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
603
- const { level, visited, order } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
794
+ // #3897 rung 4 (ADR-3473 §8.9) — the third depends_on resolution tier.
795
+ // Resolves a bare in-phase plan-number short form (e.g. "01") to its owning
796
+ // plan id. In-phase only by construction (T49): the map is built from THIS
797
+ // phase's rawPlans alone, so a short form colliding with a different
798
+ // phase's plan can never be a candidate. See {@link buildShortFormToId}'s
799
+ // own comment for the numeric-only narrowing this rung applies on top of
800
+ // the recovered SDK-lineage algorithm.
801
+ const shortFormToId = buildShortFormToId(rawPlans);
802
+ const { level, visited, order, unresolved } = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
604
803
  if (visited < rawPlans.length) {
605
804
  const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
606
805
  error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
@@ -614,7 +813,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
614
813
  const haltNodes = rawPlans.map((p) => ({
615
814
  id: p.id,
616
815
  resolvedDependsOn: p.dependsOn
617
- .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
816
+ .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId, shortFormToId))
618
817
  .filter((id) => id !== null),
619
818
  halted: p.halted,
620
819
  }));
@@ -628,6 +827,17 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
628
827
  const runnable = [];
629
828
  let hasCheckpoints = false;
630
829
  const warnings = [];
830
+ // #3427 / ADR-3473 §8.5: name every dropped depends_on edge (plan AND
831
+ // token) rather than letting it silently collapse the plan to a DAG root.
832
+ // A plan with at least one unresolved token gets ITS OWN warning here and
833
+ // the wave-mismatch verdict below is suppressed for that plan ONLY — a
834
+ // plan with no dropped edges and a genuinely wrong `wave:` still warns
835
+ // (N3, D6, T25).
836
+ const plansWithUnresolvedTokens = new Set();
837
+ for (const { plan, token } of unresolved) {
838
+ plansWithUnresolvedTokens.add(plan);
839
+ warnings.push(`Plan ${plan}: depends_on token ${formatDiagnosticToken(token)} does not resolve to any plan in this phase — edge dropped, wave placement for this plan may be unreliable`);
840
+ }
631
841
  for (const rawPlan of rawPlans) {
632
842
  if (!rawPlan.autonomous) {
633
843
  hasCheckpoints = true;
@@ -644,7 +854,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
644
854
  }
645
855
  const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
646
856
  const effectiveWave = computedWave;
647
- if (rawPlan.declaredWave !== null && rawPlan.declaredWave !== computedWave) {
857
+ // #3427 (D5/N3): suppress the wave-mismatch verdict for a plan that has
858
+ // at least one unresolved depends_on token — its own dropped-edge
859
+ // warning above already explains the degraded wave placement, so the
860
+ // mismatch here would blame the author for a DAG the tool itself
861
+ // couldn't build. A plan with NO unresolved tokens still gets a genuine
862
+ // mismatch reported (N3, T25) — the suppression is per-plan, never blanket.
863
+ if (rawPlan.declaredWave !== null &&
864
+ rawPlan.declaredWave !== computedWave &&
865
+ !plansWithUnresolvedTokens.has(rawPlan.id)) {
648
866
  warnings.push(`Plan ${rawPlan.id}: declared wave: ${rawPlan.declaredWave} but depends_on DAG places it in wave ${computedWave}`);
649
867
  }
650
868
  const plan = {
@@ -664,6 +882,8 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
664
882
  autonomous: rawPlan.autonomous,
665
883
  objective: rawPlan.objective,
666
884
  files_modified: rawPlan.filesModified,
885
+ files_deleted: rawPlan.filesDeleted,
886
+ agent_hint: rawPlan.agentHint,
667
887
  task_count: rawPlan.taskCount,
668
888
  has_summary: rawPlan.hasSummary,
669
889
  // #2830: additive fields — halted is this plan's OWN status; blocked_by
@@ -716,10 +936,123 @@ function describeGoalShapedTitle(description) {
716
936
  return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
717
937
  `as the phase title; consider a short title with the detail moved to **Goal:**.`);
718
938
  }
939
+ /**
940
+ * #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
941
+ * entry should be inserted — at the end of the active phase list, scoped to the
942
+ * CURRENT MILESTONE so the entry can never land before a trailing `---` in
943
+ * shipped/history/backlog material (the file's last `---` on a long roadmap
944
+ * sits deep in archive). When no current milestone can be resolved (no
945
+ * STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
946
+ * legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
947
+ * their existing behavior.
948
+ */
949
+ function phaseEntryInsertOffset(rawContent, cwd) {
950
+ const ranges = currentMilestoneRawRanges(rawContent, cwd);
951
+ if (!ranges) {
952
+ const legacy = rawContent.lastIndexOf('\n---');
953
+ return legacy > 0 ? legacy : rawContent.length;
954
+ }
955
+ const window = rawContent.slice(ranges.primary.start, ranges.primary.end);
956
+ const lastSeparator = window.lastIndexOf('\n---');
957
+ return lastSeparator > 0 ? ranges.primary.start + lastSeparator : ranges.primary.end;
958
+ }
959
+ /**
960
+ * #3262 (write-time milestone-scope guard): the phase-creation and
961
+ * phase-insertion entry templates interpolate the caller's `description`
962
+ * verbatim into `### Phase N: ${description}`. A description embedding a
963
+ * level 1-3 heading that carries a milestone marker (version token,
964
+ * ✅/📋/🚧/🔄, or the word "Milestone") would splice a heading that TERMINATES
965
+ * the current milestone window (`computeMilestoneSectionEnd`) and silently
966
+ * drops every later phase out of the derived milestone phase set. Reject
967
+ * before any write or phase-directory creation — the fail-loud sibling of
968
+ * the edit-phase workflow's depends_on gate. The predicate itself
969
+ * (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
970
+ * so ordinary descriptions and the phase's own numbered heading never trip it.
971
+ */
972
+ function assertDescriptionPreservesMilestoneScope(description, command) {
973
+ const offending = findMilestoneScopeHeadingLines(description);
974
+ if (offending.length === 0)
975
+ return;
976
+ error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
977
+ `the current milestone window and silently drop later phases out of the milestone scope. ` +
978
+ `Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
979
+ `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
980
+ `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
981
+ }
982
+ /**
983
+ * #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
984
+ * worktree carries its own `.planning/` on its own branch, so a phase minted
985
+ * there is invisible to the cwd-scoped sources (headers, bullets, on-disk
986
+ * dirs). Scan each sibling's phase-directory names (cheap — dir names alone
987
+ * caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
988
+ * before any directory does; milestone-scoping is wrong here because a number
989
+ * used under any milestone on another branch is still taken).
990
+ *
991
+ * Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
992
+ * non-git cwd, or an unavailable git binary each leave `used` untouched —
993
+ * allocation then behaves exactly as it did before this horizon existed.
994
+ * Sentinels reuse the canonical `isSentinelPhaseId`; the dir pattern is the
995
+ * same one the on-disk scan uses, so decimal sub-phases (`411.1-foo`) are
996
+ * correctly not integers.
997
+ */
998
+ function collectSiblingWorktreePhaseNums(cwd, used) {
999
+ let porcelain;
1000
+ try {
1001
+ porcelain = (0, node_child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], {
1002
+ cwd,
1003
+ encoding: 'utf-8',
1004
+ // Same subprocess band as the other git call sites (smart-entry, check-command-router):
1005
+ // inside the 5-30s git window, hidden console window on Windows, bounded buffer.
1006
+ timeout: 10_000,
1007
+ windowsHide: true,
1008
+ maxBuffer: 4 * 1024 * 1024,
1009
+ });
1010
+ }
1011
+ catch {
1012
+ return; // not a git repo / git unavailable — unchanged behavior
1013
+ }
1014
+ const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1015
+ // Same header shape the allocators scan locally (#1729 tag tolerance).
1016
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
1017
+ for (const line of porcelain.split('\n')) {
1018
+ if (!line.startsWith('worktree '))
1019
+ continue;
1020
+ const wt = line.slice('worktree '.length).trim();
1021
+ if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
1022
+ continue;
1023
+ try {
1024
+ for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(wt, '.planning', 'phases'))) {
1025
+ const match = entry.match(dirNumPattern);
1026
+ if (!match)
1027
+ continue;
1028
+ const num = parseInt(match[1], 10);
1029
+ if (!isSentinelPhaseId(num))
1030
+ used.add(num);
1031
+ }
1032
+ }
1033
+ catch {
1034
+ /* worktree has no .planning — normal, contributes nothing */
1035
+ }
1036
+ try {
1037
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(wt, '.planning', 'ROADMAP.md'), 'utf-8');
1038
+ let m;
1039
+ headerPattern.lastIndex = 0;
1040
+ while ((m = headerPattern.exec(content)) !== null) {
1041
+ const num = parseInt(m[1], 10);
1042
+ if (!isSentinelPhaseId(num))
1043
+ used.add(num);
1044
+ }
1045
+ }
1046
+ catch {
1047
+ /* no roadmap in that worktree — normal, contributes nothing */
1048
+ }
1049
+ }
1050
+ }
719
1051
  function cmdPhaseAdd(cwd, description, raw, customId) {
720
1052
  if (!description) {
721
1053
  error('description required for phase add');
722
1054
  }
1055
+ assertDescriptionPreservesMilestoneScope(description, 'phase add');
723
1056
  const config = loadConfig(cwd);
724
1057
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
725
1058
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -755,12 +1088,14 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
755
1088
  let m;
756
1089
  while ((m = headerPattern.exec(content)) !== null) {
757
1090
  const num = parseInt(m[1], 10);
758
- if (num !== 999)
1091
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1092
+ if (!isSentinelPhaseId(num))
759
1093
  usedPhaseNums.add(num);
760
1094
  }
761
1095
  while ((m = bulletPattern.exec(content)) !== null) {
762
1096
  const num = parseInt(m[1], 10);
763
- if (num !== 999)
1097
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1098
+ if (!isSentinelPhaseId(num))
764
1099
  usedPhaseNums.add(num);
765
1100
  }
766
1101
  // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
@@ -772,7 +1107,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
772
1107
  if (!match)
773
1108
  continue;
774
1109
  const num = parseInt(match[1], 10);
775
- if (num !== 999)
1110
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1111
+ if (!isSentinelPhaseId(num))
776
1112
  usedPhaseNums.add(num);
777
1113
  }
778
1114
  }
@@ -780,6 +1116,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
780
1116
  // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
781
1117
  // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
782
1118
  // an existing number.
1119
+ // 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
1120
+ // taken on another branch is still taken.
1121
+ collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
783
1122
  const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
784
1123
  _newPhaseId = maxUsed + 1;
785
1124
  const paddedNum = String(_newPhaseId).padStart(2, '0');
@@ -792,14 +1131,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
792
1131
  ? ''
793
1132
  : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
794
1133
  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
- }
1134
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1135
+ const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
803
1136
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
804
1137
  return { newPhaseId: _newPhaseId, dirName: _dirName };
805
1138
  });
@@ -815,11 +1148,29 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
815
1148
  if (titleWarning)
816
1149
  result['warning'] = titleWarning;
817
1150
  output(result, raw, result['padded']);
1151
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): every
1152
+ // `publishStateContract` call site in this file is audited so a refreshed
1153
+ // state.json `updated_at` always means something on disk actually moved —
1154
+ // a stale-but-refreshed timestamp is worse than no refresh, because it
1155
+ // reads as fresh to a downstream watcher. This site is unconditional
1156
+ // because every reachable path either exits via `error()` (process.exit,
1157
+ // never reaches here) or falls through to the unconditional
1158
+ // `platformEnsureDir`/`platformWriteSync` pair above that always creates
1159
+ // the phase directory and rewrites ROADMAP.md — there is no code path that
1160
+ // reaches this line without having just written to disk. Best-effort —
1161
+ // cannot throw, cannot change this command's exit code or output.
1162
+ publishStateContract(cwd);
818
1163
  }
819
1164
  function cmdPhaseAddBatch(cwd, descriptions, raw) {
820
1165
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
821
1166
  error('descriptions array required for phase add-batch');
822
1167
  }
1168
+ // #3262: validate every description BEFORE the lock — the batch is
1169
+ // all-or-nothing, so one offending description must reject the whole batch
1170
+ // with no ROADMAP write and no phase directories created.
1171
+ for (const description of descriptions) {
1172
+ assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
1173
+ }
823
1174
  const config = loadConfig(cwd);
824
1175
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
825
1176
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -832,12 +1183,24 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
832
1183
  const content = extractCurrentMilestone(rawContent, cwd);
833
1184
  let maxPhase = 0;
834
1185
  if (config.phase_naming !== 'custom') {
1186
+ // Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
1187
+ // bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
1188
+ // `Phase N` row was invisible to batch allocation (#3849 secondary).
835
1189
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
836
1190
  const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
1191
+ const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
837
1192
  let m;
838
1193
  while ((m = phasePattern.exec(content)) !== null) {
839
1194
  const num = parseInt(m[1], 10);
840
- if (num === 999)
1195
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1196
+ if (isSentinelPhaseId(num))
1197
+ continue;
1198
+ if (num > maxPhase)
1199
+ maxPhase = num;
1200
+ }
1201
+ while ((m = bulletPattern.exec(content)) !== null) {
1202
+ const num = parseInt(m[1], 10);
1203
+ if (isSentinelPhaseId(num))
841
1204
  continue;
842
1205
  if (num > maxPhase)
843
1206
  maxPhase = num;
@@ -850,12 +1213,20 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
850
1213
  if (!match)
851
1214
  continue;
852
1215
  const num = parseInt(match[1], 10);
853
- if (num === 999)
1216
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1217
+ if (isSentinelPhaseId(num))
854
1218
  continue;
855
1219
  if (num > maxPhase)
856
1220
  maxPhase = num;
857
1221
  }
858
1222
  }
1223
+ // 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
1224
+ const siblingNums = new Set();
1225
+ collectSiblingWorktreePhaseNums(cwd, siblingNums);
1226
+ for (const num of siblingNums) {
1227
+ if (num > maxPhase)
1228
+ maxPhase = num;
1229
+ }
859
1230
  }
860
1231
  const added = [];
861
1232
  for (const description of descriptions) {
@@ -878,11 +1249,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
878
1249
  ? ''
879
1250
  : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
880
1251
  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;
1252
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1253
+ rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
886
1254
  added.push({
887
1255
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
888
1256
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
@@ -896,11 +1264,17 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
896
1264
  return added;
897
1265
  });
898
1266
  output({ phases: results, count: results.length }, raw);
1267
+ // #3227: unconditional here because `platformWriteSync(roadmapPath, rawContent)`
1268
+ // above always rewrites ROADMAP.md for every description in the batch before
1269
+ // this line is reached; the only refusal path is the `error('ROADMAP.md not
1270
+ // found')` above, which terminates the process and never reaches here.
1271
+ publishStateContract(cwd);
899
1272
  }
900
1273
  function cmdPhaseInsert(cwd, afterPhase, description, raw) {
901
1274
  if (!afterPhase || !description) {
902
1275
  error('after-phase and description required for phase insert');
903
1276
  }
1277
+ assertDescriptionPreservesMilestoneScope(description, 'phase insert');
904
1278
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
905
1279
  if (!node_fs_1.default.existsSync(roadmapPath)) {
906
1280
  error('ROADMAP.md not found');
@@ -949,7 +1323,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
949
1323
  error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
950
1324
  }
951
1325
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
952
- const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
1326
+ const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
953
1327
  for (const dir of dirs) {
954
1328
  const dm = dir.match(decimalPattern);
955
1329
  if (dm)
@@ -977,15 +1351,30 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
977
1351
  const phaseLabel = useBold
978
1352
  ? `**Phase ${_decimalPhase}: ${description}**`
979
1353
  : `Phase ${_decimalPhase}: ${description}`;
1354
+ // #3413 review fix: bulletEntry stays hardcoded '\n'. The on-disk EOL
1355
+ // is decided at write time by platformWriteSync's normalizeContent /
1356
+ // _normalizeMd (shell-command-projection.cts), which unconditionally
1357
+ // converts \r\n -> \n for any .md target — so whatever terminator is
1358
+ // used here in memory is erased before the file is ever written, and
1359
+ // templating it via detectEol(rawContent) was inert dead code. '\n'
1360
+ // matches what platformWriteSync enforces anyway.
980
1361
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
981
- const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1362
+ // #3413: was `[^\n]*`, which on CRLF content swallows the line's
1363
+ // trailing \r into the match, shifting bulletLineEnd to land BETWEEN
1364
+ // the \r and \n of the original CRLF pair — a pure splice-POSITION
1365
+ // bug on the not-yet-write-normalized CRLF read (independent of the
1366
+ // final on-disk EOL, which platformWriteSync always forces to LF for
1367
+ // .md targets regardless). Widening to [^\r\n]* stops the match at the
1368
+ // true line-content boundary so bulletLineEnd lands cleanly before the
1369
+ // terminator.
1370
+ const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
982
1371
  const bulletMatchResult = rawContent.match(targetBulletPattern);
983
1372
  if (!bulletMatchResult) {
984
1373
  error(`Could not find Phase ${afterPhase} bullet line`);
985
1374
  }
986
1375
  const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
987
1376
  const afterBullet = rawContent.slice(bulletLineEnd);
988
- const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
1377
+ const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
989
1378
  let insertIdx;
990
1379
  if (nextBulletMatch) {
991
1380
  insertIdx = bulletLineEnd + nextBulletMatch.index;
@@ -1005,7 +1394,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1005
1394
  }
1006
1395
  const headerIdx = rawContent.indexOf(headerMatch[0]);
1007
1396
  const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
1008
- const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
1397
+ const nextPhaseMatch = afterHeader.match(/\r?\n#{2,4}\s+Phase\s+\d[\d.]*/i);
1009
1398
  let insertIdx;
1010
1399
  if (nextPhaseMatch) {
1011
1400
  insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
@@ -1027,6 +1416,11 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1027
1416
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1028
1417
  };
1029
1418
  output(result, raw, decimalPhase);
1419
+ // #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
1420
+ // above always rewrites ROADMAP.md with the inserted phase before this line is
1421
+ // reached; every refusal along the way (bad args, missing ROADMAP.md, unresolved
1422
+ // target bullet/header) exits via `error()`, which terminates the process.
1423
+ publishStateContract(cwd);
1030
1424
  }
1031
1425
  function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1032
1426
  const renamedDirs = [];
@@ -1059,9 +1453,33 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1059
1453
  }
1060
1454
  return { renamedDirs, renamedFiles };
1061
1455
  }
1456
+ /**
1457
+ * Find a free name to move an occupying file aside to, on collision, so the
1458
+ * intended rename can proceed without destroying either file. Appends the
1459
+ * literal `.orphaned` suffix to the whole existing filename (never `.md`,
1460
+ * so no phase-directory scan predicate — all of which filter on
1461
+ * `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
1462
+ * the displaced file back up as any phase's artifact). Falls back to a
1463
+ * numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
1464
+ * itself is taken, bounded at 100 attempts so a pathological directory
1465
+ * cannot loop forever; returns null if no free name is found within that
1466
+ * bound, letting the caller fall back to skip-and-report.
1467
+ */
1468
+ function findOrphanedDisplacementName(dir, fileName) {
1469
+ const base = `${fileName}.orphaned`;
1470
+ if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, base)))
1471
+ return base;
1472
+ for (let n = 2; n <= 100; n++) {
1473
+ const candidate = `${base}.${n}`;
1474
+ if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, candidate)))
1475
+ return candidate;
1476
+ }
1477
+ return null;
1478
+ }
1062
1479
  function renameIntegerPhases(phasesDir, removedInt) {
1063
1480
  const renamedDirs = [];
1064
1481
  const renamedFiles = [];
1482
+ const renamedFileCollisions = [];
1065
1483
  const dirs = readSubdirectories(phasesDir, true);
1066
1484
  const toRename = dirs
1067
1485
  .map((dir) => {
@@ -1069,7 +1487,8 @@ function renameIntegerPhases(phasesDir, removedInt) {
1069
1487
  if (!m)
1070
1488
  return null;
1071
1489
  const dirInt = parseInt(m[1], 10);
1072
- return dirInt > removedInt && dirInt !== 999
1490
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1491
+ return dirInt > removedInt && !isSentinelPhaseId(dirInt)
1073
1492
  ? {
1074
1493
  dir,
1075
1494
  oldInt: dirInt,
@@ -1090,21 +1509,77 @@ function renameIntegerPhases(phasesDir, removedInt) {
1090
1509
  const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
1091
1510
  const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
1092
1511
  const newDirName = `${newPrefix}-${item.slug}`;
1512
+ // WARNING-3 (#3511 review): the directory match above accepts an
1513
+ // UNPADDED leading number (`\d+`), so a supported rename can pair a
1514
+ // 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
1515
+ // `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
1516
+ // (oldPrefix always 2-padded) misses that file: it becomes desynced from
1517
+ // its now-renamed directory and the phase reads `missing`. Try the
1518
+ // UNPADDED old-prefix form as a fallback so such an artifact renames
1519
+ // alongside its directory. A trailing-digit boundary check keeps the
1520
+ // unpadded form from over-matching a DIFFERENT phase's file (unpadded
1521
+ // prefix "1" must not match "10-…").
1522
+ const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
1093
1523
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
1094
1524
  renamedDirs.push({ from: item.dir, to: newDirName });
1095
1525
  for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
1526
+ let matchedPrefix = null;
1096
1527
  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));
1528
+ matchedPrefix = oldPrefix;
1529
+ }
1530
+ else if (oldPrefixUnpadded !== oldPrefix &&
1531
+ f.startsWith(oldPrefixUnpadded) &&
1532
+ // Token-boundary check: the character immediately after the unpadded
1533
+ // prefix must be a separator (`-`, `.`) or end-of-name, not any
1534
+ // non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
1535
+ // too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
1536
+ // wholly unrelated file whose name merely starts with the digit).
1537
+ (f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))) {
1538
+ matchedPrefix = oldPrefixUnpadded;
1539
+ }
1540
+ if (matchedPrefix) {
1541
+ const newFileName = newPrefix + f.slice(matchedPrefix.length);
1542
+ const destPath = node_path_1.default.join(phasesDir, newDirName, newFileName);
1543
+ // Collision guard: the padded and unpadded prefix forms can both
1544
+ // resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
1545
+ // `9-VERIFICATION.md` in one directory both target
1546
+ // `08-VERIFICATION.md`), and a stray cross-phase file can already sit
1547
+ // at the destination name (e.g. a leftover `08-VERIFICATION.md`
1548
+ // belonging to a DIFFERENT phase, inside phase 9's directory).
1549
+ // Renaming blindly over an existing target silently destroys
1550
+ // whichever file loses; skipping the rename instead lets the stray
1551
+ // outrank the phase's own renamed artifact once it lands at the
1552
+ // canonical name. Neither is acceptable: move the OCCUPYING file
1553
+ // aside first (never overwrite, never skip the real rename), then
1554
+ // complete the intended rename so the phase's own artifact takes the
1555
+ // canonical name. This also handles a target that was already
1556
+ // claimed by an EARLIER file in this same pass, since that earlier
1557
+ // rename already created it on disk.
1558
+ if (node_fs_1.default.existsSync(destPath)) {
1559
+ const displacedName = findOrphanedDisplacementName(node_path_1.default.join(phasesDir, newDirName), newFileName);
1560
+ if (displacedName === null) {
1561
+ // No free displacement name within the bounded search — fall
1562
+ // back to skip-and-report rather than looping or overwriting.
1563
+ renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
1564
+ continue;
1565
+ }
1566
+ (0, shell_command_projection_cjs_1.retryRenameSync)(destPath, node_path_1.default.join(phasesDir, newDirName, displacedName));
1567
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
1568
+ renamedFiles.push({ from: f, to: newFileName });
1569
+ renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
1570
+ continue;
1571
+ }
1572
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
1099
1573
  renamedFiles.push({ from: f, to: newFileName });
1100
1574
  }
1101
1575
  }
1102
1576
  }
1103
- return { renamedDirs, renamedFiles };
1577
+ return { renamedDirs, renamedFiles, renamedFileCollisions };
1104
1578
  }
1105
1579
  function decrementRoadmapPhaseNumber(raw, removedInt) {
1106
1580
  const num = parseInt(raw, 10);
1107
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1581
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1582
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1108
1583
  return raw;
1109
1584
  return String(num - 1);
1110
1585
  }
@@ -1113,13 +1588,15 @@ function decrementRoadmapPhaseToken(raw, removedInt) {
1113
1588
  if (!match)
1114
1589
  return raw;
1115
1590
  const num = parseInt(match[1], 10);
1116
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1591
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1592
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1117
1593
  return raw;
1118
1594
  return `${num - 1}${match[2] || ''}`;
1119
1595
  }
1120
1596
  function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
1121
1597
  const num = parseInt(raw, 10);
1122
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1598
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1599
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1123
1600
  return raw;
1124
1601
  return String(num - 1).padStart(raw.length, '0');
1125
1602
  }
@@ -1157,10 +1634,26 @@ function findDataRowLine(sectionText, dataRowIndex) {
1157
1634
  }
1158
1635
  return null;
1159
1636
  }
1637
+ // #3685: mirror requirementsUpdated's diff-tracking contract — the caller
1638
+ // (cmdPhaseRemove) used to report `roadmap_updated: true` unconditionally,
1639
+ // hardcoded regardless of whether this transform actually changed
1640
+ // ROADMAP.md's content. Returning a real before/after comparison here lets
1641
+ // the caller report accurately, the same fix #3685 applied to
1642
+ // `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
1643
+ // sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
1160
1644
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
1161
- withPlanningLock(cwd, () => {
1162
- let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1163
- const escaped = escapeRegex(targetPhase);
1645
+ return withPlanningLock(cwd, () => {
1646
+ const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1647
+ let content = originalContent;
1648
+ const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
1649
+ // #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
1650
+ // of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
1651
+ // user's remove query is usually unpadded (`1.1`) — and integer headings
1652
+ // legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
1653
+ // makes the token padding-insensitive in both directions without widening
1654
+ // to other ids: the token stays anchored between `Phase\s+`/line-start and
1655
+ // `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
1656
+ const padTolerant = `0*${escaped}`;
1164
1657
  // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
1165
1658
  // detail section INCLUDING its own heading line. Migrated onto deleteSection
1166
1659
  // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
@@ -1172,9 +1665,9 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1172
1665
  // such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
1173
1666
  // away everything after it — including a trailing `## Progress` heading and
1174
1667
  // its tracking table.
1175
- const phaseHeadingRe = new RegExp(`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1668
+ const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1176
1669
  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'), '');
1670
+ content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
1178
1671
  // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
1179
1672
  // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
1180
1673
  // of updateTableCell). Scoped to the `## Progress` section — mirroring
@@ -1201,7 +1694,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1201
1694
  const matchRemovedProgressRow = (row) => {
1202
1695
  const firstCellRaw = (Object.values(row)[0] ?? '').trim();
1203
1696
  if (isDecimal) {
1204
- return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1697
+ return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1205
1698
  }
1206
1699
  const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
1207
1700
  if (!leadingMatch || leadingMatch[2])
@@ -1216,7 +1709,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1216
1709
  if (!isDecimal) {
1217
1710
  // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
1218
1711
  // 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}`);
1712
+ 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
1713
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
1221
1714
  // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
1222
1715
  // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
@@ -1278,7 +1771,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1278
1771
  if (!m)
1279
1772
  return false;
1280
1773
  const num = parseInt(m[1], 10);
1281
- if (!Number.isInteger(num) || num <= removedInt || num === 999)
1774
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1775
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
1282
1776
  return false;
1283
1777
  processedOrdinalRows.add(index);
1284
1778
  matchedRowIndex = index;
@@ -1291,7 +1785,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1291
1785
  const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
1292
1786
  const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
1293
1787
  const padMatch = targetLine
1294
- ? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
1788
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
1295
1789
  : null;
1296
1790
  const leadPad = padMatch ? padMatch[1] : ' ';
1297
1791
  const trailPad = padMatch ? padMatch[2] : ' ';
@@ -1308,8 +1802,43 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1308
1802
  content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
1309
1803
  }
1310
1804
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
1805
+ // #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
1806
+ // persists), not the raw pre-normalize `content` string, against the raw
1807
+ // pre-mutation `originalContent` read above — a raw `!==` here reports a
1808
+ // false `true` whenever this transform's regenerated output takes a
1809
+ // different-but-equivalent shape than the already-normalized on-disk
1810
+ // original (same normalization-order artifact #3685 fixed at
1811
+ // cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
1812
+ return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
1311
1813
  });
1312
1814
  }
1815
+ /**
1816
+ * #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
1817
+ * the leading frontmatter block's closing `---` fence — so a body field never
1818
+ * lands before the opening fence. The former whole-content prepend
1819
+ * (`field + content`) put the line ABOVE the opening `---`, and
1820
+ * syncStateFrontmatter then treated the scrambled fence structure as TWO
1821
+ * frontmatter blocks, rebuilding a derived one on top of the original
1822
+ * (milestone_name from a ROADMAP heading, total_phases counting the removed
1823
+ * phase, a stray 'Total Phases: 0' between fences). A file with no leading
1824
+ * frontmatter is all body: the field goes to content start, preserving the
1825
+ * former behavior for that shape.
1826
+ */
1827
+ function insertStateBodyFieldAtTop(content, fieldLine) {
1828
+ // Split AND join on bare '\n' so CRLF line endings stay attached to their
1829
+ // own lines — each '\r' remains the tail of the line it terminated, where
1830
+ // the trimmed fence compare still matches it. (#3572 review: splitting on
1831
+ // '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
1832
+ const lines = content.split('\n');
1833
+ if ((lines[0] ?? '').trim() === '---') {
1834
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
1835
+ if (closeIdx !== -1) {
1836
+ lines.splice(closeIdx + 1, 0, '', fieldLine);
1837
+ return lines.join('\n');
1838
+ }
1839
+ }
1840
+ return fieldLine + '\n' + content;
1841
+ }
1313
1842
  function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1314
1843
  if (!targetPhase)
1315
1844
  error('phase number required for phase remove');
@@ -1321,24 +1850,55 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1321
1850
  const isDecimal = targetPhase.includes('.');
1322
1851
  const force = options.force || false;
1323
1852
  const subdirs = readSubdirectories(phasesDir, true);
1324
- const targetDir = subdirs.find((d) => phaseTokenMatches(d, normalized)) || null;
1853
+ // #2237/#2528: every other resolution path refuses to choose between multiple
1854
+ // directories claiming one phase number. This one is the DESTRUCTIVE path, so
1855
+ // taking `matches[0]` silently is strictly worse than anywhere else: it turns
1856
+ // "resolve nothing" into "delete one of two candidates, unrecoverably, and
1857
+ // renumber every phase after it". Refuse before any file is touched.
1858
+ const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
1859
+ if (phaseDirMatches.length > 1) {
1860
+ output({
1861
+ removed: null,
1862
+ error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
1863
+ + `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
1864
+ + 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
1865
+ ambiguous_matches: phaseDirMatches,
1866
+ directory_deleted: null,
1867
+ renamed_directories: [],
1868
+ renamed_files: [],
1869
+ roadmap_updated: false,
1870
+ state_updated: false,
1871
+ }, raw);
1872
+ return;
1873
+ }
1874
+ const targetDir = phaseDirMatches[0] || null;
1325
1875
  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.`);
1876
+ // #3183: canonical summary set (root+nested) from the single owner —
1877
+ // a root-only readdirSync filter left nested (#3139 layout) summaries
1878
+ // invisible, letting a phase with completed nested work be deleted
1879
+ // without --force.
1880
+ const summaryCount = scanPhasePlans(node_path_1.default.join(phasesDir, targetDir)).summaryFiles.length;
1881
+ if (summaryCount > 0) {
1882
+ error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
1330
1883
  }
1331
1884
  }
1332
1885
  if (targetDir)
1333
1886
  node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, targetDir), { recursive: true, force: true });
1334
1887
  let renamedDirs = [];
1335
1888
  let renamedFiles = [];
1889
+ let renamedFileCollisions = [];
1336
1890
  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;
1891
+ if (isDecimal) {
1892
+ const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
1893
+ renamedDirs = renamed.renamedDirs;
1894
+ renamedFiles = renamed.renamedFiles;
1895
+ }
1896
+ else {
1897
+ const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
1898
+ renamedDirs = renamed.renamedDirs;
1899
+ renamedFiles = renamed.renamedFiles;
1900
+ renamedFileCollisions = renamed.renamedFileCollisions;
1901
+ }
1342
1902
  }
1343
1903
  catch (e) {
1344
1904
  // #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
@@ -1353,7 +1913,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1353
1913
  const msg = e instanceof Error ? e.message : String(e);
1354
1914
  error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
1355
1915
  }
1356
- updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1916
+ const roadmapUpdated = updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1357
1917
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1358
1918
  let stateUpdated = false;
1359
1919
  if (node_fs_1.default.existsSync(statePath)) {
@@ -1367,13 +1927,15 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1367
1927
  let modified = stateContent;
1368
1928
  const totalRaw = stateExtractField(modified, 'Total Phases');
1369
1929
  if (totalRaw) {
1930
+ // #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
1931
+ // an earlier remove whose dir-count was 0) must not decrement to -1 on
1932
+ // the next removal.
1370
1933
  modified =
1371
- stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
1372
- modified;
1934
+ stateReplaceField(modified, 'Total Phases', String(Math.max(0, parseInt(totalRaw, 10) - 1))) || modified;
1373
1935
  }
1374
1936
  const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1375
1937
  if (ofMatch) {
1376
- modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
1938
+ modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`);
1377
1939
  }
1378
1940
  // #2640: if neither body field was found, the transform is a no-op.
1379
1941
  // readModifyWriteStateMd's no-op guard (#948) would then skip the
@@ -1386,16 +1948,33 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1386
1948
  if (targetDir && modified === stateContent) {
1387
1949
  // subdirs was read before the deletion; excluding the removed target
1388
1950
  // gives the remaining count. Renumbering changes names but not count.
1389
- const remainingPhases = subdirs.filter((d) => phaseTokenMatches(d, normalized) === false).length;
1951
+ //
1952
+ // #2528: exclude the directory that was ACTUALLY deleted, by identity,
1953
+ // rather than re-deriving "which dir was the target" from the query.
1954
+ // The two are not the same predicate here: `targetDir` comes from
1955
+ // `matchPhaseDirs`, whose bare-integer fallback resolves digit-leading
1956
+ // dirs (`05-80-20-cleanup` for query `5`) that `phaseTokenMatches`
1957
+ // reports as non-matching — so a token re-derivation would count the
1958
+ // just-deleted directory as still present and write a `Total Phases`
1959
+ // one too high. Identity is also what the comment above already
1960
+ // claims this filter does, and the block is gated on targetDir.
1961
+ // (#3572 note: this body field counts DIRECTORIES on disk; the
1962
+ // frontmatter progress.* block is rebuilt by syncStateFrontmatter
1963
+ // from the post-removal ROADMAP — the two counts legitimately differ
1964
+ // when phases exist in ROADMAP without directories.)
1965
+ const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
1390
1966
  if (totalRaw) {
1391
1967
  modified =
1392
1968
  stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
1393
1969
  }
1394
1970
  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;
1971
+ // No 'Total Phases:' field in the body — insert one at the start of
1972
+ // the BODY so the no-op guard sees a diff. #3572: the former
1973
+ // whole-content prepend landed the line BEFORE the opening '---'
1974
+ // fence and corrupted STATE.md into two frontmatter blocks.
1975
+ // syncStateFrontmatter will still rebuild the frontmatter
1976
+ // progress.* block from the real disk/ROADMAP count.
1977
+ modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
1399
1978
  }
1400
1979
  }
1401
1980
  return modified;
@@ -1406,10 +1985,28 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1406
1985
  directory_deleted: targetDir,
1407
1986
  renamed_directories: renamedDirs,
1408
1987
  renamed_files: renamedFiles,
1409
- roadmap_updated: true,
1988
+ renamed_file_collisions: renamedFileCollisions,
1989
+ // #3685: mirror requirementsUpdated's diff-tracking contract — true only
1990
+ // when updateRoadmapAfterPhaseRemoval's content diff detected a real
1991
+ // change, not hardcoded regardless of whether ROADMAP.md's content
1992
+ // actually changed.
1993
+ roadmap_updated: roadmapUpdated,
1410
1994
  state_updated: stateUpdated,
1411
1995
  }, raw);
1996
+ // #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
1997
+ // always rewrites ROADMAP.md before this line is reached; every refusal path
1998
+ // (bad target, missing ROADMAP.md, --force-required, renumber failure) exits
1999
+ // via `error()`, and the ambiguous-match case exits via an earlier `return`
2000
+ // before any file is touched.
2001
+ publishStateContract(cwd);
1412
2002
  }
2003
+ /**
2004
+ * #3227: returns the count of writes actually applied (entries whose
2005
+ * `before` differed from `after` and were therefore written to disk) — the
2006
+ * caller (`cmdPhaseComplete`) uses this as its publish-gate signal, since a
2007
+ * re-run against an already-completed phase can produce a `writes[]` array
2008
+ * where every entry is byte-identical to what's already on disk.
2009
+ */
1413
2010
  function writePlanningFileSet(writes) {
1414
2011
  const applied = [];
1415
2012
  try {
@@ -1439,6 +2036,7 @@ function writePlanningFileSet(writes) {
1439
2036
  }
1440
2037
  throw err;
1441
2038
  }
2039
+ return applied.length;
1442
2040
  }
1443
2041
  function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1444
2042
  if (!roadmapContent || !phaseNum)
@@ -1467,11 +2065,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1467
2065
  // init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
1468
2066
  // explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
1469
2067
  const availableWorkstreams = listAvailableWorkstreams(cwd);
1470
- const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
2068
+ // #3579 root-cause fix: this is a check, not a consuming read — use the
2069
+ // non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
2070
+ // here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
2071
+ // which would misreport a present-but-bad marker as no marker at all.
2072
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
1471
2073
  if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
2074
+ // #3579: getActiveWorkstream now inherits a pointer-less session's read
2075
+ // from the shared .planning/active-workstream marker, so reaching this
2076
+ // branch with a marker actually present means the marker EXISTED but
2077
+ // didn't resolve (invalid name, or its workstream dir is gone) — a
2078
+ // materially different situation from "nothing was ever set" and one
2079
+ // that deserves its own diagnostic instead of the generic message below.
2080
+ const diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
2081
+ if (diagnosis.present) {
2082
+ 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. ` +
2083
+ `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. ` +
2084
+ `Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED, { marker_value: diagnosis.value, marker_reason: diagnosis.reason });
2085
+ }
1472
2086
  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
2087
  `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(', ')}`);
2088
+ `Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
1475
2089
  }
1476
2090
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1477
2091
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
@@ -1489,7 +2103,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1489
2103
  ? phaseInfo['summaries'].length
1490
2104
  : 0;
1491
2105
  let requirementsUpdated = false;
2106
+ // #3685: mirror requirementsUpdated's diff-tracking contract at the
2107
+ // writes.push({filePath, before, after}) sites below, rather than
2108
+ // reporting via fs.existsSync (which is true whenever the file merely
2109
+ // exists, not when the transaction actually wrote a change).
2110
+ let roadmapUpdated = false;
2111
+ let stateUpdated = false;
1492
2112
  const warnings = [];
2113
+ // ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
2114
+ // composition's preservation stage restores a curated frontmatter value
2115
+ // over a disagreeing derived one, that divergence is surfaced here rather
2116
+ // than silently absorbed. Structured (field + reason), not prose, so a
2117
+ // caller can assert on the value rather than regex a rendered message.
2118
+ // Named `preservation_warnings`, NOT `warnings`: `warnings` above is
2119
+ // already a prose `string[]` on this exact command — reusing it for a
2120
+ // structured `{field, reason}[]` shape would be the "Generative Fix
2121
+ // Divergence" anti-pattern (two sibling fields, same name, different
2122
+ // element types). Mirrors `cmdMilestoneComplete`'s identical field
2123
+ // (milestone.cts).
2124
+ const preservationWarnings = [];
1493
2125
  // #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
1494
2126
  // roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
1495
2127
  // below (inside withPlanningLock) alongside the warnings[] entry, so a
@@ -1564,7 +2196,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1564
2196
  }
1565
2197
  try {
1566
2198
  const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
1567
- for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) {
2199
+ // #3511: scope this advisory pre-scan to THIS phase's own token so a
2200
+ // stray, cross-phase, or ad-hoc file cannot name a warning against a
2201
+ // phase it does not belong to.
2202
+ const phaseFullDirBaseName = node_path_1.default.basename(phaseFullDir);
2203
+ for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseFullDirBaseName)) {
1568
2204
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
1569
2205
  if (/result: pending/.test(content))
1570
2206
  warnings.push(`${file}: has pending tests`);
@@ -1575,9 +2211,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1575
2211
  if (/status: diagnosed/.test(content))
1576
2212
  warnings.push(`${file}: has diagnosed gaps`);
1577
2213
  }
1578
- for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
2214
+ for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
1579
2215
  const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
1580
- const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
2216
+ // #3707-CR follow-up MINOR: normalize line endings at this read boundary
2217
+ // (same fix as src/verification.cts's readVerificationStatus) so a
2218
+ // lone-CR VERIFICATION.md's `---\r...\r---` frontmatter fence still
2219
+ // matches extractFrontmatter's byte-0 check instead of silently
2220
+ // dropping the human_needed/gaps_found advisory warning below.
2221
+ const content = normalizeLineEndings(node_fs_1.default.readFileSync(verificationFilePath, 'utf-8'));
1581
2222
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
1582
2223
  // from historical metadata in the file body (e.g. `previous_status: gaps_found`).
1583
2224
  // A full-text regex like /status: gaps_found/ matches the substring inside
@@ -1639,7 +2280,33 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1639
2280
  let nextPhaseNum = null;
1640
2281
  let nextPhaseName = null;
1641
2282
  let isLastPhase = true;
2283
+ // #3311: typed conflict descriptor surfaced on the result JSON alongside the
2284
+ // warnings[] entry below (same parity pattern as
2285
+ // verification_stale_check_indeterminate).
2286
+ let milestoneConflict = null;
2287
+ // #3227: set inside `runPhaseCompleteTransaction` below from
2288
+ // `writePlanningFileSet`'s applied-count return — the transaction always
2289
+ // RUNS (verification passed, the lock was taken, `writes[]` was built),
2290
+ // but a re-run against a phase whose ROADMAP/STATE bytes already reflect
2291
+ // completion produces a `writes[]` where every entry is byte-identical to
2292
+ // disk, so `writePlanningFileSet` applies none of them. That must not
2293
+ // still refresh state.json's `updated_at` (design doc §40 row 26).
2294
+ let anyPlanningWrite = false;
1642
2295
  const verificationBlocked = withPlanningLock(cwd, () => {
2296
+ // #3311: completing a phase while a live milestone claim (phase + session)
2297
+ // holds a DIFFERENT phase means two sessions are working two phases against
2298
+ // the single Current Position slot. Warn via the established warnings[]
2299
+ // channel (rendered by execute-phase.md's "If has_warnings is true" step)
2300
+ // rather than blocking — the claim may simply be stale-but-live.
2301
+ milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
2302
+ if (milestoneConflict) {
2303
+ const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
2304
+ const actor = milestoneConflict.session ?? 'an unknown (headless) session';
2305
+ warnings.push(`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
2306
+ `${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
2307
+ `STATE.md's Current Position is a single slot; verify it before trusting it`);
2308
+ milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
2309
+ }
1643
2310
  // #2617: pass the project's runtime so the blocked-completion error below
1644
2311
  // suggests the command surface this runtime actually installs
1645
2312
  // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
@@ -1786,7 +2453,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1786
2453
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1787
2454
  if (!planId)
1788
2455
  continue;
1789
- const planEscaped = escapeRegex(planId);
2456
+ const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
1790
2457
  const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
1791
2458
  b = b.replace(planCheckboxPattern, '$1x$2');
1792
2459
  }
@@ -1816,6 +2483,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1816
2483
  before: originalRoadmapContent,
1817
2484
  after: roadmapContent,
1818
2485
  });
2486
+ // #3685 / #3691: normalize both sides before comparing — see
2487
+ // contentChangedAfterNormalize's doc (shell-command-projection.cts).
2488
+ // A raw `!==` here false-positives whenever this phase-complete
2489
+ // roadmap mutation regenerates a section in a different-but-
2490
+ // equivalent raw shape than the already-normalized on-disk original.
2491
+ roadmapUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalRoadmapContent, roadmapContent);
1819
2492
  const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
1820
2493
  if (node_fs_1.default.existsSync(reqPath)) {
1821
2494
  const phaseEsc = phaseMarkdownRegexSource(phaseNum);
@@ -1862,7 +2535,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1862
2535
  .filter(Boolean)
1863
2536
  .filter((r) => REQ_ID_SHAPE_RE.test(r));
1864
2537
  for (const reqId of citedReqIds) {
1865
- const reqEscaped = escapeRegex(reqId);
2538
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
1866
2539
  // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
1867
2540
  // #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
1868
2541
  // cmdRequirementsMarkComplete). Capture the pre-flip content; if a
@@ -2069,7 +2742,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2069
2742
  // row actually matched is registered — not a ghost — regardless of
2070
2743
  // which section (deferred or not) it lives under.
2071
2744
  const reqIsRegisteredAnywhere = (id) => {
2072
- const reqEscaped = escapeRegex(id);
2745
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
2073
2746
  // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
2074
2747
  // insensitive: existence check, not the write's space-only match.
2075
2748
  if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
@@ -2102,27 +2775,67 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2102
2775
  // diff-tracking pattern used for the ROADMAP write above. A phase
2103
2776
  // whose citations match nothing (ghost REQ-IDs only) must report
2104
2777
  // `false`, not a bare "the file was present" `true`.
2105
- requirementsUpdated = reqContent !== originalReqContent;
2778
+ // #3685 / #3691: normalize both sides before comparing — same
2779
+ // false-positive shape as the sibling roadmapUpdated/stateUpdated
2780
+ // flags in this same transaction; all three must agree by
2781
+ // construction (see contentChangedAfterNormalize's doc).
2782
+ requirementsUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(reqPath, originalReqContent, reqContent);
2106
2783
  }
2107
2784
  }
2785
+ // #3701 — the ROADMAP decides WHICH phase is next; the disk decides only HOW it
2786
+ // is spelled. Both scans select the numerically lowest phase above N.
2787
+ //
2788
+ // Both scans below are unchanged in what they match; what changed is that
2789
+ // the roadmap is no longer gated behind "the disk found nothing". It used
2790
+ // to be (`if (isLastPhase && roadmapContent !== null)`), which made a wrong
2791
+ // disk answer uncorrectable: phase directories are created lazily, but
2792
+ // `phase insert` scaffolds an inserted phase's directory immediately, so an
2793
+ // inserted decimal is routinely the ONLY directory above N and outranked
2794
+ // every phase preceding it in the roadmap. Observed: roadmap `1, 2, 02.1,
2795
+ // 3` with directories for 01 and 02.1 only reported `next_phase: "02.1"`
2796
+ // after completing 1 — and PERSISTED it to STATE.md — while
2797
+ // `roadmap.analyze` correctly said `2`.
2798
+ //
2799
+ // #3581 fixed exactly this at `init.progress` and named the rule: "the
2800
+ // frontier is ROADMAP ORDER, not artifact presence". This call site was not
2801
+ // in that change's scope.
2802
+ //
2803
+ // Why the disk scan survives, rather than being replaced:
2804
+ // 1. It is the only resolver when there is no ROADMAP.md, or when its
2805
+ // phase rows do not parse.
2806
+ // 2. When both agree, it carries the SPELLING the output has always used
2807
+ // — the zero-padded directory token and the on-disk slug (`02`/`beta`),
2808
+ // where the roadmap would give `2` and a slugified title. Promoting the
2809
+ // roadmap without this would silently change the reported value on
2810
+ // every aligned project, which is the majority case.
2811
+ let diskNextNum = null;
2812
+ let diskNextName = null;
2813
+ let roadmapNextNum = null;
2814
+ let roadmapNextName = null;
2108
2815
  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));
2816
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to
2817
+ // the CURRENT milestone" — routed through the canonical owner
2818
+ // instead of a hand-rolled readdirSync + isDirInMilestone filter
2819
+ // (which also never excluded sentinels on its own, unlike the
2820
+ // owner; the per-directory isSentinelPhaseId check below stays as a
2821
+ // defensive second check against the REGEX-EXTRACTED token, which
2822
+ // is not necessarily identical to the raw directory name).
2823
+ const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
2116
2824
  for (const dir of dirs) {
2117
2825
  const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
2118
2826
  if (dm) {
2119
- if (/^999(?:\.|$)/.test(dm[1]))
2827
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2828
+ if (isSentinelPhaseId(dm[1]))
2120
2829
  continue;
2121
- if (comparePhaseNum(dm[1], phaseNum) > 0) {
2122
- nextPhaseNum = dm[1];
2123
- nextPhaseName = dm[2] || null;
2124
- isLastPhase = false;
2125
- break;
2830
+ // Numeric MINIMUM above N, not "first encountered". `listMilestonePhaseDirs`
2831
+ // does sort by `comparePhaseNum`, so a `break` on the first hit happens to be
2832
+ // correct today — but that makes this scan's correctness depend on an
2833
+ // upstream sort nothing here states. Selecting the minimum explicitly costs
2834
+ // one comparison and removes the hidden coupling.
2835
+ if (comparePhaseNum(dm[1], phaseNum) > 0
2836
+ && (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
2837
+ diskNextNum = dm[1];
2838
+ diskNextName = dm[2] || null;
2126
2839
  }
2127
2840
  }
2128
2841
  }
@@ -2136,7 +2849,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2136
2849
  * derives the same information independently from ROADMAP.md content
2137
2850
  * — not a silent data-loss path. */
2138
2851
  }
2139
- if (isLastPhase && roadmapContent !== null) {
2852
+ if (roadmapContent !== null) {
2140
2853
  try {
2141
2854
  const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
2142
2855
  // #1591: match BOTH heading-style phases (`### Phase N:`) AND
@@ -2158,20 +2871,32 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2158
2871
  let pm;
2159
2872
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
2160
2873
  // #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.
2874
+ // already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
2875
+ // stage 2's heading scan must not advance into backlog headings either.
2164
2876
  if (isSentinelPhaseId(pm[1]))
2165
2877
  continue;
2166
- if (comparePhaseNum(pm[1], phaseNum) > 0) {
2167
- nextPhaseNum = pm[1];
2168
- nextPhaseName = pm[2]
2878
+ // #3701 review: the numeric MINIMUM above N, not the first row above N in
2879
+ // DOCUMENT order. This scan walks raw roadmap text, and one global regex
2880
+ // sweeps both the `## Phases` checklist and the `## Phase Details`
2881
+ // headings, so "first match" is a statement about where a line sits in the
2882
+ // file — not about which phase comes next.
2883
+ //
2884
+ // It mattered only once this scan started deciding the answer. Before, it
2885
+ // ran solely when the disk scan found nothing; now it outranks the disk, so
2886
+ // a roadmap listing rows out of numeric sequence (`1, 3, 2`) reported
2887
+ // `next_phase: 3` and PERSISTED it, skipping Phase 2 — on an input the
2888
+ // pre-#3701 code got right, because the disk scan is numerically sorted.
2889
+ // Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
2890
+ // the disk scan and for #2028's lowest-outstanding override; the roadmap
2891
+ // defines which phases EXIST and which milestone they belong to.
2892
+ if (comparePhaseNum(pm[1], phaseNum) > 0
2893
+ && (roadmapNextNum === null || comparePhaseNum(pm[1], roadmapNextNum) < 0)) {
2894
+ roadmapNextNum = pm[1];
2895
+ roadmapNextName = pm[2]
2169
2896
  .replace(/\(INSERTED\)/i, '')
2170
2897
  .trim()
2171
2898
  .toLowerCase()
2172
2899
  .replace(/\s+/g, '-');
2173
- isLastPhase = false;
2174
- break;
2175
2900
  }
2176
2901
  }
2177
2902
  }
@@ -2182,6 +2907,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2182
2907
  * regardless and provides a further, independent override. */
2183
2908
  }
2184
2909
  }
2910
+ // Resolve. The roadmap wins on identity; the disk wins on spelling when it
2911
+ // is talking about the same phase.
2912
+ if (roadmapNextNum !== null) {
2913
+ // Same comparator both scans already use to order phases, so "the disk
2914
+ // and the roadmap mean the same phase" cannot drift from "N is above the
2915
+ // one just completed". `02` and `2` compare equal, which is the whole
2916
+ // point — they are the same phase spelled two ways.
2917
+ const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
2918
+ nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
2919
+ nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
2920
+ isLastPhase = false;
2921
+ }
2922
+ else if (diskNextNum !== null) {
2923
+ // No usable roadmap (absent, unreadable, or no parseable phase rows) —
2924
+ // the disk is all there is. Unchanged from the pre-#3701 behaviour.
2925
+ nextPhaseNum = diskNextNum;
2926
+ nextPhaseName = diskNextName;
2927
+ isLastPhase = false;
2928
+ }
2185
2929
  // #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
2186
2930
  // still outstanding. The two blocks above only clear isLastPhase when a
2187
2931
  // HIGHER-numbered phase exists, so completing the numerically-highest phase
@@ -2196,7 +2940,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2196
2940
  // pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
2197
2941
  // between the box and "Phase", a required `:`) so unrelated checklist lines
2198
2942
  // that merely mention "Phase N" don't match.
2199
- if (isLastPhase && roadmapContent !== null) {
2943
+ // #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
2944
+ // the next actionable phase?" vs "is this the last phase?"), so it must not
2945
+ // be gated on their answer. Gating on isLastPhase let a merely-positionally
2946
+ // next higher heading (stage 2) permanently mask a genuinely-outstanding
2947
+ // lower phase — stage 2 cleared isLastPhase and this scan never ran. The
2948
+ // scan already refuses anything not strictly lower than the completed phase
2949
+ // (plus sentinels, #2949), so running it unconditionally cannot manufacture
2950
+ // a wrong answer: when no lower phase is outstanding it finds nothing and
2951
+ // stages 1-2's pick stands unchanged; in the masking case isLastPhase is
2952
+ // already false, so the last-phase signal has no reachable regression.
2953
+ if (roadmapContent !== null) {
2200
2954
  try {
2201
2955
  const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
2202
2956
  const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
@@ -2243,11 +2997,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2243
2997
  // to the STATE.md Transition Module. The ~90-line inline RMW callback
2244
2998
  // that lived here is the pure `completePhaseCore` in
2245
2999
  // 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).
3000
+ // `updatePerformanceMetricsSection` stays in this adapter: it is a
3001
+ // section-table / disk-scan concern, not a classified field. The
3002
+ // sync + post-sync preservation this transaction needs runs via the
3003
+ // single write-seam composition, `syncAndPreserveStateMd` (it does
3004
+ // NOT go through readModifyWriteStateMd because STATE.md is
3005
+ // committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
3006
+ // #3374 / #3469).
2251
3007
  const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
2252
3008
  phaseDisplayNameFromSlug(nextPhaseName);
2253
3009
  const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
@@ -2269,10 +3025,64 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2269
3025
  // the intent; pass it as authoritative so the sync's prose
2270
3026
  // re-derivation cannot rewrite current_phase_name to the name's own
2271
3027
  // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
2272
- stateContent = syncStateFrontmatter(stateContent, cwd, nextPhaseDisplayName ? { current_phase_name: nextPhaseDisplayName } : undefined);
3028
+ // #3350: PAIR the override. When STATE.md's body carries no Current
3029
+ // Phase / Phase field to re-derive from (narrative prose), the #905
3030
+ // preserve guard in syncStateFrontmatter keeps the OLD frontmatter
3031
+ // current_phase while the authoritative current_phase_name advances —
3032
+ // leaving the two fields describing different phases. Pin BOTH to the
3033
+ // resolved next phase in that case. When the body DOES carry the field
3034
+ // (completePhaseCore just rewrote it), stay name-only so the body's
3035
+ // richer `N of T (name)` derived shape survives the sync.
3036
+ const fmBody = frontmatterMod.stripFrontmatter(stateContent);
3037
+ const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
3038
+ stateExtractField(fmBody, 'Phase') != null;
3039
+ const authoritativeFm = nextPhaseDisplayName
3040
+ ? bodyHasPhaseField || !nextPhaseNum
3041
+ ? { current_phase_name: nextPhaseDisplayName }
3042
+ : {
3043
+ current_phase: String(nextPhaseNum),
3044
+ current_phase_name: nextPhaseDisplayName,
3045
+ }
3046
+ : undefined;
3047
+ // ADR-3408 §8.3 / #3469: this deliberately bypasses
3048
+ // readModifyWriteStateMd (STATE.md is committed atomically with
3049
+ // ROADMAP/REQUIREMENTS), so it calls the single write-seam
3050
+ // composition (`syncAndPreserveStateMd`) directly instead of
3051
+ // assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
3052
+ // itself — a call site re-assembling the pair, even with every step
3053
+ // calling an owner, is the exact re-derivation §8.3 forbids by name
3054
+ // (Phase 2 found this shape live here). The composition runs
3055
+ // snapshots from the on-disk pre-image (originalStateContent) and
3056
+ // the transformed content, table-driven applyStatePreservation, then
3057
+ // the #2736 authoritative re-assert (which restores the #3350
3058
+ // pairing override the preserve-always restore may have reverted).
3059
+ // resync=true is the lifecycle-transition posture (progress
3060
+ // recomputed from disk; only the preserve-when-unchanged deltas
3061
+ // apply). Fields the transition legitimately rewrote (Status, Phase,
3062
+ // Stopped At via completePhaseCore's #3374 continuity line) have
3063
+ // changed body sources, so their deltas do not fire.
3064
+ // ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
3065
+ // command reports what it preserved, following `cmdMilestoneComplete`'s
3066
+ // shape (milestone.cts) — the same composition, the same out-param,
3067
+ // the same visibility contract.
3068
+ const divergedFields = [];
3069
+ stateContent = syncAndPreserveStateMd(originalStateContent, stateContent, statePath, cwd, {
3070
+ resync: true,
3071
+ authoritativeFm,
3072
+ divergedFields,
3073
+ });
3074
+ for (const field of divergedFields) {
3075
+ preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
3076
+ }
2273
3077
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
3078
+ // #3685 / #3691: normalize both sides before comparing (same
3079
+ // transitionCore-regenerated-section artifact cmdMilestoneComplete
3080
+ // hit — see contentChangedAfterNormalize's doc). Reported "not
3081
+ // exposed" by a previous agent; the reviewer disproved that by
3082
+ // inspection and this branch closes it.
3083
+ stateUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(statePath, originalStateContent, stateContent);
2274
3084
  }
2275
- writePlanningFileSet(writes);
3085
+ anyPlanningWrite = writePlanningFileSet(writes) > 0;
2276
3086
  };
2277
3087
  if (node_fs_1.default.existsSync(statePath)) {
2278
3088
  withStateLock(statePath, runPhaseCompleteTransaction);
@@ -2280,6 +3090,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2280
3090
  else {
2281
3091
  runPhaseCompleteTransaction();
2282
3092
  }
3093
+ // #3311: a successful completion of the CLAIMED phase releases the
3094
+ // milestone claim — regardless of which session completes it (an
3095
+ // orchestrator cleaning up after a dead session must not be blocked by the
3096
+ // dead session's own claim). No-ops when the claim names another phase.
3097
+ milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
2283
3098
  return null;
2284
3099
  });
2285
3100
  if (verificationBlocked) {
@@ -2325,15 +3140,23 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2325
3140
  next_phase_name: nextPhaseName,
2326
3141
  is_last_phase: isLastPhase,
2327
3142
  date: today,
2328
- roadmap_updated: node_fs_1.default.existsSync(roadmapPath),
2329
- state_updated: node_fs_1.default.existsSync(statePath),
3143
+ roadmap_updated: roadmapUpdated,
3144
+ state_updated: stateUpdated,
2330
3145
  requirements_updated: requirementsUpdated,
2331
3146
  auto_pruned: autoPruned,
2332
3147
  warnings,
2333
3148
  has_warnings: warnings.length > 0,
2334
3149
  verification_stale_check_indeterminate: staleCheckIndeterminate,
3150
+ milestone_conflict: milestoneConflict,
3151
+ preservation_warnings: preservationWarnings,
2335
3152
  };
2336
3153
  output(result, raw);
3154
+ // #3227: gate on `anyPlanningWrite` (whether `writePlanningFileSet`
3155
+ // actually wrote anything), not on reaching this line — reaching here only
3156
+ // means verification passed and the transaction ran, not that ROADMAP.md
3157
+ // or STATE.md bytes changed (see the `anyPlanningWrite` declaration above).
3158
+ if (anyPlanningWrite)
3159
+ publishStateContract(cwd);
2337
3160
  }
2338
3161
  function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
2339
3162
  if (!phaseNum) {
@@ -2353,7 +3176,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
2353
3176
  // paths without re-discovering the phase directory themselves.
2354
3177
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
2355
3178
  const planScanMod = require("./plan-scan.cjs");
2356
- const { scanPhasePlans } = planScanMod;
3179
+ const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
2357
3180
  function cmdPhaseListPlans(cwd, phaseNum, raw) {
2358
3181
  if (!phaseNum) {
2359
3182
  error('phase number required for phase list-plans');
@@ -2390,4 +3213,5 @@ module.exports = {
2390
3213
  cmdPhaseUatPassed,
2391
3214
  cmdPhaseListPlans,
2392
3215
  computeDependencyLevels,
3216
+ buildShortFormToId,
2393
3217
  };