@opengsd/gsd-core 1.11.0 → 1.13.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 (498) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  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 +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  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 +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. 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;
@@ -71,9 +75,14 @@ const { readVerificationStatus } = verificationMod;
71
75
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
72
76
  const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
73
77
  const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
74
- const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
78
+ // #612: `resolvePhaseIdConvention` selects the write-time milestone-scope
79
+ // guard's terminator vocabulary (see assertDescriptionPreservesMilestoneScope).
80
+ const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, resolvePhaseIdConvention, } = planningWorkspace;
75
81
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
76
82
  const milestoneLockMod = require("./milestone-lock.cjs");
83
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
84
+ const planDocumentMod = require("./plan-document.cjs");
85
+ const { parsePlanDocument, planIdFromFile } = planDocumentMod;
77
86
  const { extractFrontmatter } = frontmatterMod;
78
87
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
79
88
  // Any .md file with PLAN anywhere in the basename — diagnostic net
@@ -378,6 +387,57 @@ function cmdPhaseMvpMode(cwd, args, raw) {
378
387
  cli_flag_present: cliFlagPresent,
379
388
  }, raw);
380
389
  }
390
+ /**
391
+ * `phase.tdd-applicable <plan-file> [--cli-flag]` (#4273, Phase 1 of epic
392
+ * #4272) — resolves whether the TDD RED/GREEN/REFACTOR gate applies to a
393
+ * given plan, in strict precedence order: an explicit `--cli-flag` wins over
394
+ * the plan's own `type: tdd` frontmatter, which wins over any task in the
395
+ * plan carrying `tdd="true"` (the #4265 mixed-mode shape), which wins over
396
+ * the project-wide `workflow.tdd_mode` config default. Mirrors
397
+ * `cmdPhaseMvpMode`'s precedence-cascade shape immediately above.
398
+ */
399
+ function cmdPhaseTddApplicable(cwd, args, raw) {
400
+ const planPath = args[0];
401
+ if (!planPath) {
402
+ error('Usage: phase.tdd-applicable <plan-file> [--cli-flag]', ERROR_REASON.USAGE);
403
+ }
404
+ const resolvedPath = node_path_1.default.isAbsolute(planPath) ? planPath : node_path_1.default.join(cwd, planPath);
405
+ if (!node_fs_1.default.existsSync(resolvedPath)) {
406
+ error(`Plan file not found: ${planPath}`, ERROR_REASON.PHASE_NOT_FOUND);
407
+ }
408
+ const cliFlagPresent = args.includes('--cli-flag');
409
+ const content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
410
+ const doc = parsePlanDocument(content, resolvedPath);
411
+ const planType = doc.type;
412
+ const taskTddAttribute = doc.tasks.some((t) => t.tdd === 'true');
413
+ const config = loadConfig(cwd);
414
+ const configTddMode = Boolean(config.tdd_mode);
415
+ let applicable = false;
416
+ let source = 'none';
417
+ if (cliFlagPresent) {
418
+ applicable = true;
419
+ source = 'cli_flag';
420
+ }
421
+ else if (planType === 'tdd') {
422
+ applicable = true;
423
+ source = 'plan_frontmatter';
424
+ }
425
+ else if (taskTddAttribute) {
426
+ applicable = true;
427
+ source = 'task_attribute';
428
+ }
429
+ else if (configTddMode) {
430
+ applicable = true;
431
+ source = 'config';
432
+ }
433
+ output({
434
+ applicable,
435
+ source,
436
+ plan_type: planType,
437
+ config_tdd_mode: configTddMode,
438
+ cli_flag_present: cliFlagPresent,
439
+ }, raw);
440
+ }
381
441
  function cmdFindPhase(cwd, phase, raw) {
382
442
  if (!phase) {
383
443
  error('phase identifier required');
@@ -504,23 +564,73 @@ function cmdFindPhase(cwd, phase, raw) {
504
564
  }
505
565
  output(notFound, raw, '');
506
566
  }
507
- function extractObjective(content) {
508
- const m = content.match(/<objective>\s*\n?\s*(.+)/);
509
- return m ? m[1].trim() : null;
510
- }
511
567
  /**
512
568
  * 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
569
+ * (case-folded exact match, falling back to canonical-id matching, falling
570
+ * back to the in-phase short-form plan number — #3897 rung 4). Returns
514
571
  * `null` when the token does not resolve to any plan in this phase (a typo
515
572
  * or a cross-phase reference) — every call site treats that as "ignore this
516
573
  * 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.
574
+ * resolution and (#2830) the halt-propagation node resolution, so the two can
575
+ * never disagree about which token resolves to which plan. NOT used by the
576
+ * `depends_on` display mapping (#3785/N3) — that stays a passthrough by
577
+ * design; see the comment at its call site.
578
+ *
579
+ * `shortFormToId` (#3897 rung 4, ADR-3473 §8.9) is the third tier, consulted
580
+ * only when neither `planMap` nor `canonicalToId` resolves the token. It is
581
+ * optional so any caller that has not been threaded through yet (there are
582
+ * none left in this file) degrades to the pre-#3897 two-tier behavior rather
583
+ * than throwing on a missing argument.
520
584
  */
521
- function resolveDependencyId(dep, planMap, canonicalToId) {
585
+ function resolveDependencyId(dep, planMap, canonicalToId, shortFormToId) {
522
586
  const lower = dep.toLowerCase();
523
- return planMap.has(lower) ? planMap.get(lower).id : (canonicalToId.get(lower) ?? null);
587
+ if (planMap.has(lower))
588
+ return planMap.get(lower).id;
589
+ if (canonicalToId.has(lower))
590
+ return canonicalToId.get(lower);
591
+ return shortFormToId?.get(lower) ?? null;
592
+ }
593
+ // #3897 rung 4 (ADR-3473 §8.9) — builds the third depends_on resolution tier:
594
+ // a map from an in-phase BARE PLAN NUMBER (e.g. "01") to the plan id whose
595
+ // canonical id ends with that number. Recovered from the retired SDK lineage
596
+ // (sdk/src/query/phase.ts at 11918dcc3^) with ONE deliberate narrowing: the
597
+ // lost implementation indexed ANY trailing dash-segment of a canonical id,
598
+ // with no constraint that the segment be a plan NUMBER — so a phase
599
+ // containing both `09-FIX-auth-PLAN.md` and `09-GAP-auth-PLAN.md` (canonical
600
+ // id `09-FIX-auth`, trailing segment "auth") would silently bind
601
+ // `depends_on: ["auth"]` to whichever sorted first, fabricating a
602
+ // wave-affecting DAG edge with ZERO warning — a mis-resolved edge, which is
603
+ // worse than a dropped one (found in isolated correctness review, #3897).
604
+ // `docs/reference/plan-md.md` already documents this tier as resolving "the
605
+ // bare plan number", so requiring `/^\d+$/` on the trailing segment is a
606
+ // strict narrowing onto the tier's OWN documented contract, not a behavior
607
+ // change for any legitimate input. Do NOT restore the unconstrained
608
+ // lastDash-slice "to match the recovered original" — the original was wrong
609
+ // here; this rung deliberately departs from it in this one respect, and only
610
+ // this one. Everything else — the `lastDash` bound, first-write-wins,
611
+ // lowercasing — is kept exactly as recovered:
612
+ // - first write wins, deterministic because rawPlans is passed in sorted
613
+ // plan-file order (D4/T44) and this loop iterates in that same order;
614
+ // - a canonical id with no dash (`lastDash === -1` or `lastDash === 0`,
615
+ // e.g. "24" or "-01") or a trailing dash (`lastDash === canonical.length
616
+ // - 1`, e.g. "09-") is never indexed (D5).
617
+ // Exported so callers can build this map once and so tests assert against
618
+ // this REAL implementation rather than a hand-rolled copy that could
619
+ // silently disagree with it after a future change here (CLAUDE.md's
620
+ // generative-fix-divergence rule).
621
+ function buildShortFormToId(rawPlans) {
622
+ const shortFormToId = new Map();
623
+ for (const p of rawPlans) {
624
+ const canonical = extractCanonicalPlanId(p.id);
625
+ const lastDash = canonical.lastIndexOf('-');
626
+ if (lastDash > 0 && lastDash < canonical.length - 1) {
627
+ const shortForm = canonical.slice(lastDash + 1).toLowerCase();
628
+ if (/^\d+$/.test(shortForm) && !shortFormToId.has(shortForm)) {
629
+ shortFormToId.set(shortForm, p.id);
630
+ }
631
+ }
632
+ }
633
+ return shortFormToId;
524
634
  }
525
635
  // O(V + E). Assigns each in-phase plan its longest-path topological level over the
526
636
  // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
@@ -528,19 +638,32 @@ function resolveDependencyId(dep, planMap, canonicalToId) {
528
638
  // the exact dequeue order this pass already produces — a valid topological order — passed to
529
639
  // computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
530
640
  // algorithm a second time over the same graph.
531
- function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
641
+ //
642
+ // `shortFormToId` (#3897 rung 4, optional — see resolveDependencyId) is threaded through so a
643
+ // bare in-phase plan-number token (`depends_on: ["01"]`) resolves as a real DAG edge instead of
644
+ // being dropped and silently collapsing the dependent plan to wave 1 (D3).
645
+ function computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId) {
532
646
  const level = new Map();
533
647
  const inDeg = new Map();
534
648
  const adj = new Map();
649
+ // #3427 / ADR-3473 §8.5: a depends_on token that resolves via NONE of the
650
+ // three tiers (planMap, canonicalToId, shortFormToId) is a dropped edge.
651
+ // Naming it here (rather than silently `continue`-ing past it) lets
652
+ // cmdPhasePlanIndex surface the token's own warning instead of
653
+ // manufacturing a wave-mismatch verdict from the resulting damaged graph
654
+ // (#3427).
655
+ const unresolved = [];
535
656
  for (const p of rawPlans) {
536
657
  if (!inDeg.has(p.id))
537
658
  inDeg.set(p.id, 0);
538
659
  if (!adj.has(p.id))
539
660
  adj.set(p.id, []);
540
661
  for (const dep of p.dependsOn) {
541
- const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
542
- if (!resolvedDep)
662
+ const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId, shortFormToId);
663
+ if (!resolvedDep) {
664
+ unresolved.push({ plan: p.id, token: String(dep) });
543
665
  continue;
666
+ }
544
667
  if (!adj.has(resolvedDep))
545
668
  adj.set(resolvedDep, []);
546
669
  adj.get(resolvedDep).push(p.id);
@@ -573,7 +696,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
573
696
  }
574
697
  }
575
698
  }
576
- return { level, visited, order: queue };
699
+ return { level, visited, order: queue, unresolved };
577
700
  }
578
701
  function cmdPhasePlanIndex(cwd, phase, raw) {
579
702
  if (!phase) {
@@ -677,46 +800,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
677
800
  // ── Pass 1: parse each plan file ─────────────────────────────────────────
678
801
  const rawPlans = [];
679
802
  for (const planFile of planFiles) {
680
- const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
803
+ const planId = planIdFromFile(planFile);
681
804
  const planPath = node_path_1.default.join(phaseDir, planFile);
682
805
  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
- }
806
+ // #2790: plan-body parsing is owned by the shared Plan Document Module, so
807
+ // this command and the read-only `planning.inspect` query cannot drift on
808
+ // what a plan document says. planPath is still passed so a truncated
809
+ // PLAN.md names the file in the #1882 diagnostic.
810
+ const planDoc = parsePlanDocument(content, planPath);
720
811
  const hasSummary = !unsummarizedPlanFiles.has(planFile);
721
812
  // #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
722
813
  // a designed stop still writes a completion record, just one whose status
@@ -728,13 +819,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
728
819
  : false;
729
820
  rawPlans.push({
730
821
  id: planId,
731
- declaredWave,
732
- dependsOn,
733
- autonomous,
734
- objective: extractObjective(content) || fm['objective'] || null,
735
- filesModified,
736
- agentHint,
737
- taskCount,
822
+ declaredWave: planDoc.declaredWave,
823
+ dependsOn: planDoc.dependsOn,
824
+ autonomous: planDoc.autonomous,
825
+ objective: planDoc.objective,
826
+ filesModified: planDoc.filesModified,
827
+ filesDeleted: planDoc.filesDeleted,
828
+ agentHint: planDoc.agentHint,
829
+ taskCount: planDoc.taskCount,
738
830
  hasSummary,
739
831
  halted,
740
832
  });
@@ -752,7 +844,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
752
844
  }
753
845
  const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
754
846
  const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
755
- const { level, visited, order } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
847
+ // #3897 rung 4 (ADR-3473 §8.9) — the third depends_on resolution tier.
848
+ // Resolves a bare in-phase plan-number short form (e.g. "01") to its owning
849
+ // plan id. In-phase only by construction (T49): the map is built from THIS
850
+ // phase's rawPlans alone, so a short form colliding with a different
851
+ // phase's plan can never be a candidate. See {@link buildShortFormToId}'s
852
+ // own comment for the numeric-only narrowing this rung applies on top of
853
+ // the recovered SDK-lineage algorithm.
854
+ const shortFormToId = buildShortFormToId(rawPlans);
855
+ const { level, visited, order, unresolved } = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
756
856
  if (visited < rawPlans.length) {
757
857
  const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
758
858
  error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
@@ -766,7 +866,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
766
866
  const haltNodes = rawPlans.map((p) => ({
767
867
  id: p.id,
768
868
  resolvedDependsOn: p.dependsOn
769
- .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
869
+ .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId, shortFormToId))
770
870
  .filter((id) => id !== null),
771
871
  halted: p.halted,
772
872
  }));
@@ -780,6 +880,17 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
780
880
  const runnable = [];
781
881
  let hasCheckpoints = false;
782
882
  const warnings = [];
883
+ // #3427 / ADR-3473 §8.5: name every dropped depends_on edge (plan AND
884
+ // token) rather than letting it silently collapse the plan to a DAG root.
885
+ // A plan with at least one unresolved token gets ITS OWN warning here and
886
+ // the wave-mismatch verdict below is suppressed for that plan ONLY — a
887
+ // plan with no dropped edges and a genuinely wrong `wave:` still warns
888
+ // (N3, D6, T25).
889
+ const plansWithUnresolvedTokens = new Set();
890
+ for (const { plan, token } of unresolved) {
891
+ plansWithUnresolvedTokens.add(plan);
892
+ 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`);
893
+ }
783
894
  for (const rawPlan of rawPlans) {
784
895
  if (!rawPlan.autonomous) {
785
896
  hasCheckpoints = true;
@@ -796,7 +907,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
796
907
  }
797
908
  const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
798
909
  const effectiveWave = computedWave;
799
- if (rawPlan.declaredWave !== null && rawPlan.declaredWave !== computedWave) {
910
+ // #3427 (D5/N3): suppress the wave-mismatch verdict for a plan that has
911
+ // at least one unresolved depends_on token — its own dropped-edge
912
+ // warning above already explains the degraded wave placement, so the
913
+ // mismatch here would blame the author for a DAG the tool itself
914
+ // couldn't build. A plan with NO unresolved tokens still gets a genuine
915
+ // mismatch reported (N3, T25) — the suppression is per-plan, never blanket.
916
+ if (rawPlan.declaredWave !== null &&
917
+ rawPlan.declaredWave !== computedWave &&
918
+ !plansWithUnresolvedTokens.has(rawPlan.id)) {
800
919
  warnings.push(`Plan ${rawPlan.id}: declared wave: ${rawPlan.declaredWave} but depends_on DAG places it in wave ${computedWave}`);
801
920
  }
802
921
  const plan = {
@@ -816,6 +935,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
816
935
  autonomous: rawPlan.autonomous,
817
936
  objective: rawPlan.objective,
818
937
  files_modified: rawPlan.filesModified,
938
+ files_deleted: rawPlan.filesDeleted,
819
939
  agent_hint: rawPlan.agentHint,
820
940
  task_count: rawPlan.taskCount,
821
941
  has_summary: rawPlan.hasSummary,
@@ -901,22 +1021,111 @@ function phaseEntryInsertOffset(rawContent, cwd) {
901
1021
  * the edit-phase workflow's depends_on gate. The predicate itself
902
1022
  * (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
903
1023
  * so ordinary descriptions and the phase's own numbered heading never trip it.
1024
+ *
1025
+ * #612: the predicate is convention-SELECTED, because the terminator
1026
+ * vocabulary it mirrors is. On an opted-in bracket repo the ADR-canonical
1027
+ * `## [GSD.09] Hidden` carries none of the markers listed above and yet
1028
+ * terminates the window, so the blind call accepted the exact description the
1029
+ * guard exists to reject — measured at this CLI seam, two `phase add` calls,
1030
+ * the second phase silently outside the milestone phase set. Resolved through
1031
+ * the same tolerant shape the read path uses (`planningDir` throws on a
1032
+ * poisoned `GSD_PROJECT`/`GSD_WORKSTREAM` segment, and this guard runs BEFORE
1033
+ * `loadConfig` and the ROADMAP existence check — an unresolvable convention
1034
+ * must degrade to the pre-existing legacy vocabulary, never turn a rejection
1035
+ * into a crash).
904
1036
  */
905
- function assertDescriptionPreservesMilestoneScope(description, command) {
906
- const offending = findMilestoneScopeHeadingLines(description);
1037
+ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1038
+ let convention = null;
1039
+ try {
1040
+ convention = resolvePhaseIdConvention(cwd);
1041
+ }
1042
+ catch { /* unresolvable convention → treat as not-configured (base behaviour) */ }
1043
+ const offending = findMilestoneScopeHeadingLines(description, convention);
907
1044
  if (offending.length === 0)
908
1045
  return;
1046
+ const markerList = convention === 'bracket'
1047
+ ? `(a vN.N version token, a ✅/📋/🚧/🔄 marker, the word "Milestone", or — under the bracket convention — a "[CODE.NN] Name" milestone heading)`
1048
+ : `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone")`;
909
1049
  error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
910
1050
  `the current milestone window and silently drop later phases out of the milestone scope. ` +
911
1051
  `Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
912
1052
  `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
913
- `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
1053
+ markerList + `.`);
1054
+ }
1055
+ /**
1056
+ * #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
1057
+ * worktree carries its own `.planning/` on its own branch, so a phase minted
1058
+ * there is invisible to the cwd-scoped sources (headers, bullets, on-disk
1059
+ * dirs). Scan each sibling's phase-directory names (cheap — dir names alone
1060
+ * caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
1061
+ * before any directory does; milestone-scoping is wrong here because a number
1062
+ * used under any milestone on another branch is still taken).
1063
+ *
1064
+ * Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
1065
+ * non-git cwd, or an unavailable git binary each leave `used` untouched —
1066
+ * allocation then behaves exactly as it did before this horizon existed.
1067
+ * Sentinels reuse the canonical `isSentinelPhaseId`; the dir pattern is the
1068
+ * same one the on-disk scan uses, so decimal sub-phases (`411.1-foo`) are
1069
+ * correctly not integers.
1070
+ */
1071
+ function collectSiblingWorktreePhaseNums(cwd, used) {
1072
+ let porcelain;
1073
+ try {
1074
+ porcelain = (0, node_child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], {
1075
+ cwd,
1076
+ encoding: 'utf-8',
1077
+ // Same subprocess band as the other git call sites (smart-entry, check-command-router):
1078
+ // inside the 5-30s git window, hidden console window on Windows, bounded buffer.
1079
+ timeout: 10_000,
1080
+ windowsHide: true,
1081
+ maxBuffer: 4 * 1024 * 1024,
1082
+ });
1083
+ }
1084
+ catch {
1085
+ return; // not a git repo / git unavailable — unchanged behavior
1086
+ }
1087
+ const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1088
+ // Same header shape the allocators scan locally (#1729 tag tolerance).
1089
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
1090
+ for (const line of porcelain.split('\n')) {
1091
+ if (!line.startsWith('worktree '))
1092
+ continue;
1093
+ const wt = line.slice('worktree '.length).trim();
1094
+ if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
1095
+ continue;
1096
+ try {
1097
+ for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(wt, '.planning', 'phases'))) {
1098
+ const match = entry.match(dirNumPattern);
1099
+ if (!match)
1100
+ continue;
1101
+ const num = parseInt(match[1], 10);
1102
+ if (!isSentinelPhaseId(num))
1103
+ used.add(num);
1104
+ }
1105
+ }
1106
+ catch {
1107
+ /* worktree has no .planning — normal, contributes nothing */
1108
+ }
1109
+ try {
1110
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(wt, '.planning', 'ROADMAP.md'), 'utf-8');
1111
+ let m;
1112
+ headerPattern.lastIndex = 0;
1113
+ while ((m = headerPattern.exec(content)) !== null) {
1114
+ const num = parseInt(m[1], 10);
1115
+ if (!isSentinelPhaseId(num))
1116
+ used.add(num);
1117
+ }
1118
+ }
1119
+ catch {
1120
+ /* no roadmap in that worktree — normal, contributes nothing */
1121
+ }
1122
+ }
914
1123
  }
915
1124
  function cmdPhaseAdd(cwd, description, raw, customId) {
916
1125
  if (!description) {
917
1126
  error('description required for phase add');
918
1127
  }
919
- assertDescriptionPreservesMilestoneScope(description, 'phase add');
1128
+ assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add');
920
1129
  const config = loadConfig(cwd);
921
1130
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
922
1131
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -980,6 +1189,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
980
1189
  // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
981
1190
  // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
982
1191
  // an existing number.
1192
+ // 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
1193
+ // taken on another branch is still taken.
1194
+ collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
983
1195
  const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
984
1196
  _newPhaseId = maxUsed + 1;
985
1197
  const paddedNum = String(_newPhaseId).padStart(2, '0');
@@ -1009,6 +1221,18 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1009
1221
  if (titleWarning)
1010
1222
  result['warning'] = titleWarning;
1011
1223
  output(result, raw, result['padded']);
1224
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): every
1225
+ // `publishStateContract` call site in this file is audited so a refreshed
1226
+ // state.json `updated_at` always means something on disk actually moved —
1227
+ // a stale-but-refreshed timestamp is worse than no refresh, because it
1228
+ // reads as fresh to a downstream watcher. This site is unconditional
1229
+ // because every reachable path either exits via `error()` (process.exit,
1230
+ // never reaches here) or falls through to the unconditional
1231
+ // `platformEnsureDir`/`platformWriteSync` pair above that always creates
1232
+ // the phase directory and rewrites ROADMAP.md — there is no code path that
1233
+ // reaches this line without having just written to disk. Best-effort —
1234
+ // cannot throw, cannot change this command's exit code or output.
1235
+ publishStateContract(cwd);
1012
1236
  }
1013
1237
  function cmdPhaseAddBatch(cwd, descriptions, raw) {
1014
1238
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
@@ -1018,7 +1242,7 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1018
1242
  // all-or-nothing, so one offending description must reject the whole batch
1019
1243
  // with no ROADMAP write and no phase directories created.
1020
1244
  for (const description of descriptions) {
1021
- assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
1245
+ assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch');
1022
1246
  }
1023
1247
  const config = loadConfig(cwd);
1024
1248
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
@@ -1032,8 +1256,12 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1032
1256
  const content = extractCurrentMilestone(rawContent, cwd);
1033
1257
  let maxPhase = 0;
1034
1258
  if (config.phase_naming !== 'custom') {
1259
+ // Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
1260
+ // bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
1261
+ // `Phase N` row was invisible to batch allocation (#3849 secondary).
1035
1262
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1036
1263
  const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
1264
+ const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1037
1265
  let m;
1038
1266
  while ((m = phasePattern.exec(content)) !== null) {
1039
1267
  const num = parseInt(m[1], 10);
@@ -1043,6 +1271,13 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1043
1271
  if (num > maxPhase)
1044
1272
  maxPhase = num;
1045
1273
  }
1274
+ while ((m = bulletPattern.exec(content)) !== null) {
1275
+ const num = parseInt(m[1], 10);
1276
+ if (isSentinelPhaseId(num))
1277
+ continue;
1278
+ if (num > maxPhase)
1279
+ maxPhase = num;
1280
+ }
1046
1281
  const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1047
1282
  if (node_fs_1.default.existsSync(phasesOnDisk)) {
1048
1283
  const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
@@ -1058,6 +1293,13 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1058
1293
  maxPhase = num;
1059
1294
  }
1060
1295
  }
1296
+ // 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
1297
+ const siblingNums = new Set();
1298
+ collectSiblingWorktreePhaseNums(cwd, siblingNums);
1299
+ for (const num of siblingNums) {
1300
+ if (num > maxPhase)
1301
+ maxPhase = num;
1302
+ }
1061
1303
  }
1062
1304
  const added = [];
1063
1305
  for (const description of descriptions) {
@@ -1095,12 +1337,17 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1095
1337
  return added;
1096
1338
  });
1097
1339
  output({ phases: results, count: results.length }, raw);
1340
+ // #3227: unconditional here because `platformWriteSync(roadmapPath, rawContent)`
1341
+ // above always rewrites ROADMAP.md for every description in the batch before
1342
+ // this line is reached; the only refusal path is the `error('ROADMAP.md not
1343
+ // found')` above, which terminates the process and never reaches here.
1344
+ publishStateContract(cwd);
1098
1345
  }
1099
1346
  function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1100
1347
  if (!afterPhase || !description) {
1101
1348
  error('after-phase and description required for phase insert');
1102
1349
  }
1103
- assertDescriptionPreservesMilestoneScope(description, 'phase insert');
1350
+ assertDescriptionPreservesMilestoneScope(cwd, description, 'phase insert');
1104
1351
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1105
1352
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1106
1353
  error('ROADMAP.md not found');
@@ -1242,6 +1489,11 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1242
1489
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1243
1490
  };
1244
1491
  output(result, raw, decimalPhase);
1492
+ // #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
1493
+ // above always rewrites ROADMAP.md with the inserted phase before this line is
1494
+ // reached; every refusal along the way (bad args, missing ROADMAP.md, unresolved
1495
+ // target bullet/header) exits via `error()`, which terminates the process.
1496
+ publishStateContract(cwd);
1245
1497
  }
1246
1498
  function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1247
1499
  const renamedDirs = [];
@@ -1455,9 +1707,17 @@ function findDataRowLine(sectionText, dataRowIndex) {
1455
1707
  }
1456
1708
  return null;
1457
1709
  }
1710
+ // #3685: mirror requirementsUpdated's diff-tracking contract — the caller
1711
+ // (cmdPhaseRemove) used to report `roadmap_updated: true` unconditionally,
1712
+ // hardcoded regardless of whether this transform actually changed
1713
+ // ROADMAP.md's content. Returning a real before/after comparison here lets
1714
+ // the caller report accurately, the same fix #3685 applied to
1715
+ // `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
1716
+ // sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
1458
1717
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
1459
- withPlanningLock(cwd, () => {
1460
- let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1718
+ return withPlanningLock(cwd, () => {
1719
+ const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1720
+ let content = originalContent;
1461
1721
  const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
1462
1722
  // #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
1463
1723
  // of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
@@ -1615,6 +1875,14 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
1615
1875
  content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
1616
1876
  }
1617
1877
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
1878
+ // #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
1879
+ // persists), not the raw pre-normalize `content` string, against the raw
1880
+ // pre-mutation `originalContent` read above — a raw `!==` here reports a
1881
+ // false `true` whenever this transform's regenerated output takes a
1882
+ // different-but-equivalent shape than the already-normalized on-disk
1883
+ // original (same normalization-order artifact #3685 fixed at
1884
+ // cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
1885
+ return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
1618
1886
  });
1619
1887
  }
1620
1888
  /**
@@ -1718,7 +1986,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1718
1986
  const msg = e instanceof Error ? e.message : String(e);
1719
1987
  error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
1720
1988
  }
1721
- updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1989
+ const roadmapUpdated = updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1722
1990
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1723
1991
  let stateUpdated = false;
1724
1992
  if (node_fs_1.default.existsSync(statePath)) {
@@ -1791,10 +2059,27 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1791
2059
  renamed_directories: renamedDirs,
1792
2060
  renamed_files: renamedFiles,
1793
2061
  renamed_file_collisions: renamedFileCollisions,
1794
- roadmap_updated: true,
2062
+ // #3685: mirror requirementsUpdated's diff-tracking contract — true only
2063
+ // when updateRoadmapAfterPhaseRemoval's content diff detected a real
2064
+ // change, not hardcoded regardless of whether ROADMAP.md's content
2065
+ // actually changed.
2066
+ roadmap_updated: roadmapUpdated,
1795
2067
  state_updated: stateUpdated,
1796
2068
  }, raw);
2069
+ // #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
2070
+ // always rewrites ROADMAP.md before this line is reached; every refusal path
2071
+ // (bad target, missing ROADMAP.md, --force-required, renumber failure) exits
2072
+ // via `error()`, and the ambiguous-match case exits via an earlier `return`
2073
+ // before any file is touched.
2074
+ publishStateContract(cwd);
1797
2075
  }
2076
+ /**
2077
+ * #3227: returns the count of writes actually applied (entries whose
2078
+ * `before` differed from `after` and were therefore written to disk) — the
2079
+ * caller (`cmdPhaseComplete`) uses this as its publish-gate signal, since a
2080
+ * re-run against an already-completed phase can produce a `writes[]` array
2081
+ * where every entry is byte-identical to what's already on disk.
2082
+ */
1798
2083
  function writePlanningFileSet(writes) {
1799
2084
  const applied = [];
1800
2085
  try {
@@ -1824,6 +2109,7 @@ function writePlanningFileSet(writes) {
1824
2109
  }
1825
2110
  throw err;
1826
2111
  }
2112
+ return applied.length;
1827
2113
  }
1828
2114
  function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1829
2115
  if (!roadmapContent || !phaseNum)
@@ -1841,6 +2127,640 @@ function phaseDisplayNameFromSlug(slug) {
1841
2127
  const name = slug.replace(/-/g, ' ').trim();
1842
2128
  return name || null;
1843
2129
  }
2130
+ // A range operator, enumerated. CENSUS (round 3): the domain is "separator
2131
+ // spellings an author can put between two REQ-IDs", which is open, so the
2132
+ // enumeration draws a boundary rather than covering it. Reached: ASCII `..`+,
2133
+ // the seven Unicode dashes that are the SAME operator at different codepoints
2134
+ // (U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, U+2013 en,
2135
+ // U+2014 em, U+2015 horizontal bar, U+2212 minus) plus ASCII `-`, U+2026
2136
+ // ellipsis, and the words `to`/`thru`/`through`. NOT reached, and the
2137
+ // consequence is a silent under-selection — #3697's own defect — for that
2138
+ // spelling: `→`, `~`, `..=`, `..<`, `until`, and `up to` (two tokens, so it
2139
+ // cannot be one operator token at all). Those stay out deliberately: each is a
2140
+ // symbol or word with an independent non-range use between two IDs, which is
2141
+ // the over-warning class #2334 cost three rounds. The Unicode dashes DO carry
2142
+ // the ASCII hyphen's date/sub-number collision — an earlier round-3 commit
2143
+ // claimed they did not, and was wrong — so they take the strict arm with it;
2144
+ // see the rule below.
2145
+ const REQ_RANGE_DASHES = '\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212';
2146
+ // EVERY DASH IS STRICT — one rule, whatever the codepoint. `PREFIX-\d+ <dash>
2147
+ // \d+` is also a date (`FY-2026-08`) and a sub-numbered ID (`API-2-01`), and
2148
+ // that ambiguity is a property of the SHAPE, not of which dash key was pressed.
2149
+ // The design already chose strictness for ASCII `-` on exactly this trade: a
2150
+ // bare-hyphen tight range must carry a full ID on BOTH sides. Until round 3 the
2151
+ // other dashes sat in the loose arm, so `RANGE-01 (target FY-2026<en-dash>08)`
2152
+ // warned while its all-ASCII twin — pinned silent by #3697-4 — did not. That
2153
+ // inconsistency predates this PR for U+2013/U+2014; round 3 briefly widened it
2154
+ // to five more codepoints before this commit closed it for all seven.
2155
+ // The cost is symmetric and already accepted: `RANGE-01, RANGE-02<dash>05`
2156
+ // goes silent, exactly as `RANGE-01, RANGE-02-05` already does today. A bare
2157
+ // `RANGE-02<dash>05` still warns — it selects nothing, so R3 catches it.
2158
+ // LOOSE stays loose: `..`, `…` and the word operators have no date or
2159
+ // sub-number reading between two numbers, so they keep the numeric endpoint.
2160
+ const REQ_RANGE_OP = `(?:\\.{2,}|\\u2026|[${REQ_RANGE_DASHES}]|-|to|thru|through)`;
2161
+ const REQ_RANGE_OP_LOOSE = `(?:\\.{2,}|\\u2026|to|thru|through)`;
2162
+ const REQ_RANGE_OP_SYMBOL = `(?:\\.{2,}|\\u2026|[${REQ_RANGE_DASHES}]|-)`;
2163
+ const REQ_RANGE_TOKEN_RE = new RegExp(`^([A-Z][A-Z0-9]*)-(?:\\d+)\\s*(?:${REQ_RANGE_OP_LOOSE}\\s*(?:\\1-)?|[-${REQ_RANGE_DASHES}]\\s*\\1-)\\d+$`, 'i');
2164
+ const REQ_PURE_RANGE_OP_RE = new RegExp(`^${REQ_RANGE_OP}$`, 'i');
2165
+ const REQ_GLUED_RANGE_LEAD_RE = new RegExp(`^${REQ_RANGE_OP_SYMBOL}([A-Z][A-Z0-9]*-\\d+)$`, 'i');
2166
+ const REQ_GLUED_RANGE_TRAIL_RE = new RegExp(`^([A-Z][A-Z0-9]*-\\d+)${REQ_RANGE_OP}$`, 'i');
2167
+ const REQ_ID_SUBSTRING_RE = /[A-Z][A-Z0-9]*-\d+/i;
2168
+ const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
2169
+ const REQ_ID_PARTS_RE = /^([A-Z][A-Z0-9]*)-(\d+)$/i;
2170
+ // `LETTERS-\d+-\d+` — a date (`FY-2026-08`) or a sub-numbered ID (`API-2-01`).
2171
+ // REQ_RANGE_TOKEN_RE's strict-dash arm exists precisely to keep this shape
2172
+ // silent, because nothing at token level can tell the three readings apart.
2173
+ // Round 4 review Minor 2: the skipped-text rider re-reported it through the
2174
+ // side door — `REQ_ID_SUBSTRING_RE` is unanchored, so `FY-2026-08` matches as
2175
+ // `FY-2026` and landed in `unselectedIdShaped`. Whenever any OTHER rule fired
2176
+ // on a line carrying a date annotation, the warning then told the author to
2177
+ // "check whether any of it is a requirement" about a date. Not a false
2178
+ // warning — the line was warning anyway — but false CONTENT, and it is the
2179
+ // #2334 voice.
2180
+ // `PREFIX-<digits>-<digits>` — the shape the strict-dash range rule refuses to
2181
+ // act on because it is equally a date (`FY-2026-08`) and a sub-numbered id
2182
+ // (`API-2-01`). NO regex separates those: `API-2026-08` is a legal requirement
2183
+ // id and `FY-26-08` is a date, and both filters that tried scored a miss in
2184
+ // each direction under the pre-push review's continuation.
2185
+ //
2186
+ // So the rider stops adjudicating and starts DISCLOSING. Round 4 Minor 2's
2187
+ // real complaint was that the rider told the author to check whether a DATE
2188
+ // was a requirement; the fix is to name the ambiguity rather than to guess at
2189
+ // it — which is the same thing the two warning voices already do about a
2190
+ // range separator.
2191
+ const REQ_AMBIGUOUS_NUMERIC_RE = /^[A-Z][A-Z0-9]*(?:-\d+){2,}$/i;
2192
+ // The token-length cap. It bounds REQ_ID_SUBSTRING_RE, the one UNANCHORED
2193
+ // regex here, which backtracks quadratically on a pathological token. Round 3
2194
+ // review Nit 6 objected that the anchored regexes were left uncapped on the
2195
+ // strength of a comment asserting they scan linearly; they are applied through
2196
+ // the same cap now, so the claim is enforced rather than asserted. No real
2197
+ // REQ-ID-carrying token approaches this bound.
2198
+ const REQ_TOKEN_SCAN_LIMIT = 2048;
2199
+ /**
2200
+ * The Requirements-line warning KINDS, as a stable machine vocabulary (round 4
2201
+ * review Major 3).
2202
+ *
2203
+ * Before this, the kind existed only in the prose of the message, so every
2204
+ * consumer and every test had to regex an English sentence — and rewording a
2205
+ * message silently un-asserted the tests that pinned it. The repo already had
2206
+ * the settled seam for exactly these semantics: `diffLiveConfig` emits
2207
+ * `kind:'unverified'` for a truncated scan (`CONTEXT.md`), and
2208
+ * `WAVE_CLEANUP_WARNING` carries codes in `src/worktree-safety.cts`.
2209
+ *
2210
+ * Carried ALONGSIDE the prose, never instead of it. `warnings[]` is a
2211
+ * documented `string[]` in `phase complete`'s JSON output, rendered by
2212
+ * execute-phase.md's "If has_warnings is true" step, so changing its element
2213
+ * shape would be a breaking output-contract change for a shipped command. The
2214
+ * code is emitted as its own additive `requirements_line_warning` field.
2215
+ */
2216
+ const REQ_LINE_WARNING_CODE = {
2217
+ /** ID-shaped content was demonstrably not selected — the line failed to parse. */
2218
+ misparse: 'req-line-misparse',
2219
+ /** A range READING is at stake; every endpoint the rule fired on was selected. */
2220
+ rangeReading: 'req-line-range-reading',
2221
+ /** A token past the scan cap means the line was not classified — never that it is clean. */
2222
+ unverified: 'req-line-unverified',
2223
+ };
2224
+ // R4 — a full ID with a trailing statement delimiter glued to it. ANCHORED on
2225
+ // both ends, so it is linear and needs no cap of its own beyond the token
2226
+ // length guard its caller applies.
2227
+ // Zero-width, bidi-control, joiner and variation-selector codepoints. INVISIBLE
2228
+ // to the author, and the pre-push review's continuation drove the consequence
2229
+ // from both sides: a line of only these warned with nothing on screen to
2230
+ // explain it, AND stripping them wholesale from the detector made
2231
+ // `REQ-01<ZWSP>, REQ-02` go SILENT while the selector really did drop REQ-01 —
2232
+ // #3697's own defect, introduced by the fix for its mirror image. So they are
2233
+ // never stripped from the line: they are DECORATION on a token (R4 below) and
2234
+ // absence-of-content for the empty test (visibleContent), which are two
2235
+ // different questions about the same character.
2236
+ const REQ_INVISIBLE_RE = /[\u00AD\u200B-\u200F\u2060-\u2064\u2066-\u2069\uFE0F\uFEFF]/g;
2237
+ // The wrappers R4 shaves. Emphasis, quotes and backticks, because the SELECTOR
2238
+ // shaves none of them — `**REQ-01**` is genuinely not selected and is a real,
2239
+ // silent drop.
2240
+ //
2241
+ // PARENTHESES ARE DELIBERATELY ABSENT, and this is load-bearing. A parenthesis
2242
+ // is this rule's citation MARKER, not decoration to shave: `(REQ-02)` and
2243
+ // `(ADR-7)` are the same shape and the rule declines both. Including them here
2244
+ // made `REQ-01, (REQ-02), REQ-03 — REQ-05` report a glued delimiter that was
2245
+ // never there, and broke #3697-9d's channel routing with it — caught by the
2246
+ // suite immediately after the widening.
2247
+ const REQ_WRAPPER_RE = /^["'`*_~“”‘’]+|["'`*_~“”‘’]+$/g;
2248
+ // An id with a list delimiter glued to EITHER end, once styling is removed.
2249
+ // The capture is the bare id; a match means the delimiter was ADJACENT to it.
2250
+ const REQ_DELIMITED_ID_RE = /^[;:]*([A-Z][A-Z0-9]*-\d+)[;:]*$/i;
2251
+ /**
2252
+ * CENSUS (round 4): the domain is "separators an author writes between two
2253
+ * REQ-IDs INSTEAD of a comma" — distinct from the range-operator domain
2254
+ * censused above, and it had no census at all before this round.
2255
+ *
2256
+ * ROUND 4'S CENSUS WAS WRONG, AND THE WAY IT WAS WRONG IS THE LESSON. It swept
2257
+ * 26 spellings and concluded "exactly two — `; ` and `: `". It reached that
2258
+ * answer because it swept the ONE-SIDED form (`REQ-01; REQ-02`) for the
2259
+ * semicolon and colon, and only the BARE and SYMMETRIC forms (`|`, ` | `) for
2260
+ * every other separator. Different members of the domain were tested in
2261
+ * different shapes, so the conclusion could not have come out any other way.
2262
+ *
2263
+ * Re-swept round 5, fully crossed: 21 separators x {bare, trailing-space,
2264
+ * leading-space, both-spaces} = 84 combinations, driven through the built
2265
+ * artifact. 26 select both IDs, 24 under-select and already warn, and
2266
+ * 34 UNDER-SELECT SILENTLY. All 34 are the same shape — a separator glued to
2267
+ * exactly ONE of the two IDs, e.g. `REQ-01/ REQ-02` or `REQ-01 /REQ-02` — for
2268
+ * every punctuation except `,` (the real delimiter) and `;` / `:` (R4).
2269
+ * Measured silent: | / + & \ > . ! ? • · ؛ ; , - ~ and the word operators
2270
+ * `and` / `plus` in trailing-space form.
2271
+ *
2272
+ * So the honest statement is that R4 covers TWO CHARACTERS of a domain that is
2273
+ * wide open, not that the domain has two members. The round-4 review
2274
+ * hand-listed the semicolon; the colon is its sibling and fails identically;
2275
+ * everything else in that list is disclosed here and NOT caught. Widening the
2276
+ * delimiter class is a small change and deliberately not made at the end of a
2277
+ * round: three successive cuts of this rule fired on a citation.
2278
+ *
2279
+ * THE GATE IS ADJACENCY, and it is the part to read. Styling is stripped, then
2280
+ * the delimiter must be touching the id: `REQ-01;`, `;REQ-02`, `**REQ-01;**`
2281
+ * and the backticked form all qualify. `**REQ-01**;` does NOT — outside the
2282
+ * styling a `;` is sentence punctuation, which is why `REQ-01, see **REQ-7**;
2283
+ * next topic` is a citation and not a drop. An INVISIBLE anywhere in the token
2284
+ * qualifies without an adjacency test, because nobody types one on purpose, so
2285
+ * it is corruption rather than intent.
2286
+ *
2287
+ * Markdown styling on its own is NOT a trigger and NOT reported. It reaches
2288
+ * the skipped-text rider, which names the id without asserting a drop — but a
2289
+ * rider only exists inside a MESSAGE, and a message only exists when some rule
2290
+ * set `warn`. On a line where nothing else fires, `REQ-01, **REQ-02**` is
2291
+ * wholly silent. Saying it is "left to the rider" reads as coverage and is
2292
+ * not; #3697-19m pins the silence so this comment cannot drift back.
2293
+ *
2294
+ * NOT reached, stated rather than fixed, and the second member is WIDER than
2295
+ * this comment first claimed:
2296
+ * - anything inside a parenthetical. A parenthesis is this rule's citation
2297
+ * MARKER, never decoration to shave — `(REQ-02)` and `(ADR-7)` are the
2298
+ * same shape and the rule declines both.
2299
+ * - a decorated id whose prefix is on NO selected id: `REQ-01, FOO-02: x`
2300
+ * stays silent even when FOO-02 is real. Prefix agreement is what
2301
+ * separates a drop from a bare citation — `REQ-01, see ADR-7: section 3`
2302
+ * carries `ADR-7:` in exactly `REQ-01;`'s shape — and it is the module's
2303
+ * own idiom, not a new heuristic (reqEndpointsImplyInterior already
2304
+ * requires an agreeing prefix). The gate is NOT complete: a citation that
2305
+ * DOES share a selected prefix (`ADR-01, see ADR-7: sec 3`) still fires,
2306
+ * and nothing at token level separates that from a real drop. Saying so is
2307
+ * the honest position; a prose heuristic on "see" is exactly the free-text
2308
+ * detector this module exists to avoid.
2309
+ * The trade, plainly: an under-report on a rare shape over an over-report on a
2310
+ * common one — the same call the strict-dash rule makes.
2311
+ */
2312
+ function reqDelimiterDroppedIds(rawLine, selected, cap) {
2313
+ // MATCHED parenthetical spans are removed OUTRIGHT, not tracked as a depth.
2314
+ //
2315
+ // Two bugs died here. A running depth counter let an unbalanced `(` stay open
2316
+ // to end-of-line and swallow every real drop after it. Promoting a whole
2317
+ // token to immune because it CONTAINED a matched character then leaked the
2318
+ // other way: `REQ-01, REQ-02;(note) REQ-03` is one whitespace token, so the
2319
+ // parenthetical conferred immunity on the `REQ-02;` sitting outside it.
2320
+ // Deleting the span states what is actually meant — for this rule a citation
2321
+ // is not on the line — while an UNMATCHED paren is a typo and confers
2322
+ // nothing.
2323
+ //
2324
+ // Square brackets go too, exactly as the SELECTOR strips them: `[REQ-01;
2325
+ // REQ-02]` is the documented form and was silently dropping REQ-01.
2326
+ //
2327
+ // INVISIBLES STAY. They are the evidence this rule reads; the tokenizer
2328
+ // strips them for the classification rules, and the two sites answer two
2329
+ // different questions about the same character.
2330
+ const chars = [...String(rawLine).replace(/<!--[\s\S]*?-->/g, ' ')];
2331
+ const openStack = [];
2332
+ for (let i = 0; i < chars.length; i += 1) {
2333
+ if (chars[i] === '(')
2334
+ openStack.push(i);
2335
+ else if (chars[i] === ')' && openStack.length > 0) {
2336
+ const open = openStack.pop();
2337
+ for (let j = open; j <= i; j += 1)
2338
+ chars[j] = ' ';
2339
+ }
2340
+ }
2341
+ const line = chars.join('').replace(/[[\]]/g, '');
2342
+ // The prefixes actually SELECTED on this line. A dropped id must agree with
2343
+ // one of them — that is what separates a delimiter typo from a citation,
2344
+ // since `REQ-01, see ADR-7: sec 3` carries `ADR-7:` in exactly `REQ-01;`'s
2345
+ // shape. Same-prefix agreement is the module's own idiom, not a new
2346
+ // heuristic (see reqEndpointsImplyInterior).
2347
+ const selectedPrefixes = new Set();
2348
+ for (const id of selected) {
2349
+ const m = REQ_ID_PARTS_RE.exec(id);
2350
+ if (m)
2351
+ selectedPrefixes.add(m[1].toUpperCase());
2352
+ }
2353
+ const hits = [];
2354
+ for (const raw of line.split(/[,\s]+/)) {
2355
+ if (!raw || raw.length > cap)
2356
+ continue;
2357
+ // Strip STYLING only. What survives is the id plus whatever was glued
2358
+ // directly to it.
2359
+ const core = raw.replace(REQ_INVISIBLE_RE, '').replace(REQ_WRAPPER_RE, '');
2360
+ const m = REQ_DELIMITED_ID_RE.exec(core);
2361
+ if (!m)
2362
+ continue;
2363
+ const bare = m[1];
2364
+ // ADJACENCY IS THE WHOLE RULE. A `;`/`:` touching the id is a list
2365
+ // separator someone meant; the same character OUTSIDE the styling is
2366
+ // sentence punctuation — `see **REQ-7**; next topic` cites a requirement
2367
+ // while `**REQ-01;** REQ-02` fails to list one, and only the delimiter's
2368
+ // POSITION separates them. An INVISIBLE needs no adjacency test: nobody
2369
+ // types one on purpose, so anywhere in the token it is corruption rather
2370
+ // than intent.
2371
+ const hadAdjacentDelimiter = core !== bare;
2372
+ REQ_INVISIBLE_RE.lastIndex = 0;
2373
+ const hadInvisible = REQ_INVISIBLE_RE.test(raw);
2374
+ REQ_INVISIBLE_RE.lastIndex = 0;
2375
+ if (!hadAdjacentDelimiter && !hadInvisible)
2376
+ continue;
2377
+ if (selected.has(bare.toUpperCase()))
2378
+ continue;
2379
+ const parts = REQ_ID_PARTS_RE.exec(bare);
2380
+ if (parts && selectedPrefixes.has(parts[1].toUpperCase()))
2381
+ hits.push(bare);
2382
+ }
2383
+ return [...new Set(hits)];
2384
+ }
2385
+ /** Endpoints imply a dropped interior only on an AGREEING prefix and a gap > 1. */
2386
+ function reqEndpointsImplyInterior(a, b) {
2387
+ const ma = REQ_ID_PARTS_RE.exec(a);
2388
+ const mb = REQ_ID_PARTS_RE.exec(b);
2389
+ if (!ma || !mb)
2390
+ return false;
2391
+ if (ma[1].toUpperCase() !== mb[1].toUpperCase())
2392
+ return false;
2393
+ // BigInt keeps the gap exact for numbers past 2^53.
2394
+ const gap = BigInt(mb[2]) - BigInt(ma[2]);
2395
+ return gap > 1n || gap < -1n;
2396
+ }
2397
+ function analyzeRequirementsLine(rawLine) {
2398
+ const line = typeof rawLine === 'string' ? rawLine : '';
2399
+ // SELECTOR — byte-identical to the pre-extraction expression.
2400
+ const citedReqIds = line
2401
+ .replace(/[\[\]]/g, '')
2402
+ .split(/[,\s]+/)
2403
+ .map((r) => r.trim())
2404
+ .filter(Boolean)
2405
+ .filter((r) => REQ_ID_SHAPE_RE.test(r));
2406
+ // DETECTOR tokenization. A token with NO alphanumerics is shaved of brackets
2407
+ // ONLY, so `(..)` surfaces its operator while a bare `..` is not shaved to
2408
+ // nothing by the punctuation classes. A trailing run of 2+ dots is a glued
2409
+ // range operator (`REQ-01.. REQ-05`), not sentence punctuation — keep it.
2410
+ const tokens = line
2411
+ .replace(/<!--[\s\S]*?-->/g, ' ')
2412
+ // Invisibles are removed HERE, for the classification rules — an operator
2413
+ // spelled `<ZWSP>..<ZWSP>` is still the range operator, and a line of only
2414
+ // invisibles yields no tokens at all. R4 works on the RAW line and does
2415
+ // NOT strip them, because there they are the evidence of a dropped id.
2416
+ // Removing them in both places is what made `REQ-01<ZWSP>, REQ-02` silent;
2417
+ // removing them in neither is what made `REQ-01 <ZWSP>..<ZWSP> REQ-05`
2418
+ // silent. The two questions have two different answers.
2419
+ .replace(REQ_INVISIBLE_RE, '')
2420
+ .split(/[,\s]+/)
2421
+ .map((t) => {
2422
+ const trimmed = t.trim();
2423
+ if (!/[A-Za-z0-9]/.test(trimmed)) {
2424
+ return trimmed.replace(/^[[({]+/, '').replace(/[\])}]+$/, '');
2425
+ }
2426
+ if (/\.{2,}$/.test(trimmed)) {
2427
+ return trimmed.replace(/^[[({"'`*_~“”‘’]+/, '');
2428
+ }
2429
+ return trimmed.replace(/^[[({"'`*_~“”‘’]+/, '').replace(/[\])}.;:"'`*_~“”‘’]+$/, '');
2430
+ })
2431
+ .filter(Boolean);
2432
+ // Every predicate below is applied through the scan limit (Nit 6): a token
2433
+ // past the bound is not classified at all rather than classified expensively.
2434
+ const short = (t) => t.length <= REQ_TOKEN_SCAN_LIMIT;
2435
+ const rangeTokens = tokens.filter((t) => short(t) && REQ_RANGE_TOKEN_RE.test(t));
2436
+ const spacedRangePairs = [];
2437
+ tokens.forEach((t, i) => {
2438
+ const left = tokens[i - 1] ?? '';
2439
+ const right = tokens[i + 1] ?? '';
2440
+ if (
2441
+ // EVERY participant is capped, not just the operator. Capping the operator
2442
+ // alone left `<2049-char ID> .. <2049-char ID>` running REQ_ID_SHAPE_RE and
2443
+ // BigInt over both neighbours unbounded — the cap read as uniform and was
2444
+ // not (found by the round's pre-push review).
2445
+ short(t) &&
2446
+ short(left) &&
2447
+ short(right) &&
2448
+ REQ_PURE_RANGE_OP_RE.test(t) &&
2449
+ i > 0 &&
2450
+ i < tokens.length - 1 &&
2451
+ REQ_ID_SHAPE_RE.test(left) &&
2452
+ REQ_ID_SHAPE_RE.test(right) &&
2453
+ reqEndpointsImplyInterior(left, right)) {
2454
+ spacedRangePairs.push([left, right]);
2455
+ }
2456
+ });
2457
+ const hasSpacedRange = spacedRangePairs.length > 0;
2458
+ // A half-spaced range splits at the tokenizer, so R1's own `\s*` never sees
2459
+ // it. SYMBOL operators only on the LEAD arm: a word operator glued to an ID
2460
+ // is an ID — `TORANGE-05` is a valid prefix-agnostic REQ-ID. The TRAIL arm
2461
+ // keeps the word operators, because a valid ID must end in digits, so
2462
+ // `REQ-01through` can only be a glued typo.
2463
+ const hasGluedRangeFragment = tokens.some((t, i) => {
2464
+ // Neighbours capped for the same reason as R2 above.
2465
+ if (!short(t))
2466
+ return false;
2467
+ const before = tokens[i - 1] ?? '';
2468
+ const after = tokens[i + 1] ?? '';
2469
+ const lead = REQ_GLUED_RANGE_LEAD_RE.exec(t);
2470
+ if (lead &&
2471
+ i > 0 &&
2472
+ short(before) &&
2473
+ REQ_ID_SHAPE_RE.test(before) &&
2474
+ reqEndpointsImplyInterior(before, lead[1])) {
2475
+ return true;
2476
+ }
2477
+ const trail = REQ_GLUED_RANGE_TRAIL_RE.exec(t);
2478
+ return Boolean(trail &&
2479
+ i < tokens.length - 1 &&
2480
+ short(after) &&
2481
+ REQ_ID_SHAPE_RE.test(after) &&
2482
+ reqEndpointsImplyInterior(trail[1], after));
2483
+ });
2484
+ const leadToken = (tokens[0] ?? '').toUpperCase();
2485
+ // CENSUS (round 3, review finding Minor 4): the placeholder domain is what
2486
+ // GSD itself seeds plus what an author writes for "deliberately empty".
2487
+ // Reached: `TBD` — the ONLY machine-written seed, at the three phase.add /
2488
+ // -batch / -insert sites — and `None`, the author convention. NOT reached:
2489
+ // `N/A`, `Deferred`, `Pending`, `TBA`, `-`. Consequence, and it is now
2490
+ // ENFORCED rather than asserted: such a line selects zero IDs and warns
2491
+ // through R3b below, which is what #3697's acceptance criterion asks for
2492
+ // ("when it selects zero IDs from a line that is non-empty and is not the
2493
+ // `TBD` placeholder"). Round 3 shipped this same paragraph while R3's
2494
+ // ID-shape gate made it false for all five words — bare `Deferred` was
2495
+ // silent, `Deferred (see ADR-7)` warned — and the claim sat in three
2496
+ // artifacts with no test in either direction. Inferring placeholder-ness
2497
+ // from arbitrary prose is still the free-text heuristic this detector
2498
+ // avoids: R3b keys on the SELECTION being empty, never on what the prose
2499
+ // means.
2500
+ const placeholderLed = leadToken === 'TBD' || leadToken === 'NONE';
2501
+ const inertIdShaped = citedReqIds.length === 0 && !placeholderLed
2502
+ ? tokens.filter((t) => short(t) && t.includes('-') && REQ_ID_SUBSTRING_RE.test(t))
2503
+ : [];
2504
+ // R3b — the acceptance criterion's own narrow form. `tokens.length > 0` is
2505
+ // what keeps an empty line and a comment-only line silent: the tokenizer
2506
+ // strips `<!-- ... -->` before splitting, so `<!-- fill in -->` yields no
2507
+ // tokens and cannot reach this rule. Every other zero-selection,
2508
+ // non-placeholder line warns.
2509
+ const zeroSelectionInert = citedReqIds.length === 0 && !placeholderLed && tokens.length > 0;
2510
+ // R2 is the ONLY ambiguous rule — a tight range, a glued fragment and R3
2511
+ // residue each implicate ID-shaped text the selector demonstrably did not
2512
+ // take, so any of them means the line really did fail to parse. R2 is
2513
+ // ambiguous only when its OWN endpoints were selected: the detector shaves
2514
+ // brackets and the selector does not, so R2 can fire on a `(RANGE-02)` that
2515
+ // was never selected — a real drop, and the assertive channel is right there.
2516
+ // A token past the cap is NOT classified — and must therefore not be
2517
+ // silently discarded. Round 3's first cut of the uniform cap did exactly
2518
+ // that: a 2049-char range token warned before the round and went silent
2519
+ // after it, which is #3697's own defect introduced by the fix for a nit
2520
+ // (found by the round's pre-push review). The cap bounds the WORK, not the
2521
+ // warning — so an over-cap token that could carry an ID is reported as
2522
+ // unclassified. The test is `includes('-')`, a linear scan, never the
2523
+ // unanchored regex the cap exists to keep off these tokens.
2524
+ // ANY over-cap token, not just one carrying `-`. The first cut filtered on
2525
+ // `includes('-')` and therefore missed an over-cap OPERATOR:
2526
+ // `REQ-01 <2049 dots> REQ-05` warned before this round (R2 was uncapped) and
2527
+ // went silent after it. A token we could not examine makes the line
2528
+ // unverified whatever characters it happens to contain. Computed below,
2529
+ // where the selected set is available.
2530
+ // ID-shaped tokens the selector did not take, ANYWHERE on the line. This is
2531
+ // reported as a fact, never used to pick the channel: `(ADR-7)` and
2532
+ // `(REQ-02)` are indistinguishable by shape, so routing on it would put the
2533
+ // false "could not be parsed" claim back on a line carrying a citation.
2534
+ // Naming them lets the author see what the tokenizer skipped without the
2535
+ // warning asserting a verdict it cannot support in either direction.
2536
+ const selected = new Set(citedReqIds.map((id) => id.toUpperCase()));
2537
+ // A token the SELECTOR took has had its own SELECTION verified — the selector
2538
+ // is uncapped and anchored, so it examined the whole token. That is not the
2539
+ // same as "no rule was suppressed by it", and conflating the two was the
2540
+ // second continuation review's CLAIM J/K: two over-cap valid IDs either side
2541
+ // of `..` are both selected, both exempted, and R2 is capped — so a line that
2542
+ // warned before this round went silent, which is the very regression the
2543
+ // field exists to close, arriving through the fix for its own over-report.
2544
+ //
2545
+ // The exemption therefore applies only when nothing could have been
2546
+ // suppressed: an over-cap token that was selected AND has no neighbour that
2547
+ // could pair with it into a range. Everything else is unexaminable and is
2548
+ // reported as such.
2549
+ const couldPairIntoRange = (i) => {
2550
+ for (const n of [tokens[i - 1], tokens[i + 1]]) {
2551
+ if (n === undefined)
2552
+ continue;
2553
+ if (!short(n))
2554
+ return true;
2555
+ if (REQ_PURE_RANGE_OP_RE.test(n))
2556
+ return true;
2557
+ if (REQ_GLUED_RANGE_LEAD_RE.test(n) || REQ_GLUED_RANGE_TRAIL_RE.test(n))
2558
+ return true;
2559
+ }
2560
+ return false;
2561
+ };
2562
+ const oversizedTokens = tokens.filter((t, i) => !short(t) && (!selected.has(t.toUpperCase()) || couldPairIntoRange(i)));
2563
+ const unselectedIdShaped = tokens.filter((t) => short(t) && REQ_ID_SUBSTRING_RE.test(t) && !selected.has(t.toUpperCase()));
2564
+ // R4 runs on the RAW line, not on `tokens`: the shave that makes `REQ-01;`
2565
+ // look like a clean `REQ-01` is exactly the evidence this rule needs, so it
2566
+ // has to see the character the tokenizer removed.
2567
+ const delimiterDroppedIds = reqDelimiterDroppedIds(rawLine, selected, REQ_TOKEN_SCAN_LIMIT);
2568
+ const nothingDemonstrablyDropped = rangeTokens.length === 0 &&
2569
+ !hasGluedRangeFragment &&
2570
+ inertIdShaped.length === 0 &&
2571
+ // R4 is a DEMONSTRATED drop, so neither non-assertive voice — one claiming
2572
+ // nothing was dropped, the other that nothing could be checked — may speak
2573
+ // for a line carrying one.
2574
+ delimiterDroppedIds.length === 0 &&
2575
+ // R2 firing on an endpoint the selector did NOT take is itself a
2576
+ // demonstrated drop, and the assertive channel is right there. Vacuously
2577
+ // true when no spaced range fired, which is what makes this a strict
2578
+ // superset of the `!hasSpacedRange` guard the over-cap channel used to
2579
+ // carry — that channel's behaviour on a line with no spaced range is
2580
+ // unchanged, byte for byte.
2581
+ spacedRangePairs.every(([a, b]) => selected.has(a.toUpperCase()) && selected.has(b.toUpperCase()));
2582
+ const rangeReadingOnly = hasSpacedRange &&
2583
+ nothingDemonstrablyDropped &&
2584
+ // The cap bounds the WORK, never the warning. An over-cap token is not
2585
+ // classified by ANY rule (R1-R4 all skip it), so the voice whose entire
2586
+ // claim is that nothing was dropped has no basis to speak for this line.
2587
+ // It falls to the over-cap channel below instead — `unverified`, because
2588
+ // the line was not CHECKED; not `misparse`, because nothing on it
2589
+ // demonstrably failed to parse either. Round 7 review, Minor 1.
2590
+ oversizedTokens.length === 0;
2591
+ // Named rather than inlined into the return literal (round 3 review Minor 3):
2592
+ // this disjunction is the module's single most important predicate, and in
2593
+ // the literal a later edit that reordered a local below the `return` would be
2594
+ // a TDZ ReferenceError at runtime rather than an error at the reader's eye
2595
+ // level. R3b joins it here — see its field docs above for why it is not
2596
+ // gated on ID shape.
2597
+ const warn = rangeTokens.length > 0 ||
2598
+ hasSpacedRange ||
2599
+ hasGluedRangeFragment ||
2600
+ inertIdShaped.length > 0 ||
2601
+ zeroSelectionInert ||
2602
+ delimiterDroppedIds.length > 0 ||
2603
+ oversizedTokens.length > 0;
2604
+ return {
2605
+ citedReqIds,
2606
+ tokens,
2607
+ rangeTokens,
2608
+ hasSpacedRange,
2609
+ hasGluedRangeFragment,
2610
+ inertIdShaped,
2611
+ zeroSelectionInert,
2612
+ placeholderLed,
2613
+ spacedRangePairs,
2614
+ nothingDemonstrablyDropped,
2615
+ rangeReadingOnly,
2616
+ delimiterDroppedIds,
2617
+ oversizedTokens,
2618
+ unselectedIdShaped,
2619
+ warn,
2620
+ };
2621
+ }
2622
+ /**
2623
+ * Render the warning, or null when the line is clean.
2624
+ *
2625
+ * TWO CHANNELS, and the split is round 3's fix for review finding Major 3. The
2626
+ * detector cannot distinguish `RANGE-02 — RANGE-05` meaning a range from the
2627
+ * same text meaning an annotation separator; they are textually identical and
2628
+ * no token-level rule separates them. What the old single-channel message did
2629
+ * was resolve that ambiguity by ASSERTION — it told the author the line "could
2630
+ * not be parsed" and to rewrite it, on a line where every ID present had in
2631
+ * fact been selected and nothing had been dropped. That is a false statement
2632
+ * under the annotation reading and the #2334 over-warning class.
2633
+ *
2634
+ * Going silent instead is not available: the range reading is equally live, and
2635
+ * staying quiet on it re-opens the exact silent under-selection #3697 is about.
2636
+ * So the ambiguity is DISCLOSED rather than decided —
2637
+ *
2638
+ * * any rule other than R2 fired, or R2 fired on an endpoint that was not
2639
+ * selected → something ID-shaped was demonstrably NOT taken. The line did
2640
+ * fail to parse; say so plainly, as before.
2641
+ * * R2 alone fired and both its endpoints were selected → nothing was
2642
+ * dropped. State both readings and let the author pick; never claim a parse
2643
+ * failure that did not occur.
2644
+ */
2645
+ function formatRequirementsLineWarning(phaseNum, rawLine, analysis) {
2646
+ if (!analysis.warn)
2647
+ return null;
2648
+ const shown = String(rawLine).trim();
2649
+ const rangeRuleFired = analysis.rangeTokens.length > 0 || analysis.hasSpacedRange || analysis.hasGluedRangeFragment;
2650
+ // Tokens the selector skipped, stated as a fact in EITHER channel. `(ADR-7)`
2651
+ // and `(REQ-02)` are the same shape, so no rule can say which one matters —
2652
+ // but the author can, and only if the warning tells them. Round 3's first
2653
+ // cut instead let this drive the channel, which put the false "could not be
2654
+ // parsed" claim back on a line carrying a citation.
2655
+ // Names only what the rule-specific clauses did NOT already name, so the
2656
+ // assertive voice can carry it too without repeating itself.
2657
+ const alreadyNamed = new Set([...analysis.rangeTokens, ...analysis.inertIdShaped, ...analysis.delimiterDroppedIds].map((t) => t.toUpperCase()));
2658
+ const skippedNames = analysis.unselectedIdShaped.filter((t) => !alreadyNamed.has(t.toUpperCase()));
2659
+ // Named, then qualified. The `PREFIX-N-N` shape is the one the range rules
2660
+ // deliberately decline to act on, so the rider says WHY it might not be a
2661
+ // requirement instead of silently deciding it is not.
2662
+ const ambiguousNamed = skippedNames.filter((t) => REQ_AMBIGUOUS_NUMERIC_RE.test(t));
2663
+ const skipped = skippedNames.length > 0
2664
+ ? ` ID-shaped text on the line that was NOT selected: ${skippedNames.join(', ')}` +
2665
+ ` (parentheses are not stripped, unlike square brackets) — check whether any of it is a` +
2666
+ ` requirement.` +
2667
+ (ambiguousNamed.length > 0
2668
+ ? ` ${ambiguousNamed.join(', ')} may equally be a date or a sub-numbered id, which is` +
2669
+ ` why the range rules do not act on that shape.`
2670
+ : '')
2671
+ : '';
2672
+ // R4's clause. Named separately from the generic skipped-text rider because
2673
+ // this one is not a "check whether any of it is a requirement" hedge — the
2674
+ // token IS an ID, the selector demonstrably did not take it, and the cause
2675
+ // is nameable.
2676
+ const delimiterDropped = analysis.delimiterDroppedIds.length > 0
2677
+ ? ` ${analysis.delimiterDroppedIds.join(', ')} ${analysis.delimiterDroppedIds.length === 1 ? 'was' : 'were'}` +
2678
+ ` NOT selected: a \`;\` or \`:\` is glued to the ID, or it carries an invisible character, and` +
2679
+ ` the line is split on commas and whitespace only. Write each requirement as a bare ID` +
2680
+ ` separated by a comma.`
2681
+ : '';
2682
+ const oversized = analysis.oversizedTokens.length > 0
2683
+ ? ` One or more tokens exceed the ${REQ_TOKEN_SCAN_LIMIT}-character scan limit and were NOT` +
2684
+ ` classified, so this line may carry more than is reported here.`
2685
+ : '';
2686
+ if (analysis.rangeReadingOnly) {
2687
+ // AMBIGUOUS channel — the RANGE reading is what is at stake, not a parse
2688
+ // failure: every endpoint the range rule fired on was selected.
2689
+ //
2690
+ // What this voice must NOT do is claim the whole LINE is correct. It has
2691
+ // no basis for that: an unrelated `(REQ-02)` elsewhere on the line is
2692
+ // dropped by the selector and invisible to every rule, so "nothing needs
2693
+ // to change" is an affirmative false statement on exactly the input the
2694
+ // rule-scoped discriminator was built to reach. It speaks about the
2695
+ // SEPARATOR, and defers the rest to the skipped-text clause above.
2696
+ return {
2697
+ code: REQ_LINE_WARNING_CODE.rangeReading,
2698
+ message: `ROADMAP Phase ${phaseNum} **Requirements** line (\`${shown}\`) contains what reads as a range ` +
2699
+ `between two cited REQ-IDs. Range forms are not expanded, so no interior IDs were selected; ` +
2700
+ `the line selected: ${analysis.citedReqIds.join(', ')}. If a range was intended, rewrite it ` +
2701
+ `naming every requirement explicitly (e.g. \`REQ-01, REQ-02, REQ-03\`); if that separator is ` +
2702
+ `an annotation rather than a range, it selected nothing to expand and needs no change.` +
2703
+ delimiterDropped +
2704
+ skipped +
2705
+ oversized,
2706
+ };
2707
+ }
2708
+ if (analysis.oversizedTokens.length > 0 && analysis.nothingDemonstrablyDropped) {
2709
+ // A DEMONSTRATED drop outranks this voice, whose whole claim is that
2710
+ // NOTHING could be checked — both cannot be true at once. `REQ-01,
2711
+ // REQ-02: <over-cap token>` names REQ-02 in `delimiterDroppedIds` and
2712
+ // then reported `req-line-unverified`, whose message never mentions it:
2713
+ // the concrete, actionable finding masked by the token beside it. That
2714
+ // exclusion now lives in `nothingDemonstrablyDropped`, shared verbatim
2715
+ // with `rangeReadingOnly` above rather than duplicated here — the
2716
+ // duplication is what let the two drift (round 7 review, Minor 1). The
2717
+ // assertive channel already appends the over-cap rider, so routing a
2718
+ // demonstrated drop there loses nothing about the cap.
2719
+ // OVER-CAP channel — no rule could run, so no rule may be diagnosed. Say
2720
+ // exactly that: the line was not classified, rather than not a problem.
2721
+ return {
2722
+ code: REQ_LINE_WARNING_CODE.unverified,
2723
+ message: `ROADMAP Phase ${phaseNum} **Requirements** line (\`${shown.slice(0, 200)}…\`) could not be ` +
2724
+ `checked: one or more tokens exceed the ${REQ_TOKEN_SCAN_LIMIT}-character scan limit, so the ` +
2725
+ `REQ-ID selection on this line is unverified. Rewrite it as a comma-separated list ` +
2726
+ `(e.g. \`REQ-01, REQ-02, REQ-03\`).`,
2727
+ };
2728
+ }
2729
+ // ASSERTIVE channel — ID-shaped content was demonstrably not selected.
2730
+ // Deliberately says "selected", NOT "marked complete": a range whose
2731
+ // endpoints are themselves unregistered selects them and marks nothing, and a
2732
+ // warning that overclaims the write is a warning the reader learns to
2733
+ // distrust.
2734
+ const selectedDesc = analysis.citedReqIds.length > 0
2735
+ ? `the only REQ-ID(s) selected from it were: ${analysis.citedReqIds.join(', ')}`
2736
+ : 'it selected NO REQ-IDs at all, so nothing was marked';
2737
+ const unparsed = [...new Set([...analysis.rangeTokens, ...analysis.inertIdShaped])];
2738
+ // Only diagnose "range" when a range rule actually fired — an R3 warning on
2739
+ // non-range ID text must not claim one was written. And on the R3 path the
2740
+ // residue is ID-SHAPED TEXT, which is not the same claim as "a requirement we
2741
+ // failed to parse" (round 3 review finding Minor 4: `Deferred (see ADR-7)`
2742
+ // reported `ADR-7` as missed requirement content when it is a citation). Name
2743
+ // what it is, and name the placeholder escape the author actually has.
2744
+ const advice = rangeRuleFired
2745
+ ? ' Range forms are not expanded; rewrite the line naming every requirement explicitly ' +
2746
+ '(e.g. `REQ-01, REQ-02, REQ-03`).'
2747
+ : ' If these are requirements, name them explicitly (e.g. `REQ-01, REQ-02, REQ-03`); if the line ' +
2748
+ 'is deliberately empty, write `TBD` or `None` — any other wording selects nothing and warns.';
2749
+ return {
2750
+ code: REQ_LINE_WARNING_CODE.misparse,
2751
+ message: `ROADMAP Phase ${phaseNum} **Requirements** line could not be parsed as a comma-separated REQ-ID list ` +
2752
+ `(\`${shown}\`) - ${selectedDesc}.` +
2753
+ (unparsed.length > 0
2754
+ ? rangeRuleFired
2755
+ ? ` Unparsed text: ${unparsed.join(', ')}.`
2756
+ : ` ID-shaped text that was not selected: ${unparsed.join(', ')}.`
2757
+ : '') +
2758
+ advice +
2759
+ delimiterDropped +
2760
+ skipped +
2761
+ oversized,
2762
+ };
2763
+ }
1844
2764
  function cmdPhaseComplete(cwd, phaseNum, raw) {
1845
2765
  if (!phaseNum) {
1846
2766
  error('phase number required for phase complete');
@@ -1890,7 +2810,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1890
2810
  ? phaseInfo['summaries'].length
1891
2811
  : 0;
1892
2812
  let requirementsUpdated = false;
2813
+ // #3685: mirror requirementsUpdated's diff-tracking contract at the
2814
+ // writes.push({filePath, before, after}) sites below, rather than
2815
+ // reporting via fs.existsSync (which is true whenever the file merely
2816
+ // exists, not when the transaction actually wrote a change).
2817
+ let roadmapUpdated = false;
2818
+ let stateUpdated = false;
1893
2819
  const warnings = [];
2820
+ // The machine kind of the Requirements-line warning, carried out to the JSON
2821
+ // result as its own field (round 4 review Major 3). Declared HERE, in the
2822
+ // same scope as `warnings[]`, because the assignment happens inside
2823
+ // withPlanningLock and the emission happens after it.
2824
+ let reqLineWarningCode;
1894
2825
  // ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
1895
2826
  // composition's preservation stage restores a curated frontmatter value
1896
2827
  // over a disagreeing derived one, that divergence is surfaced here rather
@@ -1994,7 +2925,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1994
2925
  }
1995
2926
  for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
1996
2927
  const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
1997
- const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
2928
+ // #3707-CR follow-up MINOR: normalize line endings at this read boundary
2929
+ // (same fix as src/verification.cts's readVerificationStatus) so a
2930
+ // lone-CR VERIFICATION.md's `---\r...\r---` frontmatter fence still
2931
+ // matches extractFrontmatter's byte-0 check instead of silently
2932
+ // dropping the human_needed/gaps_found advisory warning below.
2933
+ const content = normalizeLineEndings(node_fs_1.default.readFileSync(verificationFilePath, 'utf-8'));
1998
2934
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
1999
2935
  // from historical metadata in the file body (e.g. `previous_status: gaps_found`).
2000
2936
  // A full-text regex like /status: gaps_found/ matches the substring inside
@@ -2060,6 +2996,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2060
2996
  // warnings[] entry below (same parity pattern as
2061
2997
  // verification_stale_check_indeterminate).
2062
2998
  let milestoneConflict = null;
2999
+ // #3227: set inside `runPhaseCompleteTransaction` below from
3000
+ // `writePlanningFileSet`'s applied-count return — the transaction always
3001
+ // RUNS (verification passed, the lock was taken, `writes[]` was built),
3002
+ // but a re-run against a phase whose ROADMAP/STATE bytes already reflect
3003
+ // completion produces a `writes[]` where every entry is byte-identical to
3004
+ // disk, so `writePlanningFileSet` applies none of them. That must not
3005
+ // still refresh state.json's `updated_at` (design doc §40 row 26).
3006
+ let anyPlanningWrite = false;
2063
3007
  const verificationBlocked = withPlanningLock(cwd, () => {
2064
3008
  // #3311: completing a phase while a live milestone claim (phase + session)
2065
3009
  // holds a DIFFERENT phase means two sessions are working two phases against
@@ -2251,6 +3195,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2251
3195
  before: originalRoadmapContent,
2252
3196
  after: roadmapContent,
2253
3197
  });
3198
+ // #3685 / #3691: normalize both sides before comparing — see
3199
+ // contentChangedAfterNormalize's doc (shell-command-projection.cts).
3200
+ // A raw `!==` here false-positives whenever this phase-complete
3201
+ // roadmap mutation regenerates a section in a different-but-
3202
+ // equivalent raw shape than the already-normalized on-disk original.
3203
+ roadmapUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalRoadmapContent, roadmapContent);
2254
3204
  const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
2255
3205
  if (node_fs_1.default.existsSync(reqPath)) {
2256
3206
  const phaseEsc = phaseMarkdownRegexSource(phaseNum);
@@ -2275,27 +3225,22 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2275
3225
  // `else`, discarding this fact silently instead of surfacing it.
2276
3226
  const traceabilityWriteMisses = [];
2277
3227
  if (reqMatch) {
2278
- // #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
2279
- // the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
2280
- // and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
2281
- // so the ghost-ID / unregistered comparisons stay shape-symmetric.
2282
- // Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
2283
- // the ID list into a "cited REQ-ID": the shipped
2284
- // `templates/roadmap.md:32` line
2285
- // `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
2286
- // warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
2287
- // `**Requirements:** None` warned to register the literal word
2288
- // `None`. This subsumes the `TBD` placeholder special-case (`TBD`
2289
- // does not match the REQ-ID shape either); `isPlaceholderReqId` is
2290
- // kept below as a defensive no-op for any caller that still hands
2291
- // it a raw token.
2292
- const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
2293
- citedReqIds = reqMatch[1]
2294
- .replace(/[\[\]]/g, '')
2295
- .split(/[,\s]+/)
2296
- .map((r) => r.trim())
2297
- .filter(Boolean)
2298
- .filter((r) => REQ_ID_SHAPE_RE.test(r));
3228
+ // #2334 HIGH 3 + #3697: selection and under-selection detection both
3229
+ // live in `analyzeRequirementsLine` (module scope, above), extracted in
3230
+ // round 3 so the parser is directly testable — a closure in here is
3231
+ // reachable only by spawning the CLI, which no fast-check property test
3232
+ // can do. `citedReqIds` is byte-identical to the expression that stood
3233
+ // here; nothing about what phase-complete MARKS has changed.
3234
+ const reqLineAnalysis = analyzeRequirementsLine(reqMatch[1]);
3235
+ citedReqIds = reqLineAnalysis.citedReqIds;
3236
+ const reqLineWarning = formatRequirementsLineWarning(phaseNum, reqMatch[1], reqLineAnalysis);
3237
+ if (reqLineWarning) {
3238
+ warnings.push(reqLineWarning.message);
3239
+ // Carried out to the JSON result as its own field — see
3240
+ // REQ_LINE_WARNING_CODE for why it is not folded into
3241
+ // `warnings[]`.
3242
+ reqLineWarningCode = reqLineWarning.code;
3243
+ }
2299
3244
  for (const reqId of citedReqIds) {
2300
3245
  const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
2301
3246
  // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
@@ -2537,9 +3482,43 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2537
3482
  // diff-tracking pattern used for the ROADMAP write above. A phase
2538
3483
  // whose citations match nothing (ghost REQ-IDs only) must report
2539
3484
  // `false`, not a bare "the file was present" `true`.
2540
- requirementsUpdated = reqContent !== originalReqContent;
3485
+ // #3685 / #3691: normalize both sides before comparing — same
3486
+ // false-positive shape as the sibling roadmapUpdated/stateUpdated
3487
+ // flags in this same transaction; all three must agree by
3488
+ // construction (see contentChangedAfterNormalize's doc).
3489
+ requirementsUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(reqPath, originalReqContent, reqContent);
2541
3490
  }
2542
3491
  }
3492
+ // #3701 — the ROADMAP decides WHICH phase is next; the disk decides only HOW it
3493
+ // is spelled. Both scans select the numerically lowest phase above N.
3494
+ //
3495
+ // Both scans below are unchanged in what they match; what changed is that
3496
+ // the roadmap is no longer gated behind "the disk found nothing". It used
3497
+ // to be (`if (isLastPhase && roadmapContent !== null)`), which made a wrong
3498
+ // disk answer uncorrectable: phase directories are created lazily, but
3499
+ // `phase insert` scaffolds an inserted phase's directory immediately, so an
3500
+ // inserted decimal is routinely the ONLY directory above N and outranked
3501
+ // every phase preceding it in the roadmap. Observed: roadmap `1, 2, 02.1,
3502
+ // 3` with directories for 01 and 02.1 only reported `next_phase: "02.1"`
3503
+ // after completing 1 — and PERSISTED it to STATE.md — while
3504
+ // `roadmap.analyze` correctly said `2`.
3505
+ //
3506
+ // #3581 fixed exactly this at `init.progress` and named the rule: "the
3507
+ // frontier is ROADMAP ORDER, not artifact presence". This call site was not
3508
+ // in that change's scope.
3509
+ //
3510
+ // Why the disk scan survives, rather than being replaced:
3511
+ // 1. It is the only resolver when there is no ROADMAP.md, or when its
3512
+ // phase rows do not parse.
3513
+ // 2. When both agree, it carries the SPELLING the output has always used
3514
+ // — the zero-padded directory token and the on-disk slug (`02`/`beta`),
3515
+ // where the roadmap would give `2` and a slugified title. Promoting the
3516
+ // roadmap without this would silently change the reported value on
3517
+ // every aligned project, which is the majority case.
3518
+ let diskNextNum = null;
3519
+ let diskNextName = null;
3520
+ let roadmapNextNum = null;
3521
+ let roadmapNextName = null;
2543
3522
  try {
2544
3523
  // #3185 (ADR-3180 Decision 1): "which phase directories belong to
2545
3524
  // the CURRENT milestone" — routed through the canonical owner
@@ -2555,11 +3534,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2555
3534
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2556
3535
  if (isSentinelPhaseId(dm[1]))
2557
3536
  continue;
2558
- if (comparePhaseNum(dm[1], phaseNum) > 0) {
2559
- nextPhaseNum = dm[1];
2560
- nextPhaseName = dm[2] || null;
2561
- isLastPhase = false;
2562
- break;
3537
+ // Numeric MINIMUM above N, not "first encountered". `listMilestonePhaseDirs`
3538
+ // does sort by `comparePhaseNum`, so a `break` on the first hit happens to be
3539
+ // correct today — but that makes this scan's correctness depend on an
3540
+ // upstream sort nothing here states. Selecting the minimum explicitly costs
3541
+ // one comparison and removes the hidden coupling.
3542
+ if (comparePhaseNum(dm[1], phaseNum) > 0
3543
+ && (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
3544
+ diskNextNum = dm[1];
3545
+ diskNextName = dm[2] || null;
2563
3546
  }
2564
3547
  }
2565
3548
  }
@@ -2573,7 +3556,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2573
3556
  * derives the same information independently from ROADMAP.md content
2574
3557
  * — not a silent data-loss path. */
2575
3558
  }
2576
- if (isLastPhase && roadmapContent !== null) {
3559
+ if (roadmapContent !== null) {
2577
3560
  try {
2578
3561
  const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
2579
3562
  // #1591: match BOTH heading-style phases (`### Phase N:`) AND
@@ -2591,23 +3574,55 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2591
3574
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
2592
3575
  // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
2593
3576
  // `### Phase N (Cluster B): X` resolves. Captures are unchanged.
2594
- const phasePattern = new RegExp(`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
3577
+ //
3578
+ // #4078: the checkbox branch's separator is no longer colon-only. The
3579
+ // canonical phase lookup has accepted the bullet-house dash grammar
3580
+ // (`- [ ] **Phase N — Name**`, em/en-dash/hyphen/colon) since #2199
3581
+ // (`BULLET_PHASE_LINE_PATTERN`, roadmap-parser.cjs), but this scan still
3582
+ // required `:`, so on a roadmap whose original rows use the dash grammar
3583
+ // the ONLY parseable row above N was typically a later phase.add-ingested
3584
+ // colon-form phase — positionally last — and it won the numeric-minimum
3585
+ // vote it should never have been alone in (observed: 18 of 18 selected,
3586
+ // phases 2–17 skipped). The heading branch stays colon-only, mirroring
3587
+ // `findRoadmapPhaseInContent`'s heading grammar exactly; only the
3588
+ // checkbox branch widens, and only to the separators #2199 already
3589
+ // accepts. The two branches keep separate capture groups, normalized
3590
+ // just below the loop.
3591
+ const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
3592
+ `|-\\s*\\[[ xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gi');
2595
3593
  let pm;
2596
3594
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
3595
+ // #4078: normalize the two alternation branches' captures (heading
3596
+ // branch → groups 1/2, widened checkbox branch → groups 3/4).
3597
+ const pmNum = pm[1] ?? pm[3];
3598
+ const pmName = pm[2] ?? pm[4];
2597
3599
  // #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
2598
3600
  // already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
2599
3601
  // stage 2's heading scan must not advance into backlog headings either.
2600
- if (isSentinelPhaseId(pm[1]))
3602
+ if (isSentinelPhaseId(pmNum))
2601
3603
  continue;
2602
- if (comparePhaseNum(pm[1], phaseNum) > 0) {
2603
- nextPhaseNum = pm[1];
2604
- nextPhaseName = pm[2]
3604
+ // #3701 review: the numeric MINIMUM above N, not the first row above N in
3605
+ // DOCUMENT order. This scan walks raw roadmap text, and one global regex
3606
+ // sweeps both the `## Phases` checklist and the `## Phase Details`
3607
+ // headings, so "first match" is a statement about where a line sits in the
3608
+ // file — not about which phase comes next.
3609
+ //
3610
+ // It mattered only once this scan started deciding the answer. Before, it
3611
+ // ran solely when the disk scan found nothing; now it outranks the disk, so
3612
+ // a roadmap listing rows out of numeric sequence (`1, 3, 2`) reported
3613
+ // `next_phase: 3` and PERSISTED it, skipping Phase 2 — on an input the
3614
+ // pre-#3701 code got right, because the disk scan is numerically sorted.
3615
+ // Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
3616
+ // the disk scan and for #2028's lowest-outstanding override; the roadmap
3617
+ // defines which phases EXIST and which milestone they belong to.
3618
+ if (comparePhaseNum(pmNum, phaseNum) > 0
3619
+ && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
3620
+ roadmapNextNum = pmNum;
3621
+ roadmapNextName = pmName
2605
3622
  .replace(/\(INSERTED\)/i, '')
2606
3623
  .trim()
2607
3624
  .toLowerCase()
2608
3625
  .replace(/\s+/g, '-');
2609
- isLastPhase = false;
2610
- break;
2611
3626
  }
2612
3627
  }
2613
3628
  }
@@ -2618,6 +3633,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2618
3633
  * regardless and provides a further, independent override. */
2619
3634
  }
2620
3635
  }
3636
+ // Resolve. The roadmap wins on identity; the disk wins on spelling when it
3637
+ // is talking about the same phase.
3638
+ if (roadmapNextNum !== null) {
3639
+ // Same comparator both scans already use to order phases, so "the disk
3640
+ // and the roadmap mean the same phase" cannot drift from "N is above the
3641
+ // one just completed". `02` and `2` compare equal, which is the whole
3642
+ // point — they are the same phase spelled two ways.
3643
+ const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
3644
+ nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
3645
+ nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
3646
+ isLastPhase = false;
3647
+ }
3648
+ else if (diskNextNum !== null) {
3649
+ // No usable roadmap (absent, unreadable, or no parseable phase rows) —
3650
+ // the disk is all there is. Unchanged from the pre-#3701 behaviour.
3651
+ nextPhaseNum = diskNextNum;
3652
+ nextPhaseName = diskNextName;
3653
+ isLastPhase = false;
3654
+ }
2621
3655
  // #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
2622
3656
  // still outstanding. The two blocks above only clear isLastPhase when a
2623
3657
  // HIGHER-numbered phase exists, so completing the numerically-highest phase
@@ -2645,7 +3679,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2645
3679
  if (roadmapContent !== null) {
2646
3680
  try {
2647
3681
  const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
2648
- const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
3682
+ // #4078: the separator class here mirrors stage 2's widened checkbox
3683
+ // branch (and #2199's BULLET_PHASE_LINE_PATTERN): em/en-dash/hyphen/colon.
3684
+ // Without it, this lowest-outstanding override was blind to dash-grammar
3685
+ // rows and could not correct an out-of-order completion on the same
3686
+ // mixed-grammar roadmaps that broke stage 2.
3687
+ const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi');
2649
3688
  let cbm;
2650
3689
  let lowestOutstanding = null;
2651
3690
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
@@ -2767,8 +3806,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2767
3806
  preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
2768
3807
  }
2769
3808
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
3809
+ // #3685 / #3691: normalize both sides before comparing (same
3810
+ // transitionCore-regenerated-section artifact cmdMilestoneComplete
3811
+ // hit — see contentChangedAfterNormalize's doc). Reported "not
3812
+ // exposed" by a previous agent; the reviewer disproved that by
3813
+ // inspection and this branch closes it.
3814
+ stateUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(statePath, originalStateContent, stateContent);
2770
3815
  }
2771
- writePlanningFileSet(writes);
3816
+ anyPlanningWrite = writePlanningFileSet(writes) > 0;
2772
3817
  };
2773
3818
  if (node_fs_1.default.existsSync(statePath)) {
2774
3819
  withStateLock(statePath, runPhaseCompleteTransaction);
@@ -2826,17 +3871,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2826
3871
  next_phase_name: nextPhaseName,
2827
3872
  is_last_phase: isLastPhase,
2828
3873
  date: today,
2829
- roadmap_updated: node_fs_1.default.existsSync(roadmapPath),
2830
- state_updated: node_fs_1.default.existsSync(statePath),
3874
+ roadmap_updated: roadmapUpdated,
3875
+ state_updated: stateUpdated,
2831
3876
  requirements_updated: requirementsUpdated,
2832
3877
  auto_pruned: autoPruned,
2833
3878
  warnings,
2834
3879
  has_warnings: warnings.length > 0,
3880
+ // ADDITIVE, never a change to `warnings[]`'s element shape — that array is
3881
+ // a documented string[] consumed by execute-phase.md, so re-typing it
3882
+ // would break a shipped output contract. Absent when the line is clean.
3883
+ ...(reqLineWarningCode ? { requirements_line_warning: { code: reqLineWarningCode } } : {}),
2835
3884
  verification_stale_check_indeterminate: staleCheckIndeterminate,
2836
3885
  milestone_conflict: milestoneConflict,
2837
3886
  preservation_warnings: preservationWarnings,
2838
3887
  };
2839
3888
  output(result, raw);
3889
+ // #3227: gate on `anyPlanningWrite` (whether `writePlanningFileSet`
3890
+ // actually wrote anything), not on reaching this line — reaching here only
3891
+ // means verification passed and the transaction ran, not that ROADMAP.md
3892
+ // or STATE.md bytes changed (see the `anyPlanningWrite` declaration above).
3893
+ if (anyPlanningWrite)
3894
+ publishStateContract(cwd);
2840
3895
  }
2841
3896
  function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
2842
3897
  if (!phaseNum) {
@@ -2887,10 +3942,15 @@ module.exports = {
2887
3942
  cmdPhaseAdd,
2888
3943
  cmdPhaseAddBatch,
2889
3944
  cmdPhaseMvpMode,
3945
+ cmdPhaseTddApplicable,
2890
3946
  cmdPhaseInsert,
2891
3947
  cmdPhaseRemove,
2892
3948
  cmdPhaseComplete,
3949
+ analyzeRequirementsLine,
3950
+ formatRequirementsLineWarning,
3951
+ REQ_LINE_WARNING_CODE,
2893
3952
  cmdPhaseUatPassed,
2894
3953
  cmdPhaseListPlans,
2895
3954
  computeDependencyLevels,
3955
+ buildShortFormToId,
2896
3956
  };