@opengsd/gsd-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -12,20 +12,26 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const clock_cjs_1 = require("./clock.cjs");
15
+ const pattern_cjs_1 = require("./pattern.cjs");
16
+ const text_lines_cjs_1 = require("./text-lines.cjs");
15
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
18
  const ioMod = require("./io.cjs");
17
19
  const { output, error } = ioMod;
18
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
21
  const phaseIdMod = require("./phase-id.cjs");
20
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources } = phaseIdMod;
22
+ const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, isSentinelPhaseId, scopeToPhase } = phaseIdMod;
21
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
24
  const phaseLocatorMod = require("./phase-locator.cjs");
23
- const { findPhaseInternal } = phaseLocatorMod;
25
+ const { findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const planningScopeMod = require("./planning-scope.cjs");
28
+ const { SCOPE } = planningScopeMod;
24
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
30
  const roadmapParserModule = require("./roadmap-parser.cjs");
26
- const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
31
+ const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows } = roadmapParserModule;
27
32
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
28
33
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
34
+ const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
29
35
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
30
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
37
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -34,13 +40,13 @@ const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace;
34
40
  const scanPhasePlans = require("./plan-scan.cjs");
35
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
42
  const coreUtils = require("./core-utils.cjs");
37
- const { countMatchedSummaries } = coreUtils;
43
+ const { countMatchedSummaries, findUnsummarizedPlans } = coreUtils;
38
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
45
  const frontmatter = require("./frontmatter.cjs");
40
46
  const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
41
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
48
  const verificationMod = require("./verification.cjs");
43
- const { readVerificationStatus } = verificationMod;
49
+ const { isPhaseComplete } = verificationMod;
44
50
  // ─── coerceTruthToString ──────────────────────────────────────────────────────
45
51
  /**
46
52
  * Coerce an arbitrary YAML scalar/object into a string for cross-cutting
@@ -87,23 +93,37 @@ function countPhasePlansAndSummaries(phaseDir) {
87
93
  phaseFiles = node_fs_1.default.readdirSync(phaseDir);
88
94
  }
89
95
  catch { /* empty */ }
96
+ // #3511: scope the raw listing to this phase dir before the
97
+ // phase-numbered-artifact predicates (hasContext/hasResearch) — planCount/
98
+ // summaryCount above stay on scanPhasePlans's own unscoped listing since a
99
+ // PLAN/SUMMARY leading number is a plan sequence number, not a phase
100
+ // number. Mirrors core-utils.cts's getPhaseFileStats.
101
+ const scopedFiles = scopeToPhase(phaseFiles, node_path_1.default.basename(phaseDir));
90
102
  return {
91
103
  planCount,
92
104
  summaryCount,
93
- hasContext: findContextMdIn(phaseFiles) !== null,
94
- hasResearch: phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
105
+ hasContext: findContextMdIn(scopedFiles) !== null,
106
+ hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
95
107
  };
96
108
  }
97
109
  // `phaseMarkdownRegexSource` lives in phase-id.cjs (#3537) and is imported above.
98
110
  // ─── searchPhaseInContent ─────────────────────────────────────────────────────
111
+ /**
112
+ * Build the phase-heading regex used by `searchPhaseInContent` for a given
113
+ * pre-escaped phase source. Extracted (#3412) so tests can assert against the
114
+ * exact production pattern instead of hand-duplicating it.
115
+ * #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
116
+ */
117
+ function buildPhaseHeadingRegex(escapedPhase) {
118
+ return new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
119
+ }
99
120
  /**
100
121
  * Search for a phase header (and its section) within the given content string.
101
122
  * Returns a result object if found (either a full match or a malformed_roadmap
102
123
  * checklist-only match), or null if the phase is not present at all.
103
124
  */
104
125
  function searchPhaseInContent(content, escapedPhase, phaseNum) {
105
- // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
106
- const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
126
+ const headingPattern = buildPhaseHeadingRegex(escapedPhase);
107
127
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
108
128
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
109
129
  const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
@@ -173,7 +193,8 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
173
193
  * phase resolution as `roadmap.get-phase` — not a milestone-only subset.
174
194
  */
175
195
  function getRoadmapPhaseWithFallback(cwd, phaseNum) {
176
- if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum)))
196
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
197
+ if (isSentinelPhaseId(stripProjectCodePrefix(phaseNum)))
177
198
  return null;
178
199
  const roadmapPath = planningPaths(cwd).roadmap;
179
200
  // Read directly rather than gating on fs.existsSync: existsSync returns false
@@ -206,7 +227,8 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
206
227
  }
207
228
  // ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
208
229
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
209
- if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum))) {
230
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
231
+ if (isSentinelPhaseId(stripProjectCodePrefix(phaseNum))) {
210
232
  output({ found: false, phase_number: phaseNum }, raw, '');
211
233
  return;
212
234
  }
@@ -242,6 +264,17 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
242
264
  if (!malformed)
243
265
  malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
244
266
  }
267
+ // #3577: no heading or checklist entry matched — fall back to a
268
+ // markdown-table row declaration (the same last-resort tier
269
+ // getRoadmapPhaseInternal gained). Zero-pad-tolerant id compare (#3572
270
+ // lesson: the declared form may be padded).
271
+ const stripPad = (s) => s.replace(/^0+(?=.)/, '');
272
+ const tableHit = collectTablePhaseRows(milestoneContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum))
273
+ ?? collectTablePhaseRows(fullContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum));
274
+ if (tableHit) {
275
+ output({ found: true, phase_number: phaseNum, phase_name: tableHit.name ?? `Phase ${tableHit.id}`, goal: null, section: tableHit.row.trim() }, raw, tableHit.row.trim());
276
+ return;
277
+ }
245
278
  if (malformed) {
246
279
  output(malformed, raw, '');
247
280
  return;
@@ -252,16 +285,16 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
252
285
  error('Failed to read ROADMAP.md: ' + e.message);
253
286
  }
254
287
  }
255
- // ─── cmdRoadmapAnalyze ────────────────────────────────────────────────────────
256
- function cmdRoadmapAnalyze(cwd, raw) {
257
- const roadmapPath = planningPaths(cwd).roadmap;
258
- if (!node_fs_1.default.existsSync(roadmapPath)) {
259
- output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
260
- return;
261
- }
262
- const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
263
- const content = extractCurrentMilestone(rawContent, cwd);
264
- const phasesDir = planningPaths(cwd).phases;
288
+ /**
289
+ * #3165: scan `content` for phase-detail headings (`##/###/#### Phase N: Name`)
290
+ * and enrich each with its on-disk plan/summary/completion status and ROADMAP
291
+ * checkbox. Pure extraction over `content` + the pre-built `phaseDirNames`
292
+ * lookup index — no milestone windowing of its own; the caller chooses the
293
+ * content (scoped window or fallback). Extracted verbatim from
294
+ * `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
295
+ * same enrichment, not a second derivation.
296
+ */
297
+ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
265
298
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
266
299
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
267
300
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
@@ -272,29 +305,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
272
305
  const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
273
306
  const phases = [];
274
307
  let match;
275
- // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
276
- // phases. They legitimately have no directory and must never be surfaced as
277
- // current/next phase or counted in phase_count. Mirrors the engine-wide
278
- // sentinel convention (phase-id getMilestoneFromPhaseId, roadmap-command-router
279
- // SENTINELS, the #1445 /^999/ progress filters). (#1580)
280
- const isSentinelPhase = (num) => {
281
- const major = parseInt(num, 10);
282
- return major === 0 || major === 999;
283
- };
284
- // Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
285
- const _phaseDirNames = (() => {
286
- try {
287
- return node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
288
- .filter(e => e.isDirectory())
289
- .map(e => e.name);
290
- }
291
- catch {
292
- return [];
293
- }
294
- })();
295
308
  while ((match = phasePattern.exec(content)) !== null) {
296
309
  const phaseNum = match[1];
297
- if (isSentinelPhase(phaseNum))
310
+ if (isSentinelPhaseId(phaseNum))
298
311
  continue;
299
312
  const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
300
313
  // Extract goal from the section
@@ -319,20 +332,27 @@ function cmdRoadmapAnalyze(cwd, raw) {
319
332
  let summaryCount = 0;
320
333
  let hasContext = false;
321
334
  let hasResearch = false;
322
- // DEAD catch removed (#2245 audit): _phaseDirNames.find(...) is a pure
335
+ // DEAD catch removed (#2245 audit): matchPhaseDirs(...) is a pure
323
336
  // array lookup on an already-resolved string array, and
324
337
  // countPhasePlansAndSummaries is itself fully defensive (its own
325
338
  // readdirSync is self-guarded, and it delegates to scanPhasePlans, which
326
339
  // never throws) — nothing in this block can throw, so the try/catch could
327
340
  // never be triggered.
328
- const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
341
+ const dirMatch = matchPhaseDirs(phaseDirNames, normalized).matches[0];
329
342
  if (dirMatch) {
330
343
  const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
331
344
  planCount = counts.planCount;
332
345
  summaryCount = counts.summaryCount;
333
346
  hasContext = counts.hasContext;
334
347
  hasResearch = counts.hasResearch;
335
- if (summaryCount >= planCount && planCount > 0)
348
+ // ADR-3180 §7.4 (issue #3186, disk-strict, #3168 fix): route "is this
349
+ // phase complete" through the canonical owner (`isPhaseComplete`),
350
+ // which calls readVerificationStatus UNCONDITIONALLY — plan count is
351
+ // NOT a precondition, so a zero-plan phase with a passing
352
+ // `*-VERIFICATION.md` reports complete here too, not just via
353
+ // `phase.complete`.
354
+ const completionResult = isPhaseComplete(node_path_1.default.join(phasesDir, dirMatch));
355
+ if (completionResult.value.complete)
336
356
  diskStatus = 'complete';
337
357
  else if (summaryCount > 0)
338
358
  diskStatus = 'partial';
@@ -345,19 +365,23 @@ function cmdRoadmapAnalyze(cwd, raw) {
345
365
  else
346
366
  diskStatus = 'empty';
347
367
  }
348
- // Check ROADMAP checkbox status.
349
- // #3537: padding-tolerant fragment — the heading discovered above may use
350
- // a different padding than the summary-bullet checkbox below it (mixed
351
- // padding inside one ROADMAP is legal and seen in real projects).
368
+ // Check ROADMAP checkbox status. #3537: padding-tolerant fragment — the
369
+ // heading discovered above may use a different padding than the
370
+ // summary-bullet checkbox below it (mixed padding inside one ROADMAP is
371
+ // legal and seen in real projects).
372
+ //
373
+ // ADR-3180 §7.4 (disk-strict, #2957, maintainer decision 2026-08-08):
374
+ // `roadmapComplete` is reported below as metadata ONLY — it carries NO
375
+ // machine authority over `diskStatus`. The override that used to trust a
376
+ // ticked checkbox over disk file structure is DELETED, not generalized
377
+ // (#2957: "a ticked ROADMAP checkbox is a human annotation with no
378
+ // machine authority"). A phase marked complete solely by a ticked
379
+ // checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
380
+ // reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
381
+ // Decision 3).
352
382
  const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
353
383
  const checkboxMatch = content.match(checkboxPattern);
354
384
  const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
355
- // If roadmap marks phase complete, trust that over disk file structure.
356
- // Phases completed before GSD tracking (or via external tools) may lack
357
- // the standard PLAN/SUMMARY pairs but are still done.
358
- if (roadmapComplete && diskStatus !== 'complete') {
359
- diskStatus = 'complete';
360
- }
361
385
  phases.push({
362
386
  number: phaseNum,
363
387
  name: phaseName,
@@ -372,16 +396,110 @@ function cmdRoadmapAnalyze(cwd, raw) {
372
396
  roadmap_complete: roadmapComplete,
373
397
  });
374
398
  }
375
- // Extract milestone info
376
- const milestones = [];
377
- const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi;
378
- let mMatch;
379
- while ((mMatch = milestonePattern.exec(content)) !== null) {
380
- milestones.push({
381
- heading: mMatch[1].trim(),
382
- version: 'v' + mMatch[2],
399
+ // #3577: markdown-table row declarations join the enumeration — same
400
+ // enrichment contract as headings (disk counts when the directory exists),
401
+ // zero-pad-tolerant duplicate guard so an id declared in BOTH a heading and
402
+ // a table counts once.
403
+ const stripPadA = (s) => s.replace(/^0+(?=.)/, '');
404
+ const seen = new Set(phases.map((ph) => stripPadA(ph.number)));
405
+ for (const tr of collectTablePhaseRows(content)) {
406
+ if (seen.has(stripPadA(tr.id)))
407
+ continue;
408
+ const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id)).matches[0];
409
+ let tPlanCount = 0;
410
+ let tSummaryCount = 0;
411
+ let tHasContext = false;
412
+ let tHasResearch = false;
413
+ if (dirMatchA) {
414
+ const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatchA));
415
+ tPlanCount = counts.planCount;
416
+ tSummaryCount = counts.summaryCount;
417
+ tHasContext = node_fs_1.default.existsSync(node_path_1.default.join(phasesDir, dirMatchA, 'CONTEXT.md'));
418
+ tHasResearch = node_fs_1.default.existsSync(node_path_1.default.join(phasesDir, dirMatchA, 'RESEARCH.md'));
419
+ }
420
+ phases.push({
421
+ number: tr.id,
422
+ name: tr.name ?? `Phase ${tr.id}`,
423
+ goal: null,
424
+ mode: null,
425
+ depends_on: null,
426
+ plan_count: tPlanCount,
427
+ summary_count: tSummaryCount,
428
+ has_context: tHasContext,
429
+ has_research: tHasResearch,
430
+ disk_status: dirMatchA ? 'ok' : 'no_directory',
431
+ roadmap_complete: false,
383
432
  });
384
433
  }
434
+ return phases;
435
+ }
436
+ function cmdRoadmapAnalyze(cwd, raw) {
437
+ const roadmapPath = planningPaths(cwd).roadmap;
438
+ if (!node_fs_1.default.existsSync(roadmapPath)) {
439
+ output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
440
+ return;
441
+ }
442
+ const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
443
+ // #3184/#3165: use the scoped variant so a truncated window is a
444
+ // distinguishable signal in the output instead of a silent `phase_count: 0`
445
+ // indistinguishable from a genuinely empty milestone.
446
+ const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
447
+ const phasesDir = planningPaths(cwd).phases;
448
+ // Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
449
+ // #3185 exemption (documented reason, not a file allowlist — ADR-3180
450
+ // Decision 4a): this is a heading->directory LOOKUP INDEX, not a milestone
451
+ // enumeration. It must see the PHYSICAL set so a heading already scoped by
452
+ // extractCurrentMilestoneScoped above can find its directory; filtering it
453
+ // through listMilestonePhaseDirs would scope the same set twice.
454
+ const _phaseDirNames = (() => {
455
+ try {
456
+ return node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
457
+ .filter(e => e.isDirectory())
458
+ .map(e => e.name);
459
+ }
460
+ catch {
461
+ return [];
462
+ }
463
+ })();
464
+ // Scan the scoped milestone window for phase-detail headings and enrich each
465
+ // with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
466
+ // the SAME enrichment re-runs on the fallback below — not a second copy.
467
+ let phases = collectAnalyzePhases(content, phasesDir, _phaseDirNames);
468
+ // `effectiveContent` is what the downstream checklist scan (missing_details)
469
+ // iterates. Defaults to the scoped window; switched to the fallback document
470
+ // when the recovery path below fires, so a phase found via fallback is not
471
+ // falsely reported as "in checklist but missing a detail section."
472
+ let effectiveContent = content;
473
+ // #3165: recover phase_count when the scoped window came back empty. A
474
+ // CLOSED milestone heading sitting between the active milestone heading and
475
+ // its own phase-detail sections closes `extractCurrentMilestoneScoped`'s
476
+ // window over prose only — `phases` is empty, and the consuming resume gate
477
+ // (`workflows/next.md` Route 0) iterates `.phases[]` so a safety invariant
478
+ // silently never runs. When the window is suspect (non-COMPLETE scope), the
479
+ // scoped scan found nothing, AND phase directories exist on disk (real
480
+ // evidence phases exist), re-scan the shipped-milestone-stripped document so
481
+ // the phase list reflects the real phases instead of a silent zero. The
482
+ // `scope` field retains its non-COMPLETE value downstream so consumers can
483
+ // still tell this is a best-effort count, not a cleanly scoped one. Position
484
+ // alone cannot attribute phases to the active vs the intervening closed
485
+ // milestone, so this never claims COMPLETE — it converts silence into a
486
+ // populated, flagged result.
487
+ if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
488
+ const fallbackContent = stripShippedMilestones(rawContent);
489
+ const fallbackPhases = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames);
490
+ if (fallbackPhases.length > 0) {
491
+ phases = fallbackPhases;
492
+ effectiveContent = fallbackContent;
493
+ }
494
+ }
495
+ // Extract milestone info. #3216: routed through the canonical
496
+ // `listMilestoneHeadings` owner (deleted the inline `##…` regex, which
497
+ // truncated names at a parenthetical and had no phase-heading exclusion)
498
+ // rather than re-deriving the enumeration here.
499
+ const milestones = listMilestoneHeadings(content).map((m) => ({
500
+ heading: m.heading,
501
+ version: m.version,
502
+ }));
385
503
  // Find current and next phase
386
504
  const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
387
505
  const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
@@ -399,11 +517,43 @@ function cmdRoadmapAnalyze(cwd, raw) {
399
517
  const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)/gi;
400
518
  const checklistPhases = new Set();
401
519
  let checklistMatch;
402
- while ((checklistMatch = checklistPattern.exec(content)) !== null) {
520
+ while ((checklistMatch = checklistPattern.exec(effectiveContent)) !== null) {
403
521
  checklistPhases.add(checklistMatch[1]);
404
522
  }
405
523
  const detailPhases = new Set(phases.map(p => p.number));
406
- const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhase(p));
524
+ const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhaseId(p));
525
+ // #3217 (ADR-3180 §7.6 rules 3-4): `progress_percent` used to accumulate
526
+ // `totalPlans`/`totalSummaries` above — a heading-matched enumeration
527
+ // (`phasePattern` over the milestone-windowed `content`) paired against
528
+ // `_phaseDirNames`, a DELIBERATELY unscoped physical directory listing
529
+ // (see its own comment above: it is a heading->directory lookup index,
530
+ // not a milestone enumeration). That set is not the same set
531
+ // `listMilestonePhaseDirs` scopes for `query progress` / `stats` (#3185
532
+ // Phase 3), so `progress_percent` could silently diverge from both siblings
533
+ // on the same project (rule 3). Route `progress_percent`'s own
534
+ // numerator/denominator through the single scoped owner instead — mirrors
535
+ // cmdProgressRender/cmdStats's own aggregation — and withhold the
536
+ // percentage entirely when THAT scope is not COMPLETE (rule 4), never
537
+ // returning `0` for "could not compute". This does not touch `total_plans`
538
+ // / `total_summaries` / `phases` / `completed_phases` above — those stay
539
+ // the heading-matched detail view; only `progress_percent`'s own inputs
540
+ // move onto the scoped owner.
541
+ let scopedTotalPlans = 0;
542
+ let scopedTotalSummaries = 0;
543
+ let progressScope = SCOPE.UNREADABLE;
544
+ try {
545
+ const { value: progressDirs, scope: scopedResult } = listMilestonePhaseDirs(phasesDir, { cwd });
546
+ progressScope = scopedResult;
547
+ for (const dir of progressDirs) {
548
+ const scan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
549
+ scopedTotalPlans += scan.planCount;
550
+ scopedTotalSummaries += scan.summaryCount;
551
+ }
552
+ }
553
+ catch { /* progressScope stays the pessimistic SCOPE.UNREADABLE default */ }
554
+ const progressPercent = progressScope === SCOPE.COMPLETE
555
+ ? (0, phase_lifecycle_cjs_1.clampPercent)(scopedTotalSummaries, scopedTotalPlans)
556
+ : null;
407
557
  const result = {
408
558
  milestones,
409
559
  phases,
@@ -411,13 +561,65 @@ function cmdRoadmapAnalyze(cwd, raw) {
411
561
  completed_phases: completedPhases,
412
562
  total_plans: totalPlans,
413
563
  total_summaries: totalSummaries,
414
- progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0,
564
+ progress_percent: progressPercent,
565
+ // #3217 finding 2: `progress_percent` is gated by a SECOND, independently
566
+ // computed `listMilestonePhaseDirs` scope (`progressScope` above) — not
567
+ // by the top-level `scope` field, which describes the heading-windowing
568
+ // identity `phases`/`total_plans`/`total_summaries`/`completed_phases`
569
+ // were built from. Those two scopes can legitimately disagree (e.g.
570
+ // `scope: "complete"` alongside a genuinely unreadable phases directory),
571
+ // and per the documented contract "scope tells you whether the counts
572
+ // are trustworthy", a consumer seeing `progress_percent: null` needs a
573
+ // field to tell WHY without reading source. Exposing `progress_scope`
574
+ // (rather than reconciling the two scopes into one, or re-deriving
575
+ // `total_plans`/`phases`/etc. from the scoped set) preserves the
576
+ // deliberate, already-documented choice a few lines up: `phases`/
577
+ // `total_plans`/`total_summaries`/`completed_phases` stay the
578
+ // heading-matched detail view (`_phaseDirNames` is a lookup index, not a
579
+ // milestone enumeration — see its comment); only `progress_percent`'s own
580
+ // inputs move onto the scoped owner.
581
+ progress_scope: progressScope,
415
582
  current_phase: currentPhase ? currentPhase.number : null,
416
583
  next_phase: nextPhase ? nextPhase.number : null,
417
584
  missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
585
+ // #3184/#3165: distinguishes a genuinely empty milestone (`scope:
586
+ // "complete"`, `phase_count: 0`) from a window that could not be fully
587
+ // resolved (`"truncated"` / `"unscoped"` / `"unreadable"`) — those cases
588
+ // were previously output-identical.
589
+ scope,
418
590
  };
419
591
  output(result, raw, undefined);
420
592
  }
593
+ // ─── cmdRoadmapMilestoneScope ────────────────────────────────────────────────
594
+ /**
595
+ * #3262 (write-time milestone-scope guard): read-only probe emitting the
596
+ * current milestone window's IDENTITY — its scope classification and the
597
+ * phase ids it declares — so the edit-phase workflow can capture it before
598
+ * its in-place section write, re-derive it after, and roll back on any
599
+ * change. This is the milestone-scope sibling of the workflow's existing
600
+ * `depends_on` gate, expressed as a command because the workflow's write is
601
+ * assistant-driven free-text surgery, not a code path.
602
+ *
603
+ * Deliberately NOT `cmdRoadmapAnalyze`: analyze's #3165 recovery re-populates
604
+ * `phases` from the shipped-milestone-stripped document when the scoped
605
+ * window is suspect, which is right for a human-facing progress report and
606
+ * wrong for a before/after equality probe — the refill would mask exactly
607
+ * the narrowing this guard exists to detect. This probe reports the RAW
608
+ * window (`extractCurrentMilestoneScoped` + `scanMilestonePhaseIds`), no
609
+ * fallback, so a narrowed window is always visible as a changed phase set.
610
+ */
611
+ function cmdRoadmapMilestoneScope(cwd, raw) {
612
+ const roadmapPath = planningPaths(cwd).roadmap;
613
+ if (!node_fs_1.default.existsSync(roadmapPath)) {
614
+ output({ error: 'ROADMAP.md not found', scope: SCOPE.UNREADABLE, phases: [], phase_count: 0 }, raw, undefined);
615
+ return;
616
+ }
617
+ const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
618
+ const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
619
+ // Document order (Set insertion order) — deterministic for a given document.
620
+ const phases = [...scanMilestonePhaseIds(window)];
621
+ output({ scope, phases, phase_count: phases.length }, raw, undefined);
622
+ }
421
623
  // ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
422
624
  /**
423
625
  * Scope a ROADMAP.md content string down to its "Progress table" writable
@@ -474,10 +676,34 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
474
676
  // completion date until the phase's verification status is 'passed', matching
475
677
  // cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
476
678
  // moment the last plan summary landed — before gsd-verifier had verified.
679
+ //
680
+ // ADR-3180 §7.4 (issue #3186, disk-strict): routed through the canonical
681
+ // owner (`isPhaseComplete`) instead of hand-rolling `summaryCount >=
682
+ // planCount && verificationPassed` locally — the owner calls
683
+ // readVerificationStatus UNCONDITIONALLY, so `isComplete` here always
684
+ // agrees with `roadmap analyze` / `init manager` / `phase complete` for
685
+ // the same phase (ADR-3180 §7.4's headline: one predicate for the read
686
+ // path and the write path).
477
687
  const phaseDir = node_path_1.default.join(cwd, phaseInfo.directory);
478
- const verificationResult = readVerificationStatus(phaseDir);
479
- const verificationPassed = verificationResult.status === 'passed';
480
- const isComplete = summaryCount >= planCount && verificationPassed;
688
+ const completionResult = isPhaseComplete(phaseDir);
689
+ const verificationResult = completionResult.value.verification;
690
+ // #2648 precedent, applied at this write site (ADR-3180 §7.4 / #3186):
691
+ // `isPhaseComplete` deliberately carries NO plan-count precondition — the
692
+ // owner's `complete` is exactly `verification.status === 'passed'`, and
693
+ // that must stay true (disk-strict: a zero-plan phase with a passing
694
+ // `*-VERIFICATION.md` IS complete, #3168). But `readVerificationStatus`'s
695
+ // staleness check only compares SUMMARY mtimes against the verification
696
+ // file — it has no idea a NEW plan was added after the file was written,
697
+ // so a still-fresh `passed` verification says nothing about a plan added
698
+ // afterward. This command WRITES a checkbox and a completion date into
699
+ // ROADMAP.md, a materially stronger claim than "verification passed" —
700
+ // mirroring cmdPhaseComplete's own fail-closed plan-coverage gate
701
+ // (phase.cts:~1995, #2648: "a coverage gate that passes when it cannot
702
+ // read the plans is no gate at all"), composed explicitly here rather than
703
+ // folded into the predicate: complete AND all plans executed.
704
+ const coverageScan = scanPhasePlans(phaseDir);
705
+ const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
706
+ const isComplete = completionResult.value.complete && unsummarizedPlans.length === 0;
481
707
  // #3057 B3: routing above is unchanged (an indeterminate staleness check
482
708
  // still routes as if nothing were stale) — this only makes the fact visible
483
709
  // to whatever reads this command's JSON output.
@@ -539,26 +765,65 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
539
765
  // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
540
766
  // `Plans: N plans` — plain text header
541
767
  //
542
- // #2853: the verb owns the count token ONLY — it must not destroy hand-written
543
- // prose a human placed after the count (e.g. "(11-16 are gap closure ...)").
544
- // Group $1 = phase header → `Plans:` label + trailing whitespace (unchanged).
545
- // Group $2 = the existing count token to replace: matches `N/N plans complete`,
546
- // `N/N plans executed`, or the bare template `N plans` form. Group $3 = whatever
547
- // else is on the line (`[^\r\n]*`, so CRLF `\r` is preserved).
768
+ // #2853 / #3584: the verb owns the count token ONLY — it must not destroy
769
+ // hand-written prose a human placed on the line. Group $1 = phase header →
770
+ // `Plans:` label + trailing whitespace (unchanged). Group $2 = the existing
771
+ // count token to replace: matches `N/N plans complete`, `N/N plans executed`,
772
+ // or the bare template `N plan(s)` form — singular is part of the tool's OWN
773
+ // grammar (gsd-core/templates/roadmap.md:62 ships `**Plans**: 1 plan` as the
774
+ // documented one-plan-phase shape), so the `s` is optional there (bug #3584
775
+ // Finding B; pre-fix a bare `1 plan` fell into the drop-everything path and
776
+ // was accidentally overwritten with the correct count — post-fix it must be
777
+ // recognised as a token in its own right or it freezes stale forever). Group
778
+ // $3 = whatever else is on the line (`[^\r\n]*`, so a CRLF `\r` is never part
779
+ // of the match and rides along untouched in the unmatched remainder of the
780
+ // string — never stranded, never duplicated).
548
781
  //
549
- // Trailing text is preserved ONLY when a real count token ($2) was present —
550
- // i.e. an annotation a human wrote after a real count. When $2 is absent the
551
- // line is the fresh-template bracketed placeholder (`[Number of plans…]`) or
552
- // other freeform guidance, not user prose: the count replaces the whole token,
553
- // preserving the pre-#2853 clean-output behaviour on the template path.
554
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans)?([^\\r\\n]*)`, 'i');
782
+ // Three arms, in order:
783
+ // 1. $2 present (a real count token) → rewrite the token, preserve $3
784
+ // verbatim (an annotation a human wrote after a real count; #2853).
785
+ // 2. $2 absent AND $3, trimmed, is the fresh-template PLACEHOLDER shipped
786
+ // by gsd-core/templates/roadmap.md — either
787
+ // `[Number of plans, e.g., "3 plans" or "TBD"]` (line 37) or
788
+ // `[Number of plans]` (lines 51/75/88) → replace it with the computed
789
+ // count. Detected POSITIVELY on the distinctive `Number of plans`
790
+ // wording (anchored, case-insensitive), NEVER on "wholly bracketed" —
791
+ // a bracketed HUMAN annotation such as `[Deferred pending re-scope]`
792
+ // is structurally identical but must be arm-3 preserved (bug #3584
793
+ // Finding A).
794
+ // 3. Anything else (freeform prose, `TBD` / `TBD — annotation`, a
795
+ // bracketed human note, the first line of a wrapped sentence, an
796
+ // empty value) → leave the whole matched line untouched by returning
797
+ // `_match` unchanged. An untouched first line cannot orphan its own
798
+ // continuation on the next line, since the pattern never spans past
799
+ // `\n` in the first place.
800
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`, 'i');
555
801
  const planCountText = isComplete
556
802
  ? `${summaryCount}/${planCount} plans complete`
557
803
  : `${summaryCount}/${planCount} plans executed`;
804
+ // Positive detector for the fresh-template placeholder ONLY (bug #3584
805
+ // Finding A). Anchored to the distinctive `Number of plans` wording that
806
+ // gsd-core/templates/roadmap.md actually ships, not to "anything in
807
+ // brackets" — a bracketed human annotation like `[Deferred pending
808
+ // re-scope]` is structurally bracketed too but carries none of this
809
+ // wording, so it correctly falls through to arm 3 untouched.
810
+ const isTemplatePlaceholder = (value) => {
811
+ const trimmed = value.trim();
812
+ return /^\[\s*Number of plans\b[\s\S]*\]$/i.test(trimmed);
813
+ };
558
814
  roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, (_match, label, existingCount, trailing) => {
559
- // Preserve trailing text only when a real count preceded it.
560
- const suffix = existingCount ? trailing : '';
561
- return `${label}${planCountText}${suffix}`;
815
+ if (existingCount) {
816
+ // Arm 1: real count token — rewrite it, preserve the trailing annotation.
817
+ return `${label}${planCountText}${trailing}`;
818
+ }
819
+ if (isTemplatePlaceholder(trailing)) {
820
+ // Arm 2: fresh-template placeholder — replace with the count.
821
+ return `${label}${planCountText}`;
822
+ }
823
+ // Arm 3: freeform prose, TBD, a bracketed human annotation, a wrapped
824
+ // sentence's first line, or an empty value — leave the line exactly as
825
+ // it was.
826
+ return _match;
562
827
  });
563
828
  // If complete: check checkbox
564
829
  if (isComplete) {
@@ -570,7 +835,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
570
835
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
571
836
  if (!planId)
572
837
  continue;
573
- const planEscaped = escapeRegex(planId);
838
+ const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
574
839
  const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
575
840
  roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
576
841
  }
@@ -594,7 +859,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
594
859
  // archived <details> block is still correctly detected as missing from the
595
860
  // active milestone section.
596
861
  const missingPlans = phaseInfo.plans.filter((planFile) => {
597
- const planEscaped = escapeRegex(planFile);
862
+ const planEscaped = (0, pattern_cjs_1.escapeRegex)(planFile);
598
863
  return !new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion);
599
864
  });
600
865
  if (missingPlans.length > 0) {
@@ -642,7 +907,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
642
907
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
643
908
  if (!planId)
644
909
  continue;
645
- const planEscaped = escapeRegex(planId);
910
+ const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
646
911
  const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
647
912
  roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
648
913
  }
@@ -747,6 +1012,11 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
747
1012
  let updated = false;
748
1013
  withPlanningLock(cwd, () => {
749
1014
  const content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1015
+ // #3413: preserve the file's own EOL style when the checklist block below
1016
+ // is rebuilt and spliced back in — splitLines() cleans each captured line
1017
+ // of any dangling \r, so rejoining with a bare '\n' would silently
1018
+ // downgrade a CRLF ROADMAP.md's rewritten block to LF only.
1019
+ const eol = (0, text_lines_cjs_1.detectEol)(content);
750
1020
  // Find the phase section.
751
1021
  // #3537: padding-tolerant fragment so the caller's resolved padded id
752
1022
  // matches un-padded ROADMAP headings.
@@ -776,12 +1046,20 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
776
1046
  // Review fix (F2): `(?:^|\n)` anchors the match to start-of-line so mid-line
777
1047
  // occurrences like `***Plans:***` embedded in a sentence or `OpenPlans: foo`
778
1048
  // do not trigger a false match. Groups 1 and 2 retain the same semantics.
779
- const plansBlockMatch = phaseSection.match(/(?:^|\n)(\*{0,2}Plans\*{0,2}:[^\n]*\n)((?:\s*-\s*\[[ x]\][^\n]*\n?)+)/i);
1049
+ // #3415: empirically verified linear-time to 10.9MB / 320,000 lines of adversarial
1050
+ // checklist input (0.31ms@1000 lines -> 8.4ms@320,000 lines). The outer `+` group has
1051
+ // no trailing constraint after it in the pattern, so a successful greedy pass never
1052
+ // needs to explore alternate `\r?\n?` boundary partitions to satisfy something later —
1053
+ // it accepts the first complete parse and stops, which rules out the #2128-class
1054
+ // ambiguous-boundary blowup despite the nested-quantifier shape. Non-global match on
1055
+ // already phase-sliced content, not the whole file.
1056
+ // eslint-disable-next-line local/no-unbounded-quantifier -- outer `+` has no trailing constraint to force re-partitioning; measured linear to 10.9MB
1057
+ const plansBlockMatch = phaseSection.match(/(?:^|\r?\n)(\*{0,2}Plans\*{0,2}:[^\r\n]*\r?\n)((?:\s*-\s*\[[ x]\][^\r\n]*\r?\n?)+)/i);
780
1058
  if (!plansBlockMatch)
781
1059
  return;
782
1060
  const plansHeader = plansBlockMatch[1];
783
1061
  const existingList = plansBlockMatch[2];
784
- const listLines = existingList.split('\n').filter(l => /^\s*-\s*\[/.test(l));
1062
+ const listLines = (0, text_lines_cjs_1.splitLines)(existingList).filter(l => /^\s*-\s*\[/.test(l));
785
1063
  if (listLines.length === 0)
786
1064
  return;
787
1065
  // #314 perf: build a first-wins Map so per-line lookup is O(1) instead of O(plans).
@@ -834,11 +1112,22 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
834
1112
  annotatedLines.push(`- ${t}`);
835
1113
  }
836
1114
  }
837
- const newListBlock = annotatedLines.join('\n') + '\n';
838
- // #1103: when `(?:^|\n)` consumed a leading `\n` (mid-string match), re-emit it
839
- // so the line preceding the Plans: header is not fused onto it.
840
- const leadingNewline = plansBlockMatch[0].startsWith('\n') ? '\n' : '';
841
- const newPhaseSection = phaseSection.replace(plansBlockMatch[0], leadingNewline + plansHeader + newListBlock);
1115
+ const newListBlock = (0, text_lines_cjs_1.joinLines)(annotatedLines, eol) + eol;
1116
+ // #1103: when `(?:^|\r?\n)` consumed a leading terminator (mid-string
1117
+ // match), re-emit it verbatim so the line preceding the Plans: header is
1118
+ // not fused onto it. #3413: the widened `(?:^|\r?\n)` can now consume a
1119
+ // 2-char `\r\n` — re-emit whatever was actually captured (`''`, `'\n'`,
1120
+ // or `'\r\n'`), not a hardcoded `'\n'`, or a CRLF file loses its `\r`.
1121
+ const leadingMatch = /^\r?\n/.exec(plansBlockMatch[0]);
1122
+ const leadingNewline = leadingMatch ? leadingMatch[0] : '';
1123
+ // Review fix (#3413 security): use the FUNCTION-replacement form. The
1124
+ // string-replacement form expands String#replace's special patterns
1125
+ // (`$&`, `` $` ``, `$'`, `$$`, `$1`-`$9`) inside the replacement — and
1126
+ // newListBlock is built from author-controlled truths/plan-file content,
1127
+ // so a line containing a literal `` $` `` (etc.) would splice unrelated
1128
+ // surrounding phaseSection text into the result. A function replacer is
1129
+ // never pattern-interpreted.
1130
+ const newPhaseSection = phaseSection.replace(plansBlockMatch[0], () => leadingNewline + plansHeader + newListBlock);
842
1131
  const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
843
1132
  if (nextContent === content)
844
1133
  return;
@@ -856,6 +1145,8 @@ module.exports = {
856
1145
  cmdRoadmapGetPhase,
857
1146
  getRoadmapPhaseWithFallback,
858
1147
  cmdRoadmapAnalyze,
1148
+ cmdRoadmapMilestoneScope,
859
1149
  cmdRoadmapUpdatePlanProgress,
860
1150
  cmdRoadmapAnnotateDependencies,
1151
+ buildPhaseHeadingRegex,
861
1152
  };