@opengsd/gsd-core 1.11.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 (395) 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 +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -21,9 +21,13 @@ 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;
@@ -34,7 +38,7 @@ const coreUtilsMod = require("./core-utils.cjs");
34
38
  // generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
35
39
  // copy, which was already the leaf owner, so there is no second surface left to
36
40
  // drift and no parity test needed to police one.
37
- const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
41
+ const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, normalizeLineEndings, } = coreUtilsMod;
38
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
39
43
  const phaseIdMod = require("./phase-id.cjs");
40
44
  const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
@@ -74,6 +78,9 @@ const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSu
74
78
  const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
75
79
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
76
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;
77
84
  const { extractFrontmatter } = frontmatterMod;
78
85
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
79
86
  // Any .md file with PLAN anywhere in the basename — diagnostic net
@@ -504,23 +511,73 @@ function cmdFindPhase(cwd, phase, raw) {
504
511
  }
505
512
  output(notFound, raw, '');
506
513
  }
507
- function extractObjective(content) {
508
- const m = content.match(/<objective>\s*\n?\s*(.+)/);
509
- return m ? m[1].trim() : null;
510
- }
511
514
  /**
512
515
  * Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
513
- * (case-folded exact match, falling back to canonical-id matching). Returns
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
514
518
  * `null` when the token does not resolve to any plan in this phase (a typo
515
519
  * or a cross-phase reference) — every call site treats that as "ignore this
516
520
  * edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
517
- * resolution, the `depends_on` display mapping, and (#2830) the
518
- * halt-propagation node resolution, so the three can never disagree about
519
- * which token resolves to which plan.
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.
520
531
  */
521
- function resolveDependencyId(dep, planMap, canonicalToId) {
532
+ function resolveDependencyId(dep, planMap, canonicalToId, shortFormToId) {
522
533
  const lower = dep.toLowerCase();
523
- 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;
524
581
  }
525
582
  // O(V + E). Assigns each in-phase plan its longest-path topological level over the
526
583
  // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
@@ -528,19 +585,32 @@ function resolveDependencyId(dep, planMap, canonicalToId) {
528
585
  // the exact dequeue order this pass already produces — a valid topological order — passed to
529
586
  // computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
530
587
  // algorithm a second time over the same graph.
531
- 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) {
532
593
  const level = new Map();
533
594
  const inDeg = new Map();
534
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 = [];
535
603
  for (const p of rawPlans) {
536
604
  if (!inDeg.has(p.id))
537
605
  inDeg.set(p.id, 0);
538
606
  if (!adj.has(p.id))
539
607
  adj.set(p.id, []);
540
608
  for (const dep of p.dependsOn) {
541
- const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
542
- if (!resolvedDep)
609
+ const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId, shortFormToId);
610
+ if (!resolvedDep) {
611
+ unresolved.push({ plan: p.id, token: String(dep) });
543
612
  continue;
613
+ }
544
614
  if (!adj.has(resolvedDep))
545
615
  adj.set(resolvedDep, []);
546
616
  adj.get(resolvedDep).push(p.id);
@@ -573,7 +643,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
573
643
  }
574
644
  }
575
645
  }
576
- return { level, visited, order: queue };
646
+ return { level, visited, order: queue, unresolved };
577
647
  }
578
648
  function cmdPhasePlanIndex(cwd, phase, raw) {
579
649
  if (!phase) {
@@ -677,46 +747,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
677
747
  // ── Pass 1: parse each plan file ─────────────────────────────────────────
678
748
  const rawPlans = [];
679
749
  for (const planFile of planFiles) {
680
- const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
750
+ const planId = planIdFromFile(planFile);
681
751
  const planPath = node_path_1.default.join(phaseDir, planFile);
682
752
  const content = node_fs_1.default.readFileSync(planPath, 'utf-8');
683
- // Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic.
684
- const fm = extractFrontmatter(content, planPath);
685
- const xmlTasks = content.match(/<task[\s>]/gi) || [];
686
- const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
687
- const taskCount = xmlTasks.length || mdTasks.length;
688
- const parsedWave = parseInt(fm['wave'], 10);
689
- const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
690
- let dependsOn = [];
691
- const fmDeps = fm['depends_on'];
692
- if (Array.isArray(fmDeps)) {
693
- dependsOn = fmDeps.map(String);
694
- }
695
- else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
696
- dependsOn = [fmDeps];
697
- }
698
- let autonomous = true;
699
- if (fm['autonomous'] !== undefined) {
700
- // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
701
- autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
702
- }
703
- let filesModified = [];
704
- const fmFiles = fm['files_modified'] || fm['files-modified'];
705
- if (fmFiles) {
706
- // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
707
- filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
708
- }
709
- // #1689: optional per-plan specialist executor hint. Read verbatim here; the
710
- // orchestrator resolves it against the active runtime's agent dir at dispatch
711
- // time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
712
- // gsd-executor when the field is unset or the named agent does not resolve.
713
- let agentHint = null;
714
- const fmAgentHint = fm['agent_hint'];
715
- if (fmAgentHint !== undefined) {
716
- // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
717
- const hintStr = String(fmAgentHint).trim();
718
- agentHint = hintStr !== '' ? hintStr : null;
719
- }
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);
720
758
  const hasSummary = !unsummarizedPlanFiles.has(planFile);
721
759
  // #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
722
760
  // a designed stop still writes a completion record, just one whose status
@@ -728,13 +766,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
728
766
  : false;
729
767
  rawPlans.push({
730
768
  id: planId,
731
- declaredWave,
732
- dependsOn,
733
- autonomous,
734
- objective: extractObjective(content) || fm['objective'] || null,
735
- filesModified,
736
- agentHint,
737
- 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,
738
777
  hasSummary,
739
778
  halted,
740
779
  });
@@ -752,7 +791,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
752
791
  }
753
792
  const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
754
793
  const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
755
- 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);
756
803
  if (visited < rawPlans.length) {
757
804
  const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
758
805
  error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
@@ -766,7 +813,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
766
813
  const haltNodes = rawPlans.map((p) => ({
767
814
  id: p.id,
768
815
  resolvedDependsOn: p.dependsOn
769
- .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
816
+ .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId, shortFormToId))
770
817
  .filter((id) => id !== null),
771
818
  halted: p.halted,
772
819
  }));
@@ -780,6 +827,17 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
780
827
  const runnable = [];
781
828
  let hasCheckpoints = false;
782
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
+ }
783
841
  for (const rawPlan of rawPlans) {
784
842
  if (!rawPlan.autonomous) {
785
843
  hasCheckpoints = true;
@@ -796,7 +854,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
796
854
  }
797
855
  const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
798
856
  const effectiveWave = computedWave;
799
- 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)) {
800
866
  warnings.push(`Plan ${rawPlan.id}: declared wave: ${rawPlan.declaredWave} but depends_on DAG places it in wave ${computedWave}`);
801
867
  }
802
868
  const plan = {
@@ -816,6 +882,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
816
882
  autonomous: rawPlan.autonomous,
817
883
  objective: rawPlan.objective,
818
884
  files_modified: rawPlan.filesModified,
885
+ files_deleted: rawPlan.filesDeleted,
819
886
  agent_hint: rawPlan.agentHint,
820
887
  task_count: rawPlan.taskCount,
821
888
  has_summary: rawPlan.hasSummary,
@@ -912,6 +979,75 @@ function assertDescriptionPreservesMilestoneScope(description, command) {
912
979
  `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
913
980
  `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
914
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
+ }
915
1051
  function cmdPhaseAdd(cwd, description, raw, customId) {
916
1052
  if (!description) {
917
1053
  error('description required for phase add');
@@ -980,6 +1116,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
980
1116
  // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
981
1117
  // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
982
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);
983
1122
  const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
984
1123
  _newPhaseId = maxUsed + 1;
985
1124
  const paddedNum = String(_newPhaseId).padStart(2, '0');
@@ -1009,6 +1148,18 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1009
1148
  if (titleWarning)
1010
1149
  result['warning'] = titleWarning;
1011
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);
1012
1163
  }
1013
1164
  function cmdPhaseAddBatch(cwd, descriptions, raw) {
1014
1165
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
@@ -1032,8 +1183,12 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1032
1183
  const content = extractCurrentMilestone(rawContent, cwd);
1033
1184
  let maxPhase = 0;
1034
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).
1035
1189
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1036
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;
1037
1192
  let m;
1038
1193
  while ((m = phasePattern.exec(content)) !== null) {
1039
1194
  const num = parseInt(m[1], 10);
@@ -1043,6 +1198,13 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1043
1198
  if (num > maxPhase)
1044
1199
  maxPhase = num;
1045
1200
  }
1201
+ while ((m = bulletPattern.exec(content)) !== null) {
1202
+ const num = parseInt(m[1], 10);
1203
+ if (isSentinelPhaseId(num))
1204
+ continue;
1205
+ if (num > maxPhase)
1206
+ maxPhase = num;
1207
+ }
1046
1208
  const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1047
1209
  if (node_fs_1.default.existsSync(phasesOnDisk)) {
1048
1210
  const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
@@ -1058,6 +1220,13 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1058
1220
  maxPhase = num;
1059
1221
  }
1060
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
+ }
1061
1230
  }
1062
1231
  const added = [];
1063
1232
  for (const description of descriptions) {
@@ -1095,6 +1264,11 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1095
1264
  return added;
1096
1265
  });
1097
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);
1098
1272
  }
1099
1273
  function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1100
1274
  if (!afterPhase || !description) {
@@ -1242,6 +1416,11 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1242
1416
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1243
1417
  };
1244
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);
1245
1424
  }
1246
1425
  function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1247
1426
  const renamedDirs = [];
@@ -1455,9 +1634,17 @@ function findDataRowLine(sectionText, dataRowIndex) {
1455
1634
  }
1456
1635
  return null;
1457
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`.
1458
1644
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
1459
- withPlanningLock(cwd, () => {
1460
- let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1645
+ return withPlanningLock(cwd, () => {
1646
+ const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1647
+ let content = originalContent;
1461
1648
  const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
1462
1649
  // #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
1463
1650
  // of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
@@ -1615,6 +1802,14 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1615
1802
  content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
1616
1803
  }
1617
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);
1618
1813
  });
1619
1814
  }
1620
1815
  /**
@@ -1718,7 +1913,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1718
1913
  const msg = e instanceof Error ? e.message : String(e);
1719
1914
  error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
1720
1915
  }
1721
- updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1916
+ const roadmapUpdated = updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1722
1917
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1723
1918
  let stateUpdated = false;
1724
1919
  if (node_fs_1.default.existsSync(statePath)) {
@@ -1791,10 +1986,27 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1791
1986
  renamed_directories: renamedDirs,
1792
1987
  renamed_files: renamedFiles,
1793
1988
  renamed_file_collisions: renamedFileCollisions,
1794
- roadmap_updated: true,
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,
1795
1994
  state_updated: stateUpdated,
1796
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);
1797
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
+ */
1798
2010
  function writePlanningFileSet(writes) {
1799
2011
  const applied = [];
1800
2012
  try {
@@ -1824,6 +2036,7 @@ function writePlanningFileSet(writes) {
1824
2036
  }
1825
2037
  throw err;
1826
2038
  }
2039
+ return applied.length;
1827
2040
  }
1828
2041
  function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1829
2042
  if (!roadmapContent || !phaseNum)
@@ -1890,6 +2103,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1890
2103
  ? phaseInfo['summaries'].length
1891
2104
  : 0;
1892
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;
1893
2112
  const warnings = [];
1894
2113
  // ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
1895
2114
  // composition's preservation stage restores a curated frontmatter value
@@ -1994,7 +2213,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1994
2213
  }
1995
2214
  for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
1996
2215
  const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
1997
- 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'));
1998
2222
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
1999
2223
  // from historical metadata in the file body (e.g. `previous_status: gaps_found`).
2000
2224
  // A full-text regex like /status: gaps_found/ matches the substring inside
@@ -2060,6 +2284,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2060
2284
  // warnings[] entry below (same parity pattern as
2061
2285
  // verification_stale_check_indeterminate).
2062
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;
2063
2295
  const verificationBlocked = withPlanningLock(cwd, () => {
2064
2296
  // #3311: completing a phase while a live milestone claim (phase + session)
2065
2297
  // holds a DIFFERENT phase means two sessions are working two phases against
@@ -2251,6 +2483,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2251
2483
  before: originalRoadmapContent,
2252
2484
  after: roadmapContent,
2253
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);
2254
2492
  const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
2255
2493
  if (node_fs_1.default.existsSync(reqPath)) {
2256
2494
  const phaseEsc = phaseMarkdownRegexSource(phaseNum);
@@ -2537,9 +2775,43 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2537
2775
  // diff-tracking pattern used for the ROADMAP write above. A phase
2538
2776
  // whose citations match nothing (ghost REQ-IDs only) must report
2539
2777
  // `false`, not a bare "the file was present" `true`.
2540
- 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);
2541
2783
  }
2542
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;
2543
2815
  try {
2544
2816
  // #3185 (ADR-3180 Decision 1): "which phase directories belong to
2545
2817
  // the CURRENT milestone" — routed through the canonical owner
@@ -2555,11 +2827,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2555
2827
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2556
2828
  if (isSentinelPhaseId(dm[1]))
2557
2829
  continue;
2558
- if (comparePhaseNum(dm[1], phaseNum) > 0) {
2559
- nextPhaseNum = dm[1];
2560
- nextPhaseName = dm[2] || null;
2561
- isLastPhase = false;
2562
- 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;
2563
2839
  }
2564
2840
  }
2565
2841
  }
@@ -2573,7 +2849,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2573
2849
  * derives the same information independently from ROADMAP.md content
2574
2850
  * — not a silent data-loss path. */
2575
2851
  }
2576
- if (isLastPhase && roadmapContent !== null) {
2852
+ if (roadmapContent !== null) {
2577
2853
  try {
2578
2854
  const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
2579
2855
  // #1591: match BOTH heading-style phases (`### Phase N:`) AND
@@ -2599,15 +2875,28 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2599
2875
  // stage 2's heading scan must not advance into backlog headings either.
2600
2876
  if (isSentinelPhaseId(pm[1]))
2601
2877
  continue;
2602
- if (comparePhaseNum(pm[1], phaseNum) > 0) {
2603
- nextPhaseNum = pm[1];
2604
- 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]
2605
2896
  .replace(/\(INSERTED\)/i, '')
2606
2897
  .trim()
2607
2898
  .toLowerCase()
2608
2899
  .replace(/\s+/g, '-');
2609
- isLastPhase = false;
2610
- break;
2611
2900
  }
2612
2901
  }
2613
2902
  }
@@ -2618,6 +2907,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2618
2907
  * regardless and provides a further, independent override. */
2619
2908
  }
2620
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
+ }
2621
2929
  // #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
2622
2930
  // still outstanding. The two blocks above only clear isLastPhase when a
2623
2931
  // HIGHER-numbered phase exists, so completing the numerically-highest phase
@@ -2767,8 +3075,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2767
3075
  preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
2768
3076
  }
2769
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);
2770
3084
  }
2771
- writePlanningFileSet(writes);
3085
+ anyPlanningWrite = writePlanningFileSet(writes) > 0;
2772
3086
  };
2773
3087
  if (node_fs_1.default.existsSync(statePath)) {
2774
3088
  withStateLock(statePath, runPhaseCompleteTransaction);
@@ -2826,8 +3140,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2826
3140
  next_phase_name: nextPhaseName,
2827
3141
  is_last_phase: isLastPhase,
2828
3142
  date: today,
2829
- roadmap_updated: node_fs_1.default.existsSync(roadmapPath),
2830
- state_updated: node_fs_1.default.existsSync(statePath),
3143
+ roadmap_updated: roadmapUpdated,
3144
+ state_updated: stateUpdated,
2831
3145
  requirements_updated: requirementsUpdated,
2832
3146
  auto_pruned: autoPruned,
2833
3147
  warnings,
@@ -2837,6 +3151,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2837
3151
  preservation_warnings: preservationWarnings,
2838
3152
  };
2839
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);
2840
3160
  }
2841
3161
  function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
2842
3162
  if (!phaseNum) {
@@ -2893,4 +3213,5 @@ module.exports = {
2893
3213
  cmdPhaseUatPassed,
2894
3214
  cmdPhaseListPlans,
2895
3215
  computeDependencyLevels,
3216
+ buildShortFormToId,
2896
3217
  };