@opengsd/gsd-core 1.12.0 → 1.14.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 (455) 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-advisor-researcher.compact.md +85 -0
  5. package/agents/gsd-ai-researcher.compact.md +96 -0
  6. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  7. package/agents/gsd-code-fixer.compact.md +458 -0
  8. package/agents/gsd-code-fixer.md +5 -5
  9. package/agents/gsd-code-reviewer.compact.md +269 -0
  10. package/agents/gsd-code-reviewer.md +15 -3
  11. package/agents/gsd-codebase-mapper.compact.md +760 -0
  12. package/agents/gsd-debug-session-manager.compact.md +345 -0
  13. package/agents/gsd-doc-classifier.compact.md +192 -0
  14. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  15. package/agents/gsd-doc-verifier.compact.md +143 -0
  16. package/agents/gsd-doc-writer.compact.md +440 -0
  17. package/agents/gsd-dom-verifier.compact.md +138 -0
  18. package/agents/gsd-domain-researcher.compact.md +141 -0
  19. package/agents/gsd-eval-auditor.compact.md +160 -0
  20. package/agents/gsd-eval-planner.compact.md +137 -0
  21. package/agents/gsd-executor.md +63 -35
  22. package/agents/gsd-framework-selector.compact.md +82 -0
  23. package/agents/gsd-integration-checker.compact.md +245 -0
  24. package/agents/gsd-intel-updater.compact.md +226 -0
  25. package/agents/gsd-mempalace-curator.compact.md +45 -0
  26. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  27. package/agents/gsd-pattern-mapper.compact.md +275 -0
  28. package/agents/gsd-plan-checker.md +76 -57
  29. package/agents/gsd-planner.md +14 -0
  30. package/agents/gsd-project-researcher.compact.md +587 -0
  31. package/agents/gsd-research-synthesizer.compact.md +212 -0
  32. package/agents/gsd-roadmapper.compact.md +454 -0
  33. package/agents/gsd-roadmapper.md +13 -0
  34. package/agents/gsd-security-auditor.compact.md +162 -0
  35. package/agents/gsd-ui-auditor.compact.md +404 -0
  36. package/agents/gsd-ui-checker.compact.md +277 -0
  37. package/agents/gsd-ui-checker.md +19 -3
  38. package/agents/gsd-ui-researcher.compact.md +282 -0
  39. package/agents/gsd-ui-researcher.md +29 -0
  40. package/agents/gsd-user-profiler.compact.md +108 -0
  41. package/agents/gsd-verifier.md +23 -1
  42. package/bin/install.js +444 -134
  43. package/commands/gsd/cleanup.md +1 -0
  44. package/commands/gsd/code-review.md +2 -1
  45. package/commands/gsd/complete-milestone.md +1 -0
  46. package/commands/gsd/config.md +1 -0
  47. package/commands/gsd/debug.md +1 -0
  48. package/commands/gsd/execute-phase.md +1 -1
  49. package/commands/gsd/graphify.md +1 -0
  50. package/commands/gsd/health.md +1 -0
  51. package/commands/gsd/mempalace-capture.md +1 -0
  52. package/commands/gsd/mempalace-recall.md +1 -0
  53. package/commands/gsd/new-milestone.md +1 -0
  54. package/commands/gsd/new-project.md +1 -0
  55. package/commands/gsd/next.md +1 -0
  56. package/commands/gsd/ns-workflow.md +2 -1
  57. package/commands/gsd/pause-work.md +1 -0
  58. package/commands/gsd/phase.md +2 -1
  59. package/commands/gsd/pr-branch.md +1 -0
  60. package/commands/gsd/quick-batch.md +105 -0
  61. package/commands/gsd/resume-work.md +1 -0
  62. package/commands/gsd/review-backlog.md +1 -0
  63. package/commands/gsd/settings.md +2 -1
  64. package/commands/gsd/stats.md +1 -0
  65. package/commands/gsd/surface.md +18 -8
  66. package/commands/gsd/thread.md +1 -0
  67. package/commands/gsd/workspace.md +1 -0
  68. package/commands/gsd/workstreams.md +1 -0
  69. package/gsd-core/bin/check-latest-version.cjs +8 -3
  70. package/gsd-core/bin/gsd-tools.cjs +532 -174
  71. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  72. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  73. package/gsd-core/bin/lib/audit.cjs +39 -22
  74. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  75. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  76. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  77. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  78. package/gsd-core/bin/lib/capability-registry.cjs +528 -116
  79. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  80. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  81. package/gsd-core/bin/lib/capability-validator.cjs +134 -5
  82. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  83. package/gsd-core/bin/lib/check-command-router.cjs +198 -38
  84. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  85. package/gsd-core/bin/lib/clusters.cjs +1 -0
  86. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  87. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  88. package/gsd-core/bin/lib/commands.cjs +981 -79
  89. package/gsd-core/bin/lib/config-loader.cjs +4 -0
  90. package/gsd-core/bin/lib/config.cjs +153 -38
  91. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  92. package/gsd-core/bin/lib/coverage.cjs +1 -1
  93. package/gsd-core/bin/lib/decisions.cjs +343 -28
  94. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  95. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  96. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  97. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  98. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  99. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  100. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  101. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  102. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  103. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +87 -23
  104. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  105. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  106. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  107. package/gsd-core/bin/lib/init.cjs +539 -60
  108. package/gsd-core/bin/lib/install-engine.cjs +199 -14
  109. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  110. package/gsd-core/bin/lib/install-profiles.cjs +36 -14
  111. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  112. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  113. package/gsd-core/bin/lib/io.cjs +35 -0
  114. package/gsd-core/bin/lib/loop-resolver.cjs +64 -39
  115. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  116. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  117. package/gsd-core/bin/lib/milestone.cjs +41 -10
  118. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  119. package/gsd-core/bin/lib/phase-command-router.cjs +20 -7
  120. package/gsd-core/bin/lib/phase-id.cjs +412 -31
  121. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  122. package/gsd-core/bin/lib/phase.cjs +941 -98
  123. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  124. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  125. package/gsd-core/bin/lib/planning-snapshot.cjs +206 -30
  126. package/gsd-core/bin/lib/planning-workspace.cjs +153 -29
  127. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  128. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  129. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  130. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  131. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  132. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  133. package/gsd-core/bin/lib/research-store.cjs +11 -12
  134. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  135. package/gsd-core/bin/lib/review-lane-invocation.cjs +96 -1
  136. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  137. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  138. package/gsd-core/bin/lib/roadmap-parser.cjs +555 -41
  139. package/gsd-core/bin/lib/roadmap.cjs +292 -69
  140. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +260 -43
  141. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +28 -20
  142. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +294 -108
  143. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -47
  144. package/gsd-core/bin/lib/security.cjs +126 -7
  145. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  146. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  147. package/gsd-core/bin/lib/state-document.cjs +159 -32
  148. package/gsd-core/bin/lib/state-md-schema.cjs +44 -27
  149. package/gsd-core/bin/lib/state-transition.cjs +465 -62
  150. package/gsd-core/bin/lib/state.cjs +906 -151
  151. package/gsd-core/bin/lib/surface.cjs +83 -10
  152. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  153. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  154. package/gsd-core/bin/lib/uat.cjs +1420 -516
  155. package/gsd-core/bin/lib/update-context.cjs +36 -26
  156. package/gsd-core/bin/lib/validate.cjs +230 -12
  157. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  158. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  159. package/gsd-core/bin/lib/verification.cjs +316 -23
  160. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  161. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  162. package/gsd-core/bin/lib/verify.cjs +531 -36
  163. package/gsd-core/bin/lib/workstream-inventory.cjs +21 -2
  164. package/gsd-core/bin/lib/worktree-safety.cjs +21 -7
  165. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  166. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  167. package/gsd-core/bin/verify-reapply-patches.cjs +507 -81
  168. package/gsd-core/references/agent-contracts.md +3 -3
  169. package/gsd-core/references/compact-content-gate.md +66 -0
  170. package/gsd-core/references/edge-probe.md +17 -13
  171. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  172. package/gsd-core/references/execute-phase-response-language.md +6 -0
  173. package/gsd-core/references/executor-examples.md +42 -0
  174. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  175. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  176. package/gsd-core/references/model-profiles.md +12 -3
  177. package/gsd-core/references/mvp-concepts.md +2 -2
  178. package/gsd-core/references/plan-checker-examples.md +41 -0
  179. package/gsd-core/references/planner-antipatterns.md +25 -0
  180. package/gsd-core/references/planner-chunked.md +5 -1
  181. package/gsd-core/references/planner-coupling.md +42 -0
  182. package/gsd-core/references/planner-quick-batch.md +71 -0
  183. package/gsd-core/references/planner-reviews.md +47 -0
  184. package/gsd-core/references/planner-revision.md +75 -2
  185. package/gsd-core/references/planning-config.md +5 -1
  186. package/gsd-core/references/response-language-directive.md +9 -0
  187. package/gsd-core/references/revision-loop.md +118 -11
  188. package/gsd-core/references/tdd.md +17 -9
  189. package/gsd-core/references/thinking-models-planning.md +18 -2
  190. package/gsd-core/references/verification-patterns.md +17 -4
  191. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  192. package/gsd-core/references/worktree-path-safety.md +112 -2
  193. package/gsd-core/templates/README.md +7 -1
  194. package/gsd-core/templates/phase-prompt.md +4 -0
  195. package/gsd-core/templates/state.md +6 -3
  196. package/gsd-core/templates/summary.compact.md +212 -0
  197. package/gsd-core/templates/user-setup.compact.md +199 -0
  198. package/gsd-core/templates/user-setup.md +0 -9
  199. package/gsd-core/templates/verification-report.md +5 -0
  200. package/gsd-core/workflows/add-backlog.md +2 -0
  201. package/gsd-core/workflows/add-phase.md +2 -0
  202. package/gsd-core/workflows/add-tests.md +1 -1
  203. package/gsd-core/workflows/add-todo.md +4 -3
  204. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  205. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  206. package/gsd-core/workflows/audit-fix.md +2 -0
  207. package/gsd-core/workflows/audit-milestone.md +2 -0
  208. package/gsd-core/workflows/audit-uat.md +2 -0
  209. package/gsd-core/workflows/autonomous.md +15 -10
  210. package/gsd-core/workflows/check-todos.md +5 -3
  211. package/gsd-core/workflows/cleanup.md +4 -2
  212. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +22 -13
  213. package/gsd-core/workflows/code-review-fix.md +5 -3
  214. package/gsd-core/workflows/code-review.md +211 -43
  215. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  216. package/gsd-core/workflows/complete-milestone.md +40 -254
  217. package/gsd-core/workflows/debug.md +1 -1
  218. package/gsd-core/workflows/diagnose-issues.md +5 -1
  219. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  220. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  221. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  222. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  223. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  224. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  225. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  226. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  227. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  228. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  229. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  230. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  231. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  232. package/gsd-core/workflows/discuss-phase.md +1 -1
  233. package/gsd-core/workflows/do.md +43 -13
  234. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  235. package/gsd-core/workflows/docs-update.md +15 -156
  236. package/gsd-core/workflows/edit-phase.md +2 -0
  237. package/gsd-core/workflows/eval-review.md +1 -1
  238. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  239. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +20 -3
  240. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  241. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +24 -3
  242. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  243. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  245. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  246. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  247. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  248. package/gsd-core/workflows/execute-phase.md +78 -159
  249. package/gsd-core/workflows/execute-plan.md +28 -15
  250. package/gsd-core/workflows/explore.md +2 -0
  251. package/gsd-core/workflows/extract-learnings.md +2 -0
  252. package/gsd-core/workflows/fast.md +6 -0
  253. package/gsd-core/workflows/forensics.md +2 -0
  254. package/gsd-core/workflows/graduation.md +1 -1
  255. package/gsd-core/workflows/health.md +1 -1
  256. package/gsd-core/workflows/help/modes/brief.md +2 -0
  257. package/gsd-core/workflows/help/modes/default.md +2 -0
  258. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  259. package/gsd-core/workflows/help/modes/full.md +12 -0
  260. package/gsd-core/workflows/help/modes/topic.md +2 -0
  261. package/gsd-core/workflows/help.md +3 -1
  262. package/gsd-core/workflows/import.md +3 -3
  263. package/gsd-core/workflows/inbox.md +1 -1
  264. package/gsd-core/workflows/ingest-docs.md +1 -1
  265. package/gsd-core/workflows/insert-phase.md +2 -0
  266. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  267. package/gsd-core/workflows/list-seeds.md +2 -0
  268. package/gsd-core/workflows/list-workspaces.md +2 -0
  269. package/gsd-core/workflows/manager.md +3 -3
  270. package/gsd-core/workflows/map-codebase.md +52 -3
  271. package/gsd-core/workflows/milestone-summary.md +2 -0
  272. package/gsd-core/workflows/mvp-phase.md +1 -1
  273. package/gsd-core/workflows/new-milestone.md +55 -13
  274. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  275. package/gsd-core/workflows/new-project.md +37 -205
  276. package/gsd-core/workflows/new-workspace.md +1 -1
  277. package/gsd-core/workflows/next.md +2 -0
  278. package/gsd-core/workflows/node-repair.md +2 -0
  279. package/gsd-core/workflows/note.md +2 -0
  280. package/gsd-core/workflows/onboard.md +1 -1
  281. package/gsd-core/workflows/pause-work.md +19 -4
  282. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  283. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  284. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  285. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  286. package/gsd-core/workflows/plan-phase.md +144 -185
  287. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  288. package/gsd-core/workflows/plant-seed.md +1 -1
  289. package/gsd-core/workflows/pr-branch.md +30 -10
  290. package/gsd-core/workflows/profile-user.md +1 -1
  291. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  292. package/gsd-core/workflows/progress.md +25 -3
  293. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  294. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  295. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  296. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  297. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  298. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  299. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  300. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  301. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  302. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  303. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  304. package/gsd-core/workflows/quick-batch.md +203 -0
  305. package/gsd-core/workflows/quick.md +21 -4
  306. package/gsd-core/workflows/reapply-patches.md +79 -3
  307. package/gsd-core/workflows/remove-phase.md +2 -0
  308. package/gsd-core/workflows/remove-workspace.md +1 -1
  309. package/gsd-core/workflows/resume-project.md +6 -2
  310. package/gsd-core/workflows/review.md +215 -10
  311. package/gsd-core/workflows/scan.md +2 -0
  312. package/gsd-core/workflows/section-manifest.json +12 -0
  313. package/gsd-core/workflows/secure-phase.md +1 -1
  314. package/gsd-core/workflows/session-report.md +2 -0
  315. package/gsd-core/workflows/settings-advanced.md +2 -0
  316. package/gsd-core/workflows/settings-integrations.md +9 -8
  317. package/gsd-core/workflows/settings.md +19 -6
  318. package/gsd-core/workflows/ship.md +10 -10
  319. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  320. package/gsd-core/workflows/sketch.md +1 -1
  321. package/gsd-core/workflows/smart-entry.md +1 -1
  322. package/gsd-core/workflows/spec-phase.md +24 -19
  323. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  324. package/gsd-core/workflows/spike.md +1 -1
  325. package/gsd-core/workflows/stats.md +2 -0
  326. package/gsd-core/workflows/sync-skills.md +12 -4
  327. package/gsd-core/workflows/thread.md +2 -0
  328. package/gsd-core/workflows/transition.md +2 -0
  329. package/gsd-core/workflows/ui-phase.md +26 -5
  330. package/gsd-core/workflows/ui-review.md +1 -1
  331. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  332. package/gsd-core/workflows/undo.md +1 -1
  333. package/gsd-core/workflows/update.md +48 -43
  334. package/gsd-core/workflows/validate-phase.md +1 -1
  335. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  336. package/gsd-core/workflows/verify-work.md +68 -182
  337. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  338. package/hooks/dist/gsd-check-update-worker.js +19 -2
  339. package/hooks/dist/gsd-context-monitor.js +371 -27
  340. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  341. package/hooks/dist/gsd-node-runner.sh +1 -0
  342. package/hooks/dist/gsd-prompt-guard.js +30 -5
  343. package/hooks/dist/gsd-read-guard.js +2 -0
  344. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  345. package/hooks/dist/gsd-secret-read-guard.js +1105 -0
  346. package/hooks/dist/gsd-statusline.js +18 -10
  347. package/hooks/dist/gsd-validate-commit.sh +474 -7
  348. package/hooks/dist/gsd-workflow-guard.js +2 -1
  349. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  350. package/hooks/dist/gsd-write-guard.js +46 -1
  351. package/hooks/dist/lib/dispatch-identity.js +187 -0
  352. package/hooks/dist/lib/filename-classification.js +64 -0
  353. package/hooks/dist/lib/git-cmd.js +210 -1
  354. package/hooks/dist/lib/injection-patterns.js +36 -6
  355. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  356. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  357. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  358. package/hooks/gsd-agent-isolation-guard.js +42 -16
  359. package/hooks/gsd-check-update-worker.js +19 -2
  360. package/hooks/gsd-context-monitor.js +371 -27
  361. package/hooks/gsd-cursor-subagent-start.js +34 -14
  362. package/hooks/gsd-node-runner.sh +1 -0
  363. package/hooks/gsd-prompt-guard.js +30 -5
  364. package/hooks/gsd-read-guard.js +2 -0
  365. package/hooks/gsd-read-injection-scanner.js +5 -5
  366. package/hooks/gsd-secret-read-guard.js +1105 -0
  367. package/hooks/gsd-statusline.js +18 -10
  368. package/hooks/gsd-validate-commit.sh +474 -7
  369. package/hooks/gsd-workflow-guard.js +2 -1
  370. package/hooks/gsd-worktree-path-guard.js +25 -14
  371. package/hooks/gsd-write-guard.js +46 -1
  372. package/hooks/hooks.json +6 -0
  373. package/hooks/lib/dispatch-identity.js +187 -0
  374. package/hooks/lib/filename-classification.js +64 -0
  375. package/hooks/lib/git-cmd.js +210 -1
  376. package/hooks/lib/injection-patterns.js +36 -6
  377. package/hooks/lib/isolation-deny-reason.js +53 -1
  378. package/hooks/lib/isolation-sentinel.js +58 -19
  379. package/hooks/managed-hooks-registry.cjs +1 -0
  380. package/package.json +13 -9
  381. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  382. package/scripts/benchmark-compact-content.cjs +368 -0
  383. package/scripts/build-hooks.js +11 -4
  384. package/scripts/check-contract-drift.cjs +4 -1
  385. package/scripts/check-env.cjs +36 -8
  386. package/scripts/check-glossary-refs.cjs +25 -21
  387. package/scripts/ci-next-health.cjs +271 -0
  388. package/scripts/ci-prepare-test-scope.cjs +7 -7
  389. package/scripts/ci-test-scope.cjs +133 -20
  390. package/scripts/ci-timeout-report.cjs +1 -1
  391. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  392. package/scripts/docs-guard-registry.cjs +17 -2
  393. package/scripts/gen-adr-index.cjs +8 -2
  394. package/scripts/gen-inventory-manifest.cjs +12 -0
  395. package/scripts/gen-loop-host-contract.cjs +67 -15
  396. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  397. package/scripts/lib/drift-scan.cjs +1 -1
  398. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  399. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  400. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  401. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  402. package/scripts/lib/suite-detection.cjs +32 -0
  403. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  404. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  405. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  406. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  407. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +24 -2
  408. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  409. package/scripts/lint-phase-id-drift.cjs +465 -15
  410. package/scripts/lint-portable-grep.cjs +176 -0
  411. package/scripts/lint-response-language-coverage.cjs +530 -0
  412. package/scripts/lint-source-test-name-collision.cjs +1 -1
  413. package/scripts/lint-test-file-count.allowlist.json +4 -1
  414. package/scripts/lint-vendored-deps.cjs +128 -17
  415. package/scripts/lint-workflow-shellcheck-baseline.json +1112 -0
  416. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  417. package/scripts/npm-audit-baseline.cjs +376 -0
  418. package/scripts/prompt-injection-scan.sh +22 -0
  419. package/scripts/require-issue-link-policy.cjs +16 -1
  420. package/scripts/workflow-size.cjs +139 -0
  421. package/skills/gsd-cleanup/SKILL.md +1 -0
  422. package/skills/gsd-code-review/SKILL.md +2 -1
  423. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  424. package/skills/gsd-config/SKILL.md +1 -0
  425. package/skills/gsd-debug/SKILL.md +1 -0
  426. package/skills/gsd-execute-phase/SKILL.md +1 -1
  427. package/skills/gsd-graphify/SKILL.md +1 -0
  428. package/skills/gsd-health/SKILL.md +1 -0
  429. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  430. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  431. package/skills/gsd-new-milestone/SKILL.md +1 -0
  432. package/skills/gsd-new-project/SKILL.md +1 -0
  433. package/skills/gsd-next/SKILL.md +1 -0
  434. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  435. package/skills/gsd-pause-work/SKILL.md +1 -0
  436. package/skills/gsd-phase/SKILL.md +2 -1
  437. package/skills/gsd-pr-branch/SKILL.md +1 -0
  438. package/skills/gsd-quick-batch/SKILL.md +105 -0
  439. package/skills/gsd-resume-work/SKILL.md +1 -0
  440. package/skills/gsd-review-backlog/SKILL.md +1 -0
  441. package/skills/gsd-settings/SKILL.md +2 -1
  442. package/skills/gsd-stats/SKILL.md +1 -0
  443. package/skills/gsd-surface/SKILL.md +18 -8
  444. package/skills/gsd-thread/SKILL.md +1 -0
  445. package/skills/gsd-workspace/SKILL.md +1 -0
  446. package/skills/gsd-workstreams/SKILL.md +1 -0
  447. package/vscode/package.json +1 -1
  448. package/gsd-core/templates/claude-md.md +0 -145
  449. package/gsd-core/templates/codebase/concerns.md +0 -310
  450. package/gsd-core/templates/codebase/conventions.md +0 -307
  451. package/gsd-core/templates/codebase/integrations.md +0 -280
  452. package/gsd-core/templates/codebase/structure.md +0 -285
  453. package/gsd-core/templates/codebase/testing.md +0 -480
  454. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  455. package/gsd-core/templates/discovery.md +0 -146
@@ -45,10 +45,19 @@ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhas
45
45
  const pattern_cjs_1 = require("./pattern.cjs");
46
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
47
47
  const phaseLocatorMod = require("./phase-locator.cjs");
48
- const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
48
+ const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
50
+ const planningScopeMod = require("./planning-scope.cjs");
51
+ const { SCOPE } = planningScopeMod;
49
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
50
53
  const roadmapParserMod = require("./roadmap-parser.cjs");
51
54
  const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
55
+ // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
56
+ // (pure computation, no I/O — no cycle on this path) for the intent-first
57
+ // progress counters the phase-complete transaction passes downstream.
58
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
59
+ const phaseLifecycleMod = require("./phase-lifecycle.cjs");
60
+ const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
52
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
53
62
  const planningWorkspace = require("./planning-workspace.cjs");
54
63
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -75,7 +84,9 @@ const { readVerificationStatus } = verificationMod;
75
84
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
76
85
  const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
77
86
  const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
78
- const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
87
+ // #612: `resolvePhaseIdConvention` selects the write-time milestone-scope
88
+ // guard's terminator vocabulary (see assertDescriptionPreservesMilestoneScope).
89
+ const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, resolvePhaseIdConvention, } = planningWorkspace;
79
90
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
80
91
  const milestoneLockMod = require("./milestone-lock.cjs");
81
92
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -285,25 +296,26 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
285
296
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
286
297
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
287
298
  baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
288
- const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
289
- for (const dir of dirs) {
290
- const match = dir.match(dirPattern);
291
- if (match)
292
- decimalSet.add(parseInt(match[1], 10));
293
- }
294
299
  }
295
300
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
296
301
  if (node_fs_1.default.existsSync(roadmapPath)) {
297
302
  try {
298
303
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
299
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
300
- let pm;
301
- while ((pm = phasePattern.exec(roadmapContent)) !== null) {
302
- decimalSet.add(parseInt(pm[1], 10));
304
+ for (const n of scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized)) {
305
+ decimalSet.add(n);
303
306
  }
304
307
  }
305
308
  catch {
306
- /* ROADMAP.md read failure is non-fatal */
309
+ // ROADMAP.md read failure is non-fatal — fall back to the directory-only
310
+ // scan (empty rawContent) so on-disk decimal directories are still counted.
311
+ for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
312
+ decimalSet.add(n);
313
+ }
314
+ }
315
+ }
316
+ else {
317
+ for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
318
+ decimalSet.add(n);
307
319
  }
308
320
  }
309
321
  const existingDecimals = Array.from(decimalSet)
@@ -385,6 +397,57 @@ function cmdPhaseMvpMode(cwd, args, raw) {
385
397
  cli_flag_present: cliFlagPresent,
386
398
  }, raw);
387
399
  }
400
+ /**
401
+ * `phase.tdd-applicable <plan-file> [--cli-flag]` (#4273, Phase 1 of epic
402
+ * #4272) — resolves whether the TDD RED/GREEN/REFACTOR gate applies to a
403
+ * given plan, in strict precedence order: an explicit `--cli-flag` wins over
404
+ * the plan's own `type: tdd` frontmatter, which wins over any task in the
405
+ * plan carrying `tdd="true"` (the #4265 mixed-mode shape), which wins over
406
+ * the project-wide `workflow.tdd_mode` config default. Mirrors
407
+ * `cmdPhaseMvpMode`'s precedence-cascade shape immediately above.
408
+ */
409
+ function cmdPhaseTddApplicable(cwd, args, raw) {
410
+ const planPath = args[0];
411
+ if (!planPath) {
412
+ error('Usage: phase.tdd-applicable <plan-file> [--cli-flag]', ERROR_REASON.USAGE);
413
+ }
414
+ const resolvedPath = node_path_1.default.isAbsolute(planPath) ? planPath : node_path_1.default.join(cwd, planPath);
415
+ if (!node_fs_1.default.existsSync(resolvedPath)) {
416
+ error(`Plan file not found: ${planPath}`, ERROR_REASON.PHASE_NOT_FOUND);
417
+ }
418
+ const cliFlagPresent = args.includes('--cli-flag');
419
+ const content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
420
+ const doc = parsePlanDocument(content, resolvedPath);
421
+ const planType = doc.type;
422
+ const taskTddAttribute = doc.tasks.some((t) => t.tdd === 'true');
423
+ const config = loadConfig(cwd);
424
+ const configTddMode = Boolean(config.tdd_mode);
425
+ let applicable = false;
426
+ let source = 'none';
427
+ if (cliFlagPresent) {
428
+ applicable = true;
429
+ source = 'cli_flag';
430
+ }
431
+ else if (planType === 'tdd') {
432
+ applicable = true;
433
+ source = 'plan_frontmatter';
434
+ }
435
+ else if (taskTddAttribute) {
436
+ applicable = true;
437
+ source = 'task_attribute';
438
+ }
439
+ else if (configTddMode) {
440
+ applicable = true;
441
+ source = 'config';
442
+ }
443
+ output({
444
+ applicable,
445
+ source,
446
+ plan_type: planType,
447
+ config_tdd_mode: configTddMode,
448
+ cli_flag_present: cliFlagPresent,
449
+ }, raw);
450
+ }
388
451
  function cmdFindPhase(cwd, phase, raw) {
389
452
  if (!phase) {
390
453
  error('phase identifier required');
@@ -968,16 +1031,36 @@ function phaseEntryInsertOffset(rawContent, cwd) {
968
1031
  * the edit-phase workflow's depends_on gate. The predicate itself
969
1032
  * (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
970
1033
  * so ordinary descriptions and the phase's own numbered heading never trip it.
1034
+ *
1035
+ * #612: the predicate is convention-SELECTED, because the terminator
1036
+ * vocabulary it mirrors is. On an opted-in bracket repo the ADR-canonical
1037
+ * `## [GSD.09] Hidden` carries none of the markers listed above and yet
1038
+ * terminates the window, so the blind call accepted the exact description the
1039
+ * guard exists to reject — measured at this CLI seam, two `phase add` calls,
1040
+ * the second phase silently outside the milestone phase set. Resolved through
1041
+ * the same tolerant shape the read path uses (`planningDir` throws on a
1042
+ * poisoned `GSD_PROJECT`/`GSD_WORKSTREAM` segment, and this guard runs BEFORE
1043
+ * `loadConfig` and the ROADMAP existence check — an unresolvable convention
1044
+ * must degrade to the pre-existing legacy vocabulary, never turn a rejection
1045
+ * into a crash).
971
1046
  */
972
- function assertDescriptionPreservesMilestoneScope(description, command) {
973
- const offending = findMilestoneScopeHeadingLines(description);
1047
+ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1048
+ let convention = null;
1049
+ try {
1050
+ convention = resolvePhaseIdConvention(cwd);
1051
+ }
1052
+ catch { /* unresolvable convention → treat as not-configured (base behaviour) */ }
1053
+ const offending = findMilestoneScopeHeadingLines(description, convention);
974
1054
  if (offending.length === 0)
975
1055
  return;
1056
+ const markerList = convention === 'bracket'
1057
+ ? `(a vN.N version token, a ✅/📋/🚧/🔄 marker, the word "Milestone", or — under the bracket convention — a "[CODE.NN] Name" milestone heading)`
1058
+ : `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone")`;
976
1059
  error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
977
1060
  `the current milestone window and silently drop later phases out of the milestone scope. ` +
978
1061
  `Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
979
1062
  `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
980
- `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
1063
+ markerList + `.`);
981
1064
  }
982
1065
  /**
983
1066
  * #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
@@ -988,12 +1071,24 @@ function assertDescriptionPreservesMilestoneScope(description, command) {
988
1071
  * before any directory does; milestone-scoping is wrong here because a number
989
1072
  * used under any milestone on another branch is still taken).
990
1073
  *
1074
+ * #4225 — the horizon must track the ALLOCATION scope. When the allocation is
1075
+ * workstream-scoped (`--ws`/`GSD_WORKSTREAM`, resolved into the env before
1076
+ * dispatch), the sibling's copy of the SAME workstream is what carries that
1077
+ * scope's independent numbering; the sibling's ROOT roadmap and phases/
1078
+ * belong to a different numbering universe (docs/FEATURES.md §51 REQ-WS-01 —
1079
+ * workstream state is isolated in `.planning/workstreams/{name}/`) and must
1080
+ * not contribute. `planningDir(wt, ws)` reuses the canonical resolver, so the
1081
+ * sibling scope matches the local scope's own resolution (env workstream plus
1082
+ * env project segment) by construction; `ws === null` (no workstream active)
1083
+ * keeps the #3849 root-scope horizon byte-for-byte.
1084
+ *
991
1085
  * Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
992
1086
  * non-git cwd, or an unavailable git binary each leave `used` untouched —
993
1087
  * allocation then behaves exactly as it did before this horizon existed.
994
- * Sentinels reuse the canonical `isSentinelPhaseId`; the dir pattern is the
995
- * same one the on-disk scan uses, so decimal sub-phases (`411.1-foo`) are
996
- * correctly not integers.
1088
+ * A sibling that simply lacks the active workstream's directory is the same
1089
+ * fail-open case: it contributes nothing. Sentinels reuse the canonical
1090
+ * `isSentinelPhaseId`; the dir pattern is the same one the on-disk scan uses,
1091
+ * so decimal sub-phases (`411.1-foo`) are correctly not integers.
997
1092
  */
998
1093
  function collectSiblingWorktreePhaseNums(cwd, used) {
999
1094
  let porcelain;
@@ -1011,6 +1106,13 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1011
1106
  catch {
1012
1107
  return; // not a git repo / git unavailable — unchanged behavior
1013
1108
  }
1109
+ // #4225: the env workstream, read once with planningDir's own discriminator
1110
+ // (`?? null` = deliberately no workstream — never re-derived per sibling).
1111
+ // A poisoned value would already have thrown at the local `planningDir(cwd)`
1112
+ // call every allocator makes before reaching this horizon; the per-sibling
1113
+ // try/catch below still keeps any resolution failure fail-open.
1114
+ const ws = process.env['GSD_WORKSTREAM'] ?? null;
1115
+ const siblingPlanningDir = (wt) => planningDir(wt, ws);
1014
1116
  const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1015
1117
  // Same header shape the allocators scan locally (#1729 tag tolerance).
1016
1118
  const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
@@ -1021,7 +1123,7 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1021
1123
  if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
1022
1124
  continue;
1023
1125
  try {
1024
- for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(wt, '.planning', 'phases'))) {
1126
+ for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(siblingPlanningDir(wt), 'phases'))) {
1025
1127
  const match = entry.match(dirNumPattern);
1026
1128
  if (!match)
1027
1129
  continue;
@@ -1031,10 +1133,10 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1031
1133
  }
1032
1134
  }
1033
1135
  catch {
1034
- /* worktree has no .planning — normal, contributes nothing */
1136
+ /* worktree has no .planning (or no copy of this scope) — normal, contributes nothing */
1035
1137
  }
1036
1138
  try {
1037
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(wt, '.planning', 'ROADMAP.md'), 'utf-8');
1139
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(siblingPlanningDir(wt), 'ROADMAP.md'), 'utf-8');
1038
1140
  let m;
1039
1141
  headerPattern.lastIndex = 0;
1040
1142
  while ((m = headerPattern.exec(content)) !== null) {
@@ -1044,7 +1146,7 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1044
1146
  }
1045
1147
  }
1046
1148
  catch {
1047
- /* no roadmap in that worktree — normal, contributes nothing */
1149
+ /* no roadmap in that worktree (or scope) — normal, contributes nothing */
1048
1150
  }
1049
1151
  }
1050
1152
  }
@@ -1052,7 +1154,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1052
1154
  if (!description) {
1053
1155
  error('description required for phase add');
1054
1156
  }
1055
- assertDescriptionPreservesMilestoneScope(description, 'phase add');
1157
+ assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add');
1056
1158
  const config = loadConfig(cwd);
1057
1159
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1058
1160
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -1169,7 +1271,7 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1169
1271
  // all-or-nothing, so one offending description must reject the whole batch
1170
1272
  // with no ROADMAP write and no phase directories created.
1171
1273
  for (const description of descriptions) {
1172
- assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
1274
+ assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch');
1173
1275
  }
1174
1276
  const config = loadConfig(cwd);
1175
1277
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
@@ -1270,11 +1372,67 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1270
1372
  // found')` above, which terminates the process and never reaches here.
1271
1373
  publishStateContract(cwd);
1272
1374
  }
1273
- function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1375
+ // #4569: scans all three representations of an existing decimal sub-phase
1376
+ // under `base` — on-disk `phases/` directories, `### Phase BASE.N:` headings,
1377
+ // and `- [ ] Phase BASE.N:` roadmap SUMMARY CHECKLIST bullets. A bullet-only
1378
+ // roadmap with no heading yet and no on-disk directory yet must still be
1379
+ // seen, or an allocator can silently reallocate an already-used decimal
1380
+ // number. Shared by `cmdPhaseInsert`'s normalized-base scan and its
1381
+ // sibling-allocation parent-base scan so the two never drift apart.
1382
+ function scanExistingDecimalPhaseNumbers(phasesDir, rawContent, base) {
1383
+ const decimalSet = new Set();
1384
+ // #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
1385
+ // scan above — a missing phasesDir (no decimal sub-phases yet) is the
1386
+ // expected, silent case (empty decimalSet). A readdirSync failure once the
1387
+ // dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
1388
+ // `phase insert` proceed with an incomplete decimalSet and risk writing a
1389
+ // decimal phase number that collides with an existing on-disk directory
1390
+ // the scan simply never saw — surfaced loud instead, like the sibling.
1391
+ //
1392
+ // #4634 (lint-phase-enumeration-drift): routed through the canonical
1393
+ // PHYSICAL-set owner (`listAllPhaseDirs`, phase-locator.cts) instead of a
1394
+ // hand-rolled `readdirSync`. This scan — like its sibling `cmdPhaseNextDecimal`
1395
+ // and its caller `cmdPhaseInsert` (both exempted in the drift guard for the
1396
+ // same reason) — must see EVERY on-disk decimal sub-phase directory
1397
+ // regardless of the current milestone window, so `listMilestonePhaseDirs`
1398
+ // (windowed) is the wrong owner here; `includeSentinels: true` preserves this
1399
+ // function's pre-existing behavior of never sentinel-filtering (the decimal
1400
+ // regex below only ever matches `base.N`-shaped names, so sentinel inclusion
1401
+ // is a no-op either way).
1402
+ if (node_fs_1.default.existsSync(phasesDir)) {
1403
+ const { value: dirs, scope } = listAllPhaseDirs(phasesDir, { includeSentinels: true });
1404
+ if (scope === SCOPE.UNREADABLE) {
1405
+ // The dir EXISTS but could not be read (EACCES/EIO) — a genuine anomaly,
1406
+ // not the expected empty-decimalSet case above. Surfaced loud, matching
1407
+ // this function's pre-migration `readdirSync` catch: swallowing it would
1408
+ // let `phase insert` proceed with an incomplete decimalSet and collide
1409
+ // with an existing on-disk decimal directory the scan never saw.
1410
+ error(`Failed to scan phase directories for existing decimal phases: unable to read ${phasesDir}`);
1411
+ }
1412
+ const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(base)}\\.(\\d+)`);
1413
+ for (const dir of dirs) {
1414
+ const dm = dir.match(decimalPattern);
1415
+ if (dm)
1416
+ decimalSet.add(parseInt(dm[1], 10));
1417
+ }
1418
+ }
1419
+ const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
1420
+ let rmMatch;
1421
+ while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
1422
+ decimalSet.add(parseInt(rmMatch[1], 10));
1423
+ }
1424
+ const checklistDecimalPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
1425
+ let clMatch;
1426
+ while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
1427
+ decimalSet.add(parseInt(clMatch[1], 10));
1428
+ }
1429
+ return decimalSet;
1430
+ }
1431
+ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
1274
1432
  if (!afterPhase || !description) {
1275
1433
  error('after-phase and description required for phase insert');
1276
1434
  }
1277
- assertDescriptionPreservesMilestoneScope(description, 'phase insert');
1435
+ assertDescriptionPreservesMilestoneScope(cwd, description, 'phase insert');
1278
1436
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1279
1437
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1280
1438
  error('ROADMAP.md not found');
@@ -1300,43 +1458,20 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1300
1458
  }
1301
1459
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1302
1460
  const normalizedBase = normalizePhaseName(afterPhase);
1303
- const decimalSet = new Set();
1304
- // #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
1305
- // scan above — a missing phasesDir (no decimal sub-phases yet) is the
1306
- // expected, silent case (empty decimalSet). A readdirSync failure once the
1307
- // dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
1308
- // `phase insert` proceed with an incomplete decimalSet and risk writing a
1309
- // decimal phase number that collides with an existing on-disk directory
1310
- // the scan simply never saw — surfaced loud instead, like the sibling.
1311
- if (node_fs_1.default.existsSync(phasesDir)) {
1312
- // Initialized (not just declared) so TS's definite-assignment check is
1313
- // satisfied without relying on control-flow narrowing through error()'s
1314
- // `never` return, which TS does not propagate through a destructured
1315
- // module-property function reference — error() still halts the process
1316
- // before `dirs` below is ever computed from this placeholder value.
1317
- let entries = [];
1318
- try {
1319
- entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1320
- }
1321
- catch (e) {
1322
- const msg = e instanceof Error ? e.message : String(e);
1323
- error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
1324
- }
1325
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1326
- const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
1327
- for (const dir of dirs) {
1328
- const dm = dir.match(decimalPattern);
1329
- if (dm)
1330
- decimalSet.add(parseInt(dm[1], 10));
1331
- }
1332
- }
1333
- const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
1334
- let rmMatch;
1335
- while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
1336
- decimalSet.add(parseInt(rmMatch[1], 10));
1337
- }
1461
+ const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
1338
1462
  const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
1339
- const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
1463
+ let _decimalPhase = `${normalizedBase}.${nextDecimal}`;
1464
+ // #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
1465
+ // one level deeper under afterPhase itself. A top-level phase (no existing
1466
+ // decimal segment) has no sibling level to join; nested is the only sensible
1467
+ // allocation, so we silently fall back for that case.
1468
+ const lastDotIndex = normalizedBase.lastIndexOf('.');
1469
+ if (allocation === 'sibling' && lastDotIndex !== -1) {
1470
+ const parentBase = normalizedBase.slice(0, lastDotIndex);
1471
+ const siblingDecimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
1472
+ const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
1473
+ _decimalPhase = `${parentBase}.${siblingNextDecimal}`;
1474
+ }
1340
1475
  const insertConfig = loadConfig(cwd);
1341
1476
  const projectCode = insertConfig.project_code || '';
1342
1477
  const pfx = projectCode ? `${projectCode}-` : '';
@@ -2054,6 +2189,640 @@ function phaseDisplayNameFromSlug(slug) {
2054
2189
  const name = slug.replace(/-/g, ' ').trim();
2055
2190
  return name || null;
2056
2191
  }
2192
+ // A range operator, enumerated. CENSUS (round 3): the domain is "separator
2193
+ // spellings an author can put between two REQ-IDs", which is open, so the
2194
+ // enumeration draws a boundary rather than covering it. Reached: ASCII `..`+,
2195
+ // the seven Unicode dashes that are the SAME operator at different codepoints
2196
+ // (U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, U+2013 en,
2197
+ // U+2014 em, U+2015 horizontal bar, U+2212 minus) plus ASCII `-`, U+2026
2198
+ // ellipsis, and the words `to`/`thru`/`through`. NOT reached, and the
2199
+ // consequence is a silent under-selection — #3697's own defect — for that
2200
+ // spelling: `→`, `~`, `..=`, `..<`, `until`, and `up to` (two tokens, so it
2201
+ // cannot be one operator token at all). Those stay out deliberately: each is a
2202
+ // symbol or word with an independent non-range use between two IDs, which is
2203
+ // the over-warning class #2334 cost three rounds. The Unicode dashes DO carry
2204
+ // the ASCII hyphen's date/sub-number collision — an earlier round-3 commit
2205
+ // claimed they did not, and was wrong — so they take the strict arm with it;
2206
+ // see the rule below.
2207
+ const REQ_RANGE_DASHES = '\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212';
2208
+ // EVERY DASH IS STRICT — one rule, whatever the codepoint. `PREFIX-\d+ <dash>
2209
+ // \d+` is also a date (`FY-2026-08`) and a sub-numbered ID (`API-2-01`), and
2210
+ // that ambiguity is a property of the SHAPE, not of which dash key was pressed.
2211
+ // The design already chose strictness for ASCII `-` on exactly this trade: a
2212
+ // bare-hyphen tight range must carry a full ID on BOTH sides. Until round 3 the
2213
+ // other dashes sat in the loose arm, so `RANGE-01 (target FY-2026<en-dash>08)`
2214
+ // warned while its all-ASCII twin — pinned silent by #3697-4 — did not. That
2215
+ // inconsistency predates this PR for U+2013/U+2014; round 3 briefly widened it
2216
+ // to five more codepoints before this commit closed it for all seven.
2217
+ // The cost is symmetric and already accepted: `RANGE-01, RANGE-02<dash>05`
2218
+ // goes silent, exactly as `RANGE-01, RANGE-02-05` already does today. A bare
2219
+ // `RANGE-02<dash>05` still warns — it selects nothing, so R3 catches it.
2220
+ // LOOSE stays loose: `..`, `…` and the word operators have no date or
2221
+ // sub-number reading between two numbers, so they keep the numeric endpoint.
2222
+ const REQ_RANGE_OP = `(?:\\.{2,}|\\u2026|[${REQ_RANGE_DASHES}]|-|to|thru|through)`;
2223
+ const REQ_RANGE_OP_LOOSE = `(?:\\.{2,}|\\u2026|to|thru|through)`;
2224
+ const REQ_RANGE_OP_SYMBOL = `(?:\\.{2,}|\\u2026|[${REQ_RANGE_DASHES}]|-)`;
2225
+ 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');
2226
+ const REQ_PURE_RANGE_OP_RE = new RegExp(`^${REQ_RANGE_OP}$`, 'i');
2227
+ const REQ_GLUED_RANGE_LEAD_RE = new RegExp(`^${REQ_RANGE_OP_SYMBOL}([A-Z][A-Z0-9]*-\\d+)$`, 'i');
2228
+ const REQ_GLUED_RANGE_TRAIL_RE = new RegExp(`^([A-Z][A-Z0-9]*-\\d+)${REQ_RANGE_OP}$`, 'i');
2229
+ const REQ_ID_SUBSTRING_RE = /[A-Z][A-Z0-9]*-\d+/i;
2230
+ const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
2231
+ const REQ_ID_PARTS_RE = /^([A-Z][A-Z0-9]*)-(\d+)$/i;
2232
+ // `LETTERS-\d+-\d+` — a date (`FY-2026-08`) or a sub-numbered ID (`API-2-01`).
2233
+ // REQ_RANGE_TOKEN_RE's strict-dash arm exists precisely to keep this shape
2234
+ // silent, because nothing at token level can tell the three readings apart.
2235
+ // Round 4 review Minor 2: the skipped-text rider re-reported it through the
2236
+ // side door — `REQ_ID_SUBSTRING_RE` is unanchored, so `FY-2026-08` matches as
2237
+ // `FY-2026` and landed in `unselectedIdShaped`. Whenever any OTHER rule fired
2238
+ // on a line carrying a date annotation, the warning then told the author to
2239
+ // "check whether any of it is a requirement" about a date. Not a false
2240
+ // warning — the line was warning anyway — but false CONTENT, and it is the
2241
+ // #2334 voice.
2242
+ // `PREFIX-<digits>-<digits>` — the shape the strict-dash range rule refuses to
2243
+ // act on because it is equally a date (`FY-2026-08`) and a sub-numbered id
2244
+ // (`API-2-01`). NO regex separates those: `API-2026-08` is a legal requirement
2245
+ // id and `FY-26-08` is a date, and both filters that tried scored a miss in
2246
+ // each direction under the pre-push review's continuation.
2247
+ //
2248
+ // So the rider stops adjudicating and starts DISCLOSING. Round 4 Minor 2's
2249
+ // real complaint was that the rider told the author to check whether a DATE
2250
+ // was a requirement; the fix is to name the ambiguity rather than to guess at
2251
+ // it — which is the same thing the two warning voices already do about a
2252
+ // range separator.
2253
+ const REQ_AMBIGUOUS_NUMERIC_RE = /^[A-Z][A-Z0-9]*(?:-\d+){2,}$/i;
2254
+ // The token-length cap. It bounds REQ_ID_SUBSTRING_RE, the one UNANCHORED
2255
+ // regex here, which backtracks quadratically on a pathological token. Round 3
2256
+ // review Nit 6 objected that the anchored regexes were left uncapped on the
2257
+ // strength of a comment asserting they scan linearly; they are applied through
2258
+ // the same cap now, so the claim is enforced rather than asserted. No real
2259
+ // REQ-ID-carrying token approaches this bound.
2260
+ const REQ_TOKEN_SCAN_LIMIT = 2048;
2261
+ /**
2262
+ * The Requirements-line warning KINDS, as a stable machine vocabulary (round 4
2263
+ * review Major 3).
2264
+ *
2265
+ * Before this, the kind existed only in the prose of the message, so every
2266
+ * consumer and every test had to regex an English sentence — and rewording a
2267
+ * message silently un-asserted the tests that pinned it. The repo already had
2268
+ * the settled seam for exactly these semantics: `diffLiveConfig` emits
2269
+ * `kind:'unverified'` for a truncated scan (`CONTEXT.md`), and
2270
+ * `WAVE_CLEANUP_WARNING` carries codes in `src/worktree-safety.cts`.
2271
+ *
2272
+ * Carried ALONGSIDE the prose, never instead of it. `warnings[]` is a
2273
+ * documented `string[]` in `phase complete`'s JSON output, rendered by
2274
+ * execute-phase.md's "If has_warnings is true" step, so changing its element
2275
+ * shape would be a breaking output-contract change for a shipped command. The
2276
+ * code is emitted as its own additive `requirements_line_warning` field.
2277
+ */
2278
+ const REQ_LINE_WARNING_CODE = {
2279
+ /** ID-shaped content was demonstrably not selected — the line failed to parse. */
2280
+ misparse: 'req-line-misparse',
2281
+ /** A range READING is at stake; every endpoint the rule fired on was selected. */
2282
+ rangeReading: 'req-line-range-reading',
2283
+ /** A token past the scan cap means the line was not classified — never that it is clean. */
2284
+ unverified: 'req-line-unverified',
2285
+ };
2286
+ // R4 — a full ID with a trailing statement delimiter glued to it. ANCHORED on
2287
+ // both ends, so it is linear and needs no cap of its own beyond the token
2288
+ // length guard its caller applies.
2289
+ // Zero-width, bidi-control, joiner and variation-selector codepoints. INVISIBLE
2290
+ // to the author, and the pre-push review's continuation drove the consequence
2291
+ // from both sides: a line of only these warned with nothing on screen to
2292
+ // explain it, AND stripping them wholesale from the detector made
2293
+ // `REQ-01<ZWSP>, REQ-02` go SILENT while the selector really did drop REQ-01 —
2294
+ // #3697's own defect, introduced by the fix for its mirror image. So they are
2295
+ // never stripped from the line: they are DECORATION on a token (R4 below) and
2296
+ // absence-of-content for the empty test (visibleContent), which are two
2297
+ // different questions about the same character.
2298
+ const REQ_INVISIBLE_RE = /[\u00AD\u200B-\u200F\u2060-\u2064\u2066-\u2069\uFE0F\uFEFF]/g;
2299
+ // The wrappers R4 shaves. Emphasis, quotes and backticks, because the SELECTOR
2300
+ // shaves none of them — `**REQ-01**` is genuinely not selected and is a real,
2301
+ // silent drop.
2302
+ //
2303
+ // PARENTHESES ARE DELIBERATELY ABSENT, and this is load-bearing. A parenthesis
2304
+ // is this rule's citation MARKER, not decoration to shave: `(REQ-02)` and
2305
+ // `(ADR-7)` are the same shape and the rule declines both. Including them here
2306
+ // made `REQ-01, (REQ-02), REQ-03 — REQ-05` report a glued delimiter that was
2307
+ // never there, and broke #3697-9d's channel routing with it — caught by the
2308
+ // suite immediately after the widening.
2309
+ const REQ_WRAPPER_RE = /^["'`*_~“”‘’]+|["'`*_~“”‘’]+$/g;
2310
+ // An id with a list delimiter glued to EITHER end, once styling is removed.
2311
+ // The capture is the bare id; a match means the delimiter was ADJACENT to it.
2312
+ const REQ_DELIMITED_ID_RE = /^[;:]*([A-Z][A-Z0-9]*-\d+)[;:]*$/i;
2313
+ /**
2314
+ * CENSUS (round 4): the domain is "separators an author writes between two
2315
+ * REQ-IDs INSTEAD of a comma" — distinct from the range-operator domain
2316
+ * censused above, and it had no census at all before this round.
2317
+ *
2318
+ * ROUND 4'S CENSUS WAS WRONG, AND THE WAY IT WAS WRONG IS THE LESSON. It swept
2319
+ * 26 spellings and concluded "exactly two — `; ` and `: `". It reached that
2320
+ * answer because it swept the ONE-SIDED form (`REQ-01; REQ-02`) for the
2321
+ * semicolon and colon, and only the BARE and SYMMETRIC forms (`|`, ` | `) for
2322
+ * every other separator. Different members of the domain were tested in
2323
+ * different shapes, so the conclusion could not have come out any other way.
2324
+ *
2325
+ * Re-swept round 5, fully crossed: 21 separators x {bare, trailing-space,
2326
+ * leading-space, both-spaces} = 84 combinations, driven through the built
2327
+ * artifact. 26 select both IDs, 24 under-select and already warn, and
2328
+ * 34 UNDER-SELECT SILENTLY. All 34 are the same shape — a separator glued to
2329
+ * exactly ONE of the two IDs, e.g. `REQ-01/ REQ-02` or `REQ-01 /REQ-02` — for
2330
+ * every punctuation except `,` (the real delimiter) and `;` / `:` (R4).
2331
+ * Measured silent: | / + & \ > . ! ? • · ؛ ; , - ~ and the word operators
2332
+ * `and` / `plus` in trailing-space form.
2333
+ *
2334
+ * So the honest statement is that R4 covers TWO CHARACTERS of a domain that is
2335
+ * wide open, not that the domain has two members. The round-4 review
2336
+ * hand-listed the semicolon; the colon is its sibling and fails identically;
2337
+ * everything else in that list is disclosed here and NOT caught. Widening the
2338
+ * delimiter class is a small change and deliberately not made at the end of a
2339
+ * round: three successive cuts of this rule fired on a citation.
2340
+ *
2341
+ * THE GATE IS ADJACENCY, and it is the part to read. Styling is stripped, then
2342
+ * the delimiter must be touching the id: `REQ-01;`, `;REQ-02`, `**REQ-01;**`
2343
+ * and the backticked form all qualify. `**REQ-01**;` does NOT — outside the
2344
+ * styling a `;` is sentence punctuation, which is why `REQ-01, see **REQ-7**;
2345
+ * next topic` is a citation and not a drop. An INVISIBLE anywhere in the token
2346
+ * qualifies without an adjacency test, because nobody types one on purpose, so
2347
+ * it is corruption rather than intent.
2348
+ *
2349
+ * Markdown styling on its own is NOT a trigger and NOT reported. It reaches
2350
+ * the skipped-text rider, which names the id without asserting a drop — but a
2351
+ * rider only exists inside a MESSAGE, and a message only exists when some rule
2352
+ * set `warn`. On a line where nothing else fires, `REQ-01, **REQ-02**` is
2353
+ * wholly silent. Saying it is "left to the rider" reads as coverage and is
2354
+ * not; #3697-19m pins the silence so this comment cannot drift back.
2355
+ *
2356
+ * NOT reached, stated rather than fixed, and the second member is WIDER than
2357
+ * this comment first claimed:
2358
+ * - anything inside a parenthetical. A parenthesis is this rule's citation
2359
+ * MARKER, never decoration to shave — `(REQ-02)` and `(ADR-7)` are the
2360
+ * same shape and the rule declines both.
2361
+ * - a decorated id whose prefix is on NO selected id: `REQ-01, FOO-02: x`
2362
+ * stays silent even when FOO-02 is real. Prefix agreement is what
2363
+ * separates a drop from a bare citation — `REQ-01, see ADR-7: section 3`
2364
+ * carries `ADR-7:` in exactly `REQ-01;`'s shape — and it is the module's
2365
+ * own idiom, not a new heuristic (reqEndpointsImplyInterior already
2366
+ * requires an agreeing prefix). The gate is NOT complete: a citation that
2367
+ * DOES share a selected prefix (`ADR-01, see ADR-7: sec 3`) still fires,
2368
+ * and nothing at token level separates that from a real drop. Saying so is
2369
+ * the honest position; a prose heuristic on "see" is exactly the free-text
2370
+ * detector this module exists to avoid.
2371
+ * The trade, plainly: an under-report on a rare shape over an over-report on a
2372
+ * common one — the same call the strict-dash rule makes.
2373
+ */
2374
+ function reqDelimiterDroppedIds(rawLine, selected, cap) {
2375
+ // MATCHED parenthetical spans are removed OUTRIGHT, not tracked as a depth.
2376
+ //
2377
+ // Two bugs died here. A running depth counter let an unbalanced `(` stay open
2378
+ // to end-of-line and swallow every real drop after it. Promoting a whole
2379
+ // token to immune because it CONTAINED a matched character then leaked the
2380
+ // other way: `REQ-01, REQ-02;(note) REQ-03` is one whitespace token, so the
2381
+ // parenthetical conferred immunity on the `REQ-02;` sitting outside it.
2382
+ // Deleting the span states what is actually meant — for this rule a citation
2383
+ // is not on the line — while an UNMATCHED paren is a typo and confers
2384
+ // nothing.
2385
+ //
2386
+ // Square brackets go too, exactly as the SELECTOR strips them: `[REQ-01;
2387
+ // REQ-02]` is the documented form and was silently dropping REQ-01.
2388
+ //
2389
+ // INVISIBLES STAY. They are the evidence this rule reads; the tokenizer
2390
+ // strips them for the classification rules, and the two sites answer two
2391
+ // different questions about the same character.
2392
+ const chars = [...String(rawLine).replace(/<!--[\s\S]*?-->/g, ' ')];
2393
+ const openStack = [];
2394
+ for (let i = 0; i < chars.length; i += 1) {
2395
+ if (chars[i] === '(')
2396
+ openStack.push(i);
2397
+ else if (chars[i] === ')' && openStack.length > 0) {
2398
+ const open = openStack.pop();
2399
+ for (let j = open; j <= i; j += 1)
2400
+ chars[j] = ' ';
2401
+ }
2402
+ }
2403
+ const line = chars.join('').replace(/[[\]]/g, '');
2404
+ // The prefixes actually SELECTED on this line. A dropped id must agree with
2405
+ // one of them — that is what separates a delimiter typo from a citation,
2406
+ // since `REQ-01, see ADR-7: sec 3` carries `ADR-7:` in exactly `REQ-01;`'s
2407
+ // shape. Same-prefix agreement is the module's own idiom, not a new
2408
+ // heuristic (see reqEndpointsImplyInterior).
2409
+ const selectedPrefixes = new Set();
2410
+ for (const id of selected) {
2411
+ const m = REQ_ID_PARTS_RE.exec(id);
2412
+ if (m)
2413
+ selectedPrefixes.add(m[1].toUpperCase());
2414
+ }
2415
+ const hits = [];
2416
+ for (const raw of line.split(/[,\s]+/)) {
2417
+ if (!raw || raw.length > cap)
2418
+ continue;
2419
+ // Strip STYLING only. What survives is the id plus whatever was glued
2420
+ // directly to it.
2421
+ const core = raw.replace(REQ_INVISIBLE_RE, '').replace(REQ_WRAPPER_RE, '');
2422
+ const m = REQ_DELIMITED_ID_RE.exec(core);
2423
+ if (!m)
2424
+ continue;
2425
+ const bare = m[1];
2426
+ // ADJACENCY IS THE WHOLE RULE. A `;`/`:` touching the id is a list
2427
+ // separator someone meant; the same character OUTSIDE the styling is
2428
+ // sentence punctuation — `see **REQ-7**; next topic` cites a requirement
2429
+ // while `**REQ-01;** REQ-02` fails to list one, and only the delimiter's
2430
+ // POSITION separates them. An INVISIBLE needs no adjacency test: nobody
2431
+ // types one on purpose, so anywhere in the token it is corruption rather
2432
+ // than intent.
2433
+ const hadAdjacentDelimiter = core !== bare;
2434
+ REQ_INVISIBLE_RE.lastIndex = 0;
2435
+ const hadInvisible = REQ_INVISIBLE_RE.test(raw);
2436
+ REQ_INVISIBLE_RE.lastIndex = 0;
2437
+ if (!hadAdjacentDelimiter && !hadInvisible)
2438
+ continue;
2439
+ if (selected.has(bare.toUpperCase()))
2440
+ continue;
2441
+ const parts = REQ_ID_PARTS_RE.exec(bare);
2442
+ if (parts && selectedPrefixes.has(parts[1].toUpperCase()))
2443
+ hits.push(bare);
2444
+ }
2445
+ return [...new Set(hits)];
2446
+ }
2447
+ /** Endpoints imply a dropped interior only on an AGREEING prefix and a gap > 1. */
2448
+ function reqEndpointsImplyInterior(a, b) {
2449
+ const ma = REQ_ID_PARTS_RE.exec(a);
2450
+ const mb = REQ_ID_PARTS_RE.exec(b);
2451
+ if (!ma || !mb)
2452
+ return false;
2453
+ if (ma[1].toUpperCase() !== mb[1].toUpperCase())
2454
+ return false;
2455
+ // BigInt keeps the gap exact for numbers past 2^53.
2456
+ const gap = BigInt(mb[2]) - BigInt(ma[2]);
2457
+ return gap > 1n || gap < -1n;
2458
+ }
2459
+ function analyzeRequirementsLine(rawLine) {
2460
+ const line = typeof rawLine === 'string' ? rawLine : '';
2461
+ // SELECTOR — byte-identical to the pre-extraction expression.
2462
+ const citedReqIds = line
2463
+ .replace(/[\[\]]/g, '')
2464
+ .split(/[,\s]+/)
2465
+ .map((r) => r.trim())
2466
+ .filter(Boolean)
2467
+ .filter((r) => REQ_ID_SHAPE_RE.test(r));
2468
+ // DETECTOR tokenization. A token with NO alphanumerics is shaved of brackets
2469
+ // ONLY, so `(..)` surfaces its operator while a bare `..` is not shaved to
2470
+ // nothing by the punctuation classes. A trailing run of 2+ dots is a glued
2471
+ // range operator (`REQ-01.. REQ-05`), not sentence punctuation — keep it.
2472
+ const tokens = line
2473
+ .replace(/<!--[\s\S]*?-->/g, ' ')
2474
+ // Invisibles are removed HERE, for the classification rules — an operator
2475
+ // spelled `<ZWSP>..<ZWSP>` is still the range operator, and a line of only
2476
+ // invisibles yields no tokens at all. R4 works on the RAW line and does
2477
+ // NOT strip them, because there they are the evidence of a dropped id.
2478
+ // Removing them in both places is what made `REQ-01<ZWSP>, REQ-02` silent;
2479
+ // removing them in neither is what made `REQ-01 <ZWSP>..<ZWSP> REQ-05`
2480
+ // silent. The two questions have two different answers.
2481
+ .replace(REQ_INVISIBLE_RE, '')
2482
+ .split(/[,\s]+/)
2483
+ .map((t) => {
2484
+ const trimmed = t.trim();
2485
+ if (!/[A-Za-z0-9]/.test(trimmed)) {
2486
+ return trimmed.replace(/^[[({]+/, '').replace(/[\])}]+$/, '');
2487
+ }
2488
+ if (/\.{2,}$/.test(trimmed)) {
2489
+ return trimmed.replace(/^[[({"'`*_~“”‘’]+/, '');
2490
+ }
2491
+ return trimmed.replace(/^[[({"'`*_~“”‘’]+/, '').replace(/[\])}.;:"'`*_~“”‘’]+$/, '');
2492
+ })
2493
+ .filter(Boolean);
2494
+ // Every predicate below is applied through the scan limit (Nit 6): a token
2495
+ // past the bound is not classified at all rather than classified expensively.
2496
+ const short = (t) => t.length <= REQ_TOKEN_SCAN_LIMIT;
2497
+ const rangeTokens = tokens.filter((t) => short(t) && REQ_RANGE_TOKEN_RE.test(t));
2498
+ const spacedRangePairs = [];
2499
+ tokens.forEach((t, i) => {
2500
+ const left = tokens[i - 1] ?? '';
2501
+ const right = tokens[i + 1] ?? '';
2502
+ if (
2503
+ // EVERY participant is capped, not just the operator. Capping the operator
2504
+ // alone left `<2049-char ID> .. <2049-char ID>` running REQ_ID_SHAPE_RE and
2505
+ // BigInt over both neighbours unbounded — the cap read as uniform and was
2506
+ // not (found by the round's pre-push review).
2507
+ short(t) &&
2508
+ short(left) &&
2509
+ short(right) &&
2510
+ REQ_PURE_RANGE_OP_RE.test(t) &&
2511
+ i > 0 &&
2512
+ i < tokens.length - 1 &&
2513
+ REQ_ID_SHAPE_RE.test(left) &&
2514
+ REQ_ID_SHAPE_RE.test(right) &&
2515
+ reqEndpointsImplyInterior(left, right)) {
2516
+ spacedRangePairs.push([left, right]);
2517
+ }
2518
+ });
2519
+ const hasSpacedRange = spacedRangePairs.length > 0;
2520
+ // A half-spaced range splits at the tokenizer, so R1's own `\s*` never sees
2521
+ // it. SYMBOL operators only on the LEAD arm: a word operator glued to an ID
2522
+ // is an ID — `TORANGE-05` is a valid prefix-agnostic REQ-ID. The TRAIL arm
2523
+ // keeps the word operators, because a valid ID must end in digits, so
2524
+ // `REQ-01through` can only be a glued typo.
2525
+ const hasGluedRangeFragment = tokens.some((t, i) => {
2526
+ // Neighbours capped for the same reason as R2 above.
2527
+ if (!short(t))
2528
+ return false;
2529
+ const before = tokens[i - 1] ?? '';
2530
+ const after = tokens[i + 1] ?? '';
2531
+ const lead = REQ_GLUED_RANGE_LEAD_RE.exec(t);
2532
+ if (lead &&
2533
+ i > 0 &&
2534
+ short(before) &&
2535
+ REQ_ID_SHAPE_RE.test(before) &&
2536
+ reqEndpointsImplyInterior(before, lead[1])) {
2537
+ return true;
2538
+ }
2539
+ const trail = REQ_GLUED_RANGE_TRAIL_RE.exec(t);
2540
+ return Boolean(trail &&
2541
+ i < tokens.length - 1 &&
2542
+ short(after) &&
2543
+ REQ_ID_SHAPE_RE.test(after) &&
2544
+ reqEndpointsImplyInterior(trail[1], after));
2545
+ });
2546
+ const leadToken = (tokens[0] ?? '').toUpperCase();
2547
+ // CENSUS (round 3, review finding Minor 4): the placeholder domain is what
2548
+ // GSD itself seeds plus what an author writes for "deliberately empty".
2549
+ // Reached: `TBD` — the ONLY machine-written seed, at the three phase.add /
2550
+ // -batch / -insert sites — and `None`, the author convention. NOT reached:
2551
+ // `N/A`, `Deferred`, `Pending`, `TBA`, `-`. Consequence, and it is now
2552
+ // ENFORCED rather than asserted: such a line selects zero IDs and warns
2553
+ // through R3b below, which is what #3697's acceptance criterion asks for
2554
+ // ("when it selects zero IDs from a line that is non-empty and is not the
2555
+ // `TBD` placeholder"). Round 3 shipped this same paragraph while R3's
2556
+ // ID-shape gate made it false for all five words — bare `Deferred` was
2557
+ // silent, `Deferred (see ADR-7)` warned — and the claim sat in three
2558
+ // artifacts with no test in either direction. Inferring placeholder-ness
2559
+ // from arbitrary prose is still the free-text heuristic this detector
2560
+ // avoids: R3b keys on the SELECTION being empty, never on what the prose
2561
+ // means.
2562
+ const placeholderLed = leadToken === 'TBD' || leadToken === 'NONE';
2563
+ const inertIdShaped = citedReqIds.length === 0 && !placeholderLed
2564
+ ? tokens.filter((t) => short(t) && t.includes('-') && REQ_ID_SUBSTRING_RE.test(t))
2565
+ : [];
2566
+ // R3b — the acceptance criterion's own narrow form. `tokens.length > 0` is
2567
+ // what keeps an empty line and a comment-only line silent: the tokenizer
2568
+ // strips `<!-- ... -->` before splitting, so `<!-- fill in -->` yields no
2569
+ // tokens and cannot reach this rule. Every other zero-selection,
2570
+ // non-placeholder line warns.
2571
+ const zeroSelectionInert = citedReqIds.length === 0 && !placeholderLed && tokens.length > 0;
2572
+ // R2 is the ONLY ambiguous rule — a tight range, a glued fragment and R3
2573
+ // residue each implicate ID-shaped text the selector demonstrably did not
2574
+ // take, so any of them means the line really did fail to parse. R2 is
2575
+ // ambiguous only when its OWN endpoints were selected: the detector shaves
2576
+ // brackets and the selector does not, so R2 can fire on a `(RANGE-02)` that
2577
+ // was never selected — a real drop, and the assertive channel is right there.
2578
+ // A token past the cap is NOT classified — and must therefore not be
2579
+ // silently discarded. Round 3's first cut of the uniform cap did exactly
2580
+ // that: a 2049-char range token warned before the round and went silent
2581
+ // after it, which is #3697's own defect introduced by the fix for a nit
2582
+ // (found by the round's pre-push review). The cap bounds the WORK, not the
2583
+ // warning — so an over-cap token that could carry an ID is reported as
2584
+ // unclassified. The test is `includes('-')`, a linear scan, never the
2585
+ // unanchored regex the cap exists to keep off these tokens.
2586
+ // ANY over-cap token, not just one carrying `-`. The first cut filtered on
2587
+ // `includes('-')` and therefore missed an over-cap OPERATOR:
2588
+ // `REQ-01 <2049 dots> REQ-05` warned before this round (R2 was uncapped) and
2589
+ // went silent after it. A token we could not examine makes the line
2590
+ // unverified whatever characters it happens to contain. Computed below,
2591
+ // where the selected set is available.
2592
+ // ID-shaped tokens the selector did not take, ANYWHERE on the line. This is
2593
+ // reported as a fact, never used to pick the channel: `(ADR-7)` and
2594
+ // `(REQ-02)` are indistinguishable by shape, so routing on it would put the
2595
+ // false "could not be parsed" claim back on a line carrying a citation.
2596
+ // Naming them lets the author see what the tokenizer skipped without the
2597
+ // warning asserting a verdict it cannot support in either direction.
2598
+ const selected = new Set(citedReqIds.map((id) => id.toUpperCase()));
2599
+ // A token the SELECTOR took has had its own SELECTION verified — the selector
2600
+ // is uncapped and anchored, so it examined the whole token. That is not the
2601
+ // same as "no rule was suppressed by it", and conflating the two was the
2602
+ // second continuation review's CLAIM J/K: two over-cap valid IDs either side
2603
+ // of `..` are both selected, both exempted, and R2 is capped — so a line that
2604
+ // warned before this round went silent, which is the very regression the
2605
+ // field exists to close, arriving through the fix for its own over-report.
2606
+ //
2607
+ // The exemption therefore applies only when nothing could have been
2608
+ // suppressed: an over-cap token that was selected AND has no neighbour that
2609
+ // could pair with it into a range. Everything else is unexaminable and is
2610
+ // reported as such.
2611
+ const couldPairIntoRange = (i) => {
2612
+ for (const n of [tokens[i - 1], tokens[i + 1]]) {
2613
+ if (n === undefined)
2614
+ continue;
2615
+ if (!short(n))
2616
+ return true;
2617
+ if (REQ_PURE_RANGE_OP_RE.test(n))
2618
+ return true;
2619
+ if (REQ_GLUED_RANGE_LEAD_RE.test(n) || REQ_GLUED_RANGE_TRAIL_RE.test(n))
2620
+ return true;
2621
+ }
2622
+ return false;
2623
+ };
2624
+ const oversizedTokens = tokens.filter((t, i) => !short(t) && (!selected.has(t.toUpperCase()) || couldPairIntoRange(i)));
2625
+ const unselectedIdShaped = tokens.filter((t) => short(t) && REQ_ID_SUBSTRING_RE.test(t) && !selected.has(t.toUpperCase()));
2626
+ // R4 runs on the RAW line, not on `tokens`: the shave that makes `REQ-01;`
2627
+ // look like a clean `REQ-01` is exactly the evidence this rule needs, so it
2628
+ // has to see the character the tokenizer removed.
2629
+ const delimiterDroppedIds = reqDelimiterDroppedIds(rawLine, selected, REQ_TOKEN_SCAN_LIMIT);
2630
+ const nothingDemonstrablyDropped = rangeTokens.length === 0 &&
2631
+ !hasGluedRangeFragment &&
2632
+ inertIdShaped.length === 0 &&
2633
+ // R4 is a DEMONSTRATED drop, so neither non-assertive voice — one claiming
2634
+ // nothing was dropped, the other that nothing could be checked — may speak
2635
+ // for a line carrying one.
2636
+ delimiterDroppedIds.length === 0 &&
2637
+ // R2 firing on an endpoint the selector did NOT take is itself a
2638
+ // demonstrated drop, and the assertive channel is right there. Vacuously
2639
+ // true when no spaced range fired, which is what makes this a strict
2640
+ // superset of the `!hasSpacedRange` guard the over-cap channel used to
2641
+ // carry — that channel's behaviour on a line with no spaced range is
2642
+ // unchanged, byte for byte.
2643
+ spacedRangePairs.every(([a, b]) => selected.has(a.toUpperCase()) && selected.has(b.toUpperCase()));
2644
+ const rangeReadingOnly = hasSpacedRange &&
2645
+ nothingDemonstrablyDropped &&
2646
+ // The cap bounds the WORK, never the warning. An over-cap token is not
2647
+ // classified by ANY rule (R1-R4 all skip it), so the voice whose entire
2648
+ // claim is that nothing was dropped has no basis to speak for this line.
2649
+ // It falls to the over-cap channel below instead — `unverified`, because
2650
+ // the line was not CHECKED; not `misparse`, because nothing on it
2651
+ // demonstrably failed to parse either. Round 7 review, Minor 1.
2652
+ oversizedTokens.length === 0;
2653
+ // Named rather than inlined into the return literal (round 3 review Minor 3):
2654
+ // this disjunction is the module's single most important predicate, and in
2655
+ // the literal a later edit that reordered a local below the `return` would be
2656
+ // a TDZ ReferenceError at runtime rather than an error at the reader's eye
2657
+ // level. R3b joins it here — see its field docs above for why it is not
2658
+ // gated on ID shape.
2659
+ const warn = rangeTokens.length > 0 ||
2660
+ hasSpacedRange ||
2661
+ hasGluedRangeFragment ||
2662
+ inertIdShaped.length > 0 ||
2663
+ zeroSelectionInert ||
2664
+ delimiterDroppedIds.length > 0 ||
2665
+ oversizedTokens.length > 0;
2666
+ return {
2667
+ citedReqIds,
2668
+ tokens,
2669
+ rangeTokens,
2670
+ hasSpacedRange,
2671
+ hasGluedRangeFragment,
2672
+ inertIdShaped,
2673
+ zeroSelectionInert,
2674
+ placeholderLed,
2675
+ spacedRangePairs,
2676
+ nothingDemonstrablyDropped,
2677
+ rangeReadingOnly,
2678
+ delimiterDroppedIds,
2679
+ oversizedTokens,
2680
+ unselectedIdShaped,
2681
+ warn,
2682
+ };
2683
+ }
2684
+ /**
2685
+ * Render the warning, or null when the line is clean.
2686
+ *
2687
+ * TWO CHANNELS, and the split is round 3's fix for review finding Major 3. The
2688
+ * detector cannot distinguish `RANGE-02 — RANGE-05` meaning a range from the
2689
+ * same text meaning an annotation separator; they are textually identical and
2690
+ * no token-level rule separates them. What the old single-channel message did
2691
+ * was resolve that ambiguity by ASSERTION — it told the author the line "could
2692
+ * not be parsed" and to rewrite it, on a line where every ID present had in
2693
+ * fact been selected and nothing had been dropped. That is a false statement
2694
+ * under the annotation reading and the #2334 over-warning class.
2695
+ *
2696
+ * Going silent instead is not available: the range reading is equally live, and
2697
+ * staying quiet on it re-opens the exact silent under-selection #3697 is about.
2698
+ * So the ambiguity is DISCLOSED rather than decided —
2699
+ *
2700
+ * * any rule other than R2 fired, or R2 fired on an endpoint that was not
2701
+ * selected → something ID-shaped was demonstrably NOT taken. The line did
2702
+ * fail to parse; say so plainly, as before.
2703
+ * * R2 alone fired and both its endpoints were selected → nothing was
2704
+ * dropped. State both readings and let the author pick; never claim a parse
2705
+ * failure that did not occur.
2706
+ */
2707
+ function formatRequirementsLineWarning(phaseNum, rawLine, analysis) {
2708
+ if (!analysis.warn)
2709
+ return null;
2710
+ const shown = String(rawLine).trim();
2711
+ const rangeRuleFired = analysis.rangeTokens.length > 0 || analysis.hasSpacedRange || analysis.hasGluedRangeFragment;
2712
+ // Tokens the selector skipped, stated as a fact in EITHER channel. `(ADR-7)`
2713
+ // and `(REQ-02)` are the same shape, so no rule can say which one matters —
2714
+ // but the author can, and only if the warning tells them. Round 3's first
2715
+ // cut instead let this drive the channel, which put the false "could not be
2716
+ // parsed" claim back on a line carrying a citation.
2717
+ // Names only what the rule-specific clauses did NOT already name, so the
2718
+ // assertive voice can carry it too without repeating itself.
2719
+ const alreadyNamed = new Set([...analysis.rangeTokens, ...analysis.inertIdShaped, ...analysis.delimiterDroppedIds].map((t) => t.toUpperCase()));
2720
+ const skippedNames = analysis.unselectedIdShaped.filter((t) => !alreadyNamed.has(t.toUpperCase()));
2721
+ // Named, then qualified. The `PREFIX-N-N` shape is the one the range rules
2722
+ // deliberately decline to act on, so the rider says WHY it might not be a
2723
+ // requirement instead of silently deciding it is not.
2724
+ const ambiguousNamed = skippedNames.filter((t) => REQ_AMBIGUOUS_NUMERIC_RE.test(t));
2725
+ const skipped = skippedNames.length > 0
2726
+ ? ` ID-shaped text on the line that was NOT selected: ${skippedNames.join(', ')}` +
2727
+ ` (parentheses are not stripped, unlike square brackets) — check whether any of it is a` +
2728
+ ` requirement.` +
2729
+ (ambiguousNamed.length > 0
2730
+ ? ` ${ambiguousNamed.join(', ')} may equally be a date or a sub-numbered id, which is` +
2731
+ ` why the range rules do not act on that shape.`
2732
+ : '')
2733
+ : '';
2734
+ // R4's clause. Named separately from the generic skipped-text rider because
2735
+ // this one is not a "check whether any of it is a requirement" hedge — the
2736
+ // token IS an ID, the selector demonstrably did not take it, and the cause
2737
+ // is nameable.
2738
+ const delimiterDropped = analysis.delimiterDroppedIds.length > 0
2739
+ ? ` ${analysis.delimiterDroppedIds.join(', ')} ${analysis.delimiterDroppedIds.length === 1 ? 'was' : 'were'}` +
2740
+ ` NOT selected: a \`;\` or \`:\` is glued to the ID, or it carries an invisible character, and` +
2741
+ ` the line is split on commas and whitespace only. Write each requirement as a bare ID` +
2742
+ ` separated by a comma.`
2743
+ : '';
2744
+ const oversized = analysis.oversizedTokens.length > 0
2745
+ ? ` One or more tokens exceed the ${REQ_TOKEN_SCAN_LIMIT}-character scan limit and were NOT` +
2746
+ ` classified, so this line may carry more than is reported here.`
2747
+ : '';
2748
+ if (analysis.rangeReadingOnly) {
2749
+ // AMBIGUOUS channel — the RANGE reading is what is at stake, not a parse
2750
+ // failure: every endpoint the range rule fired on was selected.
2751
+ //
2752
+ // What this voice must NOT do is claim the whole LINE is correct. It has
2753
+ // no basis for that: an unrelated `(REQ-02)` elsewhere on the line is
2754
+ // dropped by the selector and invisible to every rule, so "nothing needs
2755
+ // to change" is an affirmative false statement on exactly the input the
2756
+ // rule-scoped discriminator was built to reach. It speaks about the
2757
+ // SEPARATOR, and defers the rest to the skipped-text clause above.
2758
+ return {
2759
+ code: REQ_LINE_WARNING_CODE.rangeReading,
2760
+ message: `ROADMAP Phase ${phaseNum} **Requirements** line (\`${shown}\`) contains what reads as a range ` +
2761
+ `between two cited REQ-IDs. Range forms are not expanded, so no interior IDs were selected; ` +
2762
+ `the line selected: ${analysis.citedReqIds.join(', ')}. If a range was intended, rewrite it ` +
2763
+ `naming every requirement explicitly (e.g. \`REQ-01, REQ-02, REQ-03\`); if that separator is ` +
2764
+ `an annotation rather than a range, it selected nothing to expand and needs no change.` +
2765
+ delimiterDropped +
2766
+ skipped +
2767
+ oversized,
2768
+ };
2769
+ }
2770
+ if (analysis.oversizedTokens.length > 0 && analysis.nothingDemonstrablyDropped) {
2771
+ // A DEMONSTRATED drop outranks this voice, whose whole claim is that
2772
+ // NOTHING could be checked — both cannot be true at once. `REQ-01,
2773
+ // REQ-02: <over-cap token>` names REQ-02 in `delimiterDroppedIds` and
2774
+ // then reported `req-line-unverified`, whose message never mentions it:
2775
+ // the concrete, actionable finding masked by the token beside it. That
2776
+ // exclusion now lives in `nothingDemonstrablyDropped`, shared verbatim
2777
+ // with `rangeReadingOnly` above rather than duplicated here — the
2778
+ // duplication is what let the two drift (round 7 review, Minor 1). The
2779
+ // assertive channel already appends the over-cap rider, so routing a
2780
+ // demonstrated drop there loses nothing about the cap.
2781
+ // OVER-CAP channel — no rule could run, so no rule may be diagnosed. Say
2782
+ // exactly that: the line was not classified, rather than not a problem.
2783
+ return {
2784
+ code: REQ_LINE_WARNING_CODE.unverified,
2785
+ message: `ROADMAP Phase ${phaseNum} **Requirements** line (\`${shown.slice(0, 200)}…\`) could not be ` +
2786
+ `checked: one or more tokens exceed the ${REQ_TOKEN_SCAN_LIMIT}-character scan limit, so the ` +
2787
+ `REQ-ID selection on this line is unverified. Rewrite it as a comma-separated list ` +
2788
+ `(e.g. \`REQ-01, REQ-02, REQ-03\`).`,
2789
+ };
2790
+ }
2791
+ // ASSERTIVE channel — ID-shaped content was demonstrably not selected.
2792
+ // Deliberately says "selected", NOT "marked complete": a range whose
2793
+ // endpoints are themselves unregistered selects them and marks nothing, and a
2794
+ // warning that overclaims the write is a warning the reader learns to
2795
+ // distrust.
2796
+ const selectedDesc = analysis.citedReqIds.length > 0
2797
+ ? `the only REQ-ID(s) selected from it were: ${analysis.citedReqIds.join(', ')}`
2798
+ : 'it selected NO REQ-IDs at all, so nothing was marked';
2799
+ const unparsed = [...new Set([...analysis.rangeTokens, ...analysis.inertIdShaped])];
2800
+ // Only diagnose "range" when a range rule actually fired — an R3 warning on
2801
+ // non-range ID text must not claim one was written. And on the R3 path the
2802
+ // residue is ID-SHAPED TEXT, which is not the same claim as "a requirement we
2803
+ // failed to parse" (round 3 review finding Minor 4: `Deferred (see ADR-7)`
2804
+ // reported `ADR-7` as missed requirement content when it is a citation). Name
2805
+ // what it is, and name the placeholder escape the author actually has.
2806
+ const advice = rangeRuleFired
2807
+ ? ' Range forms are not expanded; rewrite the line naming every requirement explicitly ' +
2808
+ '(e.g. `REQ-01, REQ-02, REQ-03`).'
2809
+ : ' If these are requirements, name them explicitly (e.g. `REQ-01, REQ-02, REQ-03`); if the line ' +
2810
+ 'is deliberately empty, write `TBD` or `None` — any other wording selects nothing and warns.';
2811
+ return {
2812
+ code: REQ_LINE_WARNING_CODE.misparse,
2813
+ message: `ROADMAP Phase ${phaseNum} **Requirements** line could not be parsed as a comma-separated REQ-ID list ` +
2814
+ `(\`${shown}\`) - ${selectedDesc}.` +
2815
+ (unparsed.length > 0
2816
+ ? rangeRuleFired
2817
+ ? ` Unparsed text: ${unparsed.join(', ')}.`
2818
+ : ` ID-shaped text that was not selected: ${unparsed.join(', ')}.`
2819
+ : '') +
2820
+ advice +
2821
+ delimiterDropped +
2822
+ skipped +
2823
+ oversized,
2824
+ };
2825
+ }
2057
2826
  function cmdPhaseComplete(cwd, phaseNum, raw) {
2058
2827
  if (!phaseNum) {
2059
2828
  error('phase number required for phase complete');
@@ -2110,6 +2879,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2110
2879
  let roadmapUpdated = false;
2111
2880
  let stateUpdated = false;
2112
2881
  const warnings = [];
2882
+ // The machine kind of the Requirements-line warning, carried out to the JSON
2883
+ // result as its own field (round 4 review Major 3). Declared HERE, in the
2884
+ // same scope as `warnings[]`, because the assignment happens inside
2885
+ // withPlanningLock and the emission happens after it.
2886
+ let reqLineWarningCode;
2113
2887
  // ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
2114
2888
  // composition's preservation stage restores a curated frontmatter value
2115
2889
  // over a disagreeing derived one, that divergence is surfaced here rather
@@ -2310,7 +3084,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2310
3084
  // #2617: pass the project's runtime so the blocked-completion error below
2311
3085
  // suggests the command surface this runtime actually installs
2312
3086
  // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
2313
- const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
3087
+ const verificationStatus = readVerificationStatus(phaseFullDir, {
3088
+ runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd),
3089
+ convention: resolvePhaseIdConvention(cwd),
3090
+ });
2314
3091
  // #3057 B3: the staleness check inside readVerificationStatus can itself
2315
3092
  // fail (fs / scanPhasePlans / clock error), in which case `status` above
2316
3093
  // was routed as if nothing were stale (unchanged fail-open routing) — but
@@ -2513,27 +3290,22 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2513
3290
  // `else`, discarding this fact silently instead of surfacing it.
2514
3291
  const traceabilityWriteMisses = [];
2515
3292
  if (reqMatch) {
2516
- // #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
2517
- // the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
2518
- // and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
2519
- // so the ghost-ID / unregistered comparisons stay shape-symmetric.
2520
- // Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
2521
- // the ID list into a "cited REQ-ID": the shipped
2522
- // `templates/roadmap.md:32` line
2523
- // `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
2524
- // warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
2525
- // `**Requirements:** None` warned to register the literal word
2526
- // `None`. This subsumes the `TBD` placeholder special-case (`TBD`
2527
- // does not match the REQ-ID shape either); `isPlaceholderReqId` is
2528
- // kept below as a defensive no-op for any caller that still hands
2529
- // it a raw token.
2530
- const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
2531
- citedReqIds = reqMatch[1]
2532
- .replace(/[\[\]]/g, '')
2533
- .split(/[,\s]+/)
2534
- .map((r) => r.trim())
2535
- .filter(Boolean)
2536
- .filter((r) => REQ_ID_SHAPE_RE.test(r));
3293
+ // #2334 HIGH 3 + #3697: selection and under-selection detection both
3294
+ // live in `analyzeRequirementsLine` (module scope, above), extracted in
3295
+ // round 3 so the parser is directly testable — a closure in here is
3296
+ // reachable only by spawning the CLI, which no fast-check property test
3297
+ // can do. `citedReqIds` is byte-identical to the expression that stood
3298
+ // here; nothing about what phase-complete MARKS has changed.
3299
+ const reqLineAnalysis = analyzeRequirementsLine(reqMatch[1]);
3300
+ citedReqIds = reqLineAnalysis.citedReqIds;
3301
+ const reqLineWarning = formatRequirementsLineWarning(phaseNum, reqMatch[1], reqLineAnalysis);
3302
+ if (reqLineWarning) {
3303
+ warnings.push(reqLineWarning.message);
3304
+ // Carried out to the JSON result as its own field — see
3305
+ // REQ_LINE_WARNING_CODE for why it is not folded into
3306
+ // `warnings[]`.
3307
+ reqLineWarningCode = reqLineWarning.code;
3308
+ }
2537
3309
  for (const reqId of citedReqIds) {
2538
3310
  const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
2539
3311
  // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
@@ -2867,13 +3639,32 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2867
3639
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
2868
3640
  // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
2869
3641
  // `### Phase N (Cluster B): X` resolves. Captures are unchanged.
2870
- const phasePattern = new RegExp(`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
3642
+ //
3643
+ // #4078: the checkbox branch's separator is no longer colon-only. The
3644
+ // canonical phase lookup has accepted the bullet-house dash grammar
3645
+ // (`- [ ] **Phase N — Name**`, em/en-dash/hyphen/colon) since #2199
3646
+ // (`BULLET_PHASE_LINE_PATTERN`, roadmap-parser.cjs), but this scan still
3647
+ // required `:`, so on a roadmap whose original rows use the dash grammar
3648
+ // the ONLY parseable row above N was typically a later phase.add-ingested
3649
+ // colon-form phase — positionally last — and it won the numeric-minimum
3650
+ // vote it should never have been alone in (observed: 18 of 18 selected,
3651
+ // phases 2–17 skipped). The heading branch stays colon-only, mirroring
3652
+ // `findRoadmapPhaseInContent`'s heading grammar exactly; only the
3653
+ // checkbox branch widens, and only to the separators #2199 already
3654
+ // accepts. The two branches keep separate capture groups, normalized
3655
+ // just below the loop.
3656
+ const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
3657
+ `|-\\s*\\[[ xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gi');
2871
3658
  let pm;
2872
3659
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
3660
+ // #4078: normalize the two alternation branches' captures (heading
3661
+ // branch → groups 1/2, widened checkbox branch → groups 3/4).
3662
+ const pmNum = pm[1] ?? pm[3];
3663
+ const pmName = pm[2] ?? pm[4];
2873
3664
  // #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
2874
3665
  // already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
2875
3666
  // stage 2's heading scan must not advance into backlog headings either.
2876
- if (isSentinelPhaseId(pm[1]))
3667
+ if (isSentinelPhaseId(pmNum))
2877
3668
  continue;
2878
3669
  // #3701 review: the numeric MINIMUM above N, not the first row above N in
2879
3670
  // DOCUMENT order. This scan walks raw roadmap text, and one global regex
@@ -2889,10 +3680,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2889
3680
  // Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
2890
3681
  // the disk scan and for #2028's lowest-outstanding override; the roadmap
2891
3682
  // defines which phases EXIST and which milestone they belong to.
2892
- if (comparePhaseNum(pm[1], phaseNum) > 0
2893
- && (roadmapNextNum === null || comparePhaseNum(pm[1], roadmapNextNum) < 0)) {
2894
- roadmapNextNum = pm[1];
2895
- roadmapNextName = pm[2]
3683
+ if (comparePhaseNum(pmNum, phaseNum) > 0
3684
+ && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
3685
+ roadmapNextNum = pmNum;
3686
+ roadmapNextName = pmName
2896
3687
  .replace(/\(INSERTED\)/i, '')
2897
3688
  .trim()
2898
3689
  .toLowerCase()
@@ -2953,7 +3744,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2953
3744
  if (roadmapContent !== null) {
2954
3745
  try {
2955
3746
  const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
2956
- const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
3747
+ // #4078: the separator class here mirrors stage 2's widened checkbox
3748
+ // branch (and #2199's BULLET_PHASE_LINE_PATTERN): em/en-dash/hyphen/colon.
3749
+ // Without it, this lowest-outstanding override was blind to dash-grammar
3750
+ // rows and could not correct an out-of-order completion on the same
3751
+ // mixed-grammar roadmaps that broke stage 2.
3752
+ const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi');
2957
3753
  let cbm;
2958
3754
  let lowestOutstanding = null;
2959
3755
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
@@ -3036,14 +3832,53 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3036
3832
  const fmBody = frontmatterMod.stripFrontmatter(stateContent);
3037
3833
  const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
3038
3834
  stateExtractField(fmBody, 'Phase') != null;
3039
- const authoritativeFm = nextPhaseDisplayName
3040
- ? bodyHasPhaseField || !nextPhaseNum
3041
- ? { current_phase_name: nextPhaseDisplayName }
3042
- : {
3043
- current_phase: String(nextPhaseNum),
3044
- current_phase_name: nextPhaseDisplayName,
3835
+ // #4129: the POST-completion progress counters, derived from the very
3836
+ // ROADMAP this transaction just mutated (still in memory — it hits disk
3837
+ // only at writePlanningFileSet, AFTER this content was assembled).
3838
+ // buildStateFrontmatter's disk scan inside syncAndPreserveStateMd
3839
+ // reads the PRE-completion ROADMAP (and any stale-dated sibling
3840
+ // verification), so without this intent the persisted counter failed
3841
+ // to increment on the completing phase's own transaction. Routed
3842
+ // through the #2736 authoritativeFm seam's object direction: the
3843
+ // pre-preservation merge makes it the derived truth the ratchet
3844
+ // compares, and the post-preservation re-assert (completedOnlyRaise)
3845
+ // is a floor no preservation branch can drop below. clampPercent is
3846
+ // completePhaseCore's own percent formula (state-transition.cts),
3847
+ // reused so the frontmatter and the body `Progress:` line agree.
3848
+ const postCompletionRoadmapScope = roadmapContent !== null
3849
+ ? extractCurrentMilestone(roadmapContent, cwd)
3850
+ : null;
3851
+ const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
3852
+ ? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
3853
+ : null;
3854
+ const authoritativeProgress = postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
3855
+ ? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
3856
+ ? {
3857
+ completed_phases: postCompletionRoadmapProgress.completedPhases,
3858
+ percent: clampPercentForIntent(postCompletionRoadmapProgress.completedPhases, postCompletionRoadmapProgress.totalPhases),
3045
3859
  }
3860
+ : { completed_phases: postCompletionRoadmapProgress.completedPhases }
3046
3861
  : undefined;
3862
+ const authoritativeFm = authoritativeProgress
3863
+ ? {
3864
+ ...(nextPhaseDisplayName
3865
+ ? bodyHasPhaseField || !nextPhaseNum
3866
+ ? { current_phase_name: nextPhaseDisplayName }
3867
+ : {
3868
+ current_phase: String(nextPhaseNum),
3869
+ current_phase_name: nextPhaseDisplayName,
3870
+ }
3871
+ : {}),
3872
+ progress: authoritativeProgress,
3873
+ }
3874
+ : nextPhaseDisplayName
3875
+ ? bodyHasPhaseField || !nextPhaseNum
3876
+ ? { current_phase_name: nextPhaseDisplayName }
3877
+ : {
3878
+ current_phase: String(nextPhaseNum),
3879
+ current_phase_name: nextPhaseDisplayName,
3880
+ }
3881
+ : undefined;
3047
3882
  // ADR-3408 §8.3 / #3469: this deliberately bypasses
3048
3883
  // readModifyWriteStateMd (STATE.md is committed atomically with
3049
3884
  // ROADMAP/REQUIREMENTS), so it calls the single write-seam
@@ -3146,6 +3981,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3146
3981
  auto_pruned: autoPruned,
3147
3982
  warnings,
3148
3983
  has_warnings: warnings.length > 0,
3984
+ // ADDITIVE, never a change to `warnings[]`'s element shape — that array is
3985
+ // a documented string[] consumed by execute-phase.md, so re-typing it
3986
+ // would break a shipped output contract. Absent when the line is clean.
3987
+ ...(reqLineWarningCode ? { requirements_line_warning: { code: reqLineWarningCode } } : {}),
3149
3988
  verification_stale_check_indeterminate: staleCheckIndeterminate,
3150
3989
  milestone_conflict: milestoneConflict,
3151
3990
  preservation_warnings: preservationWarnings,
@@ -3207,9 +4046,13 @@ module.exports = {
3207
4046
  cmdPhaseAdd,
3208
4047
  cmdPhaseAddBatch,
3209
4048
  cmdPhaseMvpMode,
4049
+ cmdPhaseTddApplicable,
3210
4050
  cmdPhaseInsert,
3211
4051
  cmdPhaseRemove,
3212
4052
  cmdPhaseComplete,
4053
+ analyzeRequirementsLine,
4054
+ formatRequirementsLineWarning,
4055
+ REQ_LINE_WARNING_CODE,
3213
4056
  cmdPhaseUatPassed,
3214
4057
  cmdPhaseListPlans,
3215
4058
  computeDependencyLevels,