@opengsd/gsd-core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -13,8 +13,6 @@ const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_os_1 = __importDefault(require("node:os"));
15
15
  const validate_cjs_1 = require("./validate.cjs");
16
- const clock_cjs_1 = require("./clock.cjs");
17
- const validate_cjs_2 = require("./validate.cjs");
18
16
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
19
17
  const planningWorkspace = require("./planning-workspace.cjs");
20
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -25,40 +23,52 @@ const stateMod = require("./state.cjs");
25
23
  const modelProfilesMod = require("./model-profiles.cjs");
26
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
27
25
  const planScanMod = require("./plan-scan.cjs");
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
27
+ const coreUtilsMod = require("./core-utils.cjs");
28
+ const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
30
+ const planningScopeMod = require("./planning-scope.cjs");
31
+ const { SCOPE } = planningScopeMod;
28
32
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
29
- const package_identity_cjs_1 = require("./package-identity.cjs");
33
+ const security_cjs_1 = require("./security.cjs");
30
34
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
31
35
  const schema_detect_cjs_1 = require("./schema-detect.cjs");
32
- const artifacts_cjs_1 = require("./artifacts.cjs");
33
36
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
34
- const model_catalog_cjs_1 = require("./model-catalog.cjs");
37
+ const pattern_cjs_1 = require("./pattern.cjs");
35
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
36
39
  const agentInstallCheck = require("./agent-install-check.cjs");
37
- const { checkAgentsInstalled } = agentInstallCheck;
40
+ const { checkAgentsInstalled, checkCodexModelPosture, checkCodexSandboxPosture } = agentInstallCheck;
38
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
42
  const ioMod = require("./io.cjs");
40
43
  const { output, error } = ioMod;
41
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
- const configLoaderMod = require("./config-loader.cjs");
43
- const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
44
- // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const phaseIdMod = require("./phase-id.cjs");
46
- const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, extractPhaseToken, comparePhaseNum } = phaseIdMod;
46
+ const { normalizePhaseName, matchPhaseDirs } = phaseIdMod;
47
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
48
48
  const phaseLocatorMod = require("./phase-locator.cjs");
49
49
  const { findPhaseInternal } = phaseLocatorMod;
50
50
  // eslint-disable-next-line @typescript-eslint/no-require-imports
51
51
  const roadmapParserMod = require("./roadmap-parser.cjs");
52
- const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = roadmapParserMod;
53
- // eslint-disable-next-line @typescript-eslint/no-require-imports
54
- const worktreeSafetyMod = require("./worktree-safety.cjs");
55
- const { inspectWorktreeHealth } = worktreeSafetyMod;
56
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
57
- const commandsMod = require("./commands.cjs");
58
- const { determinePhaseStatus } = commandsMod;
59
- const { planningDir, planningRoot } = planningWorkspace;
52
+ const { stripShippedMilestones } = roadmapParserMod;
53
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module
54
+ const healthDiagnosticMod = require("./health-diagnostic.cjs");
55
+ const { SEVERITY: HEALTH_SEVERITY, REMEDY_ACTION, REMEDY_RISK, evaluateRules, evaluateConsistencyRules, applyRepairs } = healthDiagnosticMod;
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-snapshot.cjs is an export= CommonJS module
57
+ const planningSnapshotMod = require("./planning-snapshot.cjs");
58
+ const { buildPlanningSnapshot } = planningSnapshotMod;
59
+ const { planningDir } = planningWorkspace;
60
60
  const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
61
- const { writeStateMd } = stateMod;
61
+ const { readStateHeadFreshness } = stateMod;
62
+ /**
63
+ * W024 (#2573) threshold — how many commits STATE.md may lag HEAD before
64
+ * `validate.health` mentions it.
65
+ *
66
+ * Deliberately coarse. `state_head` restamps on every state write, so a small
67
+ * count is normal for any active project; firing near zero would make health
68
+ * noisy for healthy projects without telling anyone anything. This is a
69
+ * freshness proxy, not a drift measurement — see readStateHeadFreshness.
70
+ */
71
+ const STATE_HEAD_ADVISORY_COMMITS = 20;
62
72
  const { MODEL_PROFILES } = modelProfilesMod;
63
73
  // Unused but imported for structural parity
64
74
  void stripShippedMilestones;
@@ -252,15 +262,78 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
252
262
  * verify gate fails on the comment echo rather than a real regression. Conservative:
253
263
  * errors only on a confidently-extracted QUOTED literal; ambiguous (bareword) → warning.
254
264
  */
265
+ /**
266
+ * Decode entity-escaped ampersands (& → &) — #3611. Planners emit
267
+ * <automated> bodies with `&amp;&amp;` as the chain operator (66 occurrences
268
+ * vs 0 literal in the reporting repo), and the executing agent reads the
269
+ * decoded (rendered) form. Every downstream scan — segment split,
270
+ * zero-comparison, literal harvest, echo matching — must operate on the same
271
+ * decoded text or a negative clause (`= 0`) poisons the literals of a
272
+ * POSITIVE clause (-ge 3) joined to it. Shared by both plan-discipline
273
+ * scanners so the two gates cannot drift apart again.
274
+ */
275
+ function decodeEntityAmps(s) {
276
+ return s.replace(/&amp;/g, '&');
277
+ }
278
+ /**
279
+ * Split one shell line into &&/|| segments, QUOTE-AWARE (#3611 adversarial
280
+ * review): an operator inside a quoted literal (`grep -c 'a&&b'`) is part of
281
+ * the pattern, not a chain operator — a quote-blind split destroys the
282
+ * literal and silently disarms the gate for exactly the plans that spell
283
+ * patterns with ampersands. Backslash escapes count inside double quotes
284
+ * (POSIX single quotes have none, and over-staying a single-quoted span can
285
+ * only miss a split, never invent one).
286
+ */
287
+ function splitShellSegments(line) {
288
+ const segments = [];
289
+ let current = '';
290
+ let quote = null;
291
+ for (let i = 0; i < line.length; i++) {
292
+ const ch = line[i];
293
+ if (quote === "'") {
294
+ if (ch === "'")
295
+ quote = null;
296
+ current += ch;
297
+ continue;
298
+ }
299
+ if (quote === '"') {
300
+ if (ch === '\\') {
301
+ current += ch + (line[i + 1] ?? '');
302
+ i++;
303
+ continue;
304
+ }
305
+ if (ch === '"')
306
+ quote = null;
307
+ current += ch;
308
+ continue;
309
+ }
310
+ if (ch === "'" || ch === '"') {
311
+ quote = ch;
312
+ current += ch;
313
+ continue;
314
+ }
315
+ if ((ch === '&' && line[i + 1] === '&') || (ch === '|' && line[i + 1] === '|')) {
316
+ segments.push(current.trim());
317
+ current = '';
318
+ i++;
319
+ continue;
320
+ }
321
+ current += ch;
322
+ }
323
+ segments.push(current.trim());
324
+ return segments.filter((s) => s !== '');
325
+ }
255
326
  function scanNegativeGrepCommentEcho(content) {
256
327
  const errors = [];
257
328
  const warnings = [];
258
329
  // Normalize newlines; join backslash line-continuations so a verify command wrapped
259
330
  // across lines (grep ... \ <newline> == 0) is still seen as one segment.
260
- const text = (content || '')
331
+ // #3611: decode entity-escaped ampersands (see decodeEntityAmps) so every
332
+ // downstream scan reads the same decoded text the executing agent reads.
333
+ const text = decodeEntityAmps((content || '')
261
334
  .replace(/\r\n/g, '\n')
262
335
  .replace(/\r/g, '\n')
263
- .replace(/\\\n/g, ' ');
336
+ .replace(/\\\n/g, ' '));
264
337
  // 1. Allowlisted literals: <!-- planner-discipline-allow: LIT -->
265
338
  const allow = new Set();
266
339
  const allowRe = /<!--\s*planner-discipline-allow:\s*(.+?)\s*-->/g;
@@ -303,7 +376,7 @@ function scanNegativeGrepCommentEcho(content) {
303
376
  // poisoning a negative gate (`== 0`) sharing the same physical line.
304
377
  const seenErr = new Set();
305
378
  const seenWarn = new Set();
306
- const segments = text.split('\n').flatMap((line) => line.split(/\s*(?:&&|\|\|)\s*/));
379
+ const segments = text.split('\n').flatMap(splitShellSegments);
307
380
  for (const seg of segments) {
308
381
  if (!/grep(?:\s+-{1,2}[A-Za-z])/.test(seg) || !zeroCmp(seg))
309
382
  continue;
@@ -352,10 +425,13 @@ function scanNegativeGrepCommentEcho(content) {
352
425
  function scanFileWideNegativeGateConflict(content) {
353
426
  const warnings = [];
354
427
  // Normalize newlines; join backslash line-continuations (same as #429).
355
- const text = (content || '')
428
+ // #3611: the SAME entity decode as the #429 scanner — the two gates share
429
+ // the caller and the input; a decode on one side only let an entity-escaped
430
+ // chain poison this detector's harvest exactly the same way.
431
+ const text = decodeEntityAmps((content || '')
356
432
  .replace(/\r\n/g, '\n')
357
433
  .replace(/\r/g, '\n')
358
- .replace(/\\\n/g, ' ');
434
+ .replace(/\\\n/g, ' '));
359
435
  // Allowlisted patterns: <!-- planner-region-allow: PAT -->
360
436
  const allow = new Set();
361
437
  const allowRe = /<!--\s*planner-region-allow:\s*(.+?)\s*-->/g;
@@ -489,8 +565,8 @@ function scanFileWideNegativeGateConflict(content) {
489
565
  // For each task A, scan gate text for file-wide negative grep bans.
490
566
  for (let ai = 0; ai < tasks.length; ai++) {
491
567
  const taskA = tasks[ai];
492
- // Split gate text into shell segments (split on && / || within lines).
493
- const segments = taskA.gateText.split('\n').flatMap(line => line.split(/\s*(?:&&|\|\|)\s*/));
568
+ // Split gate text into shell segments (quote-aware, shared with #429 — #3611).
569
+ const segments = taskA.gateText.split('\n').flatMap(splitShellSegments);
494
570
  for (const seg of segments) {
495
571
  if (!/grep/.test(seg))
496
572
  continue;
@@ -655,17 +731,22 @@ function extractPlanTaskInfos(content) {
655
731
  name,
656
732
  type,
657
733
  hasName,
658
- hasFiles: /<files>/.test(body),
659
- hasAction: /<action>/.test(body),
660
- hasVerify: /<verify>/.test(body),
661
- hasDone: /<done>/.test(body),
662
- hasWhatBuilt: /<what-built>/.test(body),
663
- hasHowToVerify: /<how-to-verify>/.test(body),
664
- hasDecision: /<decision>/.test(body),
665
- hasOptions: /<options>/.test(body),
666
- hasInstructions: /<instructions>/.test(body),
667
- hasVerification: /<verification>/.test(body),
668
- hasResumeSignal: /<resume-signal>/.test(body),
734
+ // #3193: child-tag presence uses /<tag[\s>]/ (attribute-tolerant) so an
735
+ // opener like <verify type="auto"> still counts as present, matching how
736
+ // the parent <task type="…"> is read by PLAN_TASK_BLOCK_RE. The [\s>]
737
+ // terminator (not \b) keeps <verify> from satisfying <verification> and
738
+ // prevents a hyphenated sibling like <verify-mode> from masking <verify>.
739
+ hasFiles: /<files[\s>]/.test(body),
740
+ hasAction: /<action[\s>]/.test(body),
741
+ hasVerify: /<verify[\s>]/.test(body),
742
+ hasDone: /<done[\s>]/.test(body),
743
+ hasWhatBuilt: /<what-built[\s>]/.test(body),
744
+ hasHowToVerify: /<how-to-verify[\s>]/.test(body),
745
+ hasDecision: /<decision[\s>]/.test(body),
746
+ hasOptions: /<options[\s>]/.test(body),
747
+ hasInstructions: /<instructions[\s>]/.test(body),
748
+ hasVerification: /<verification[\s>]/.test(body),
749
+ hasResumeSignal: /<resume-signal[\s>]/.test(body),
669
750
  });
670
751
  // Guard against zero-length matches looping forever.
671
752
  if (match.index === PLAN_TASK_BLOCK_RE.lastIndex) {
@@ -759,7 +840,7 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
759
840
  // #2701: fail loud on NUL/binary corruption before structure checks. A
760
841
  // structurally intact-but-NUL-corrupted plan otherwise passes as valid and is
761
842
  // silently skipped by recursive/binary-skipping searchers downstream.
762
- const encErr = (0, validate_cjs_2.textEncodingError)(content, filePath);
843
+ const encErr = (0, validate_cjs_1.textEncodingError)(content, filePath);
763
844
  if (encErr) {
764
845
  output({ valid: false, errors: [encErr] }, raw);
765
846
  return;
@@ -845,31 +926,34 @@ function cmdVerifyPhaseCompleteness(cwd, phase, raw) {
845
926
  const errors = [];
846
927
  const warnings = [];
847
928
  const phaseDir = node_path_1.default.join(cwd, phaseInfo['directory']);
848
- let files;
849
- try {
850
- files = node_fs_1.default.readdirSync(phaseDir);
851
- }
852
- catch {
929
+ // #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source plans/
930
+ // summaries and their pairing from the single owner (scanPhasePlans +
931
+ // findUnsummarizedPlans/findOrphanSummaries) instead of a bespoke
932
+ // root-only `-PLAN.md`/`-SUMMARY.md` filter and exact-suffix-stem
933
+ // Set-diff. The prior pairing missed bare PLAN.md/SUMMARY.md, nested
934
+ // (#3139 layout) plans, and any of the canonical pairing's other
935
+ // recognized naming forms — producing false "Plans without summaries" /
936
+ // "Summaries without plans" for names it could not recognize as paired
937
+ // (the same failure class #1988/#2648 fixed for the owner's own callers).
938
+ const scan = planScanMod.scanPhasePlans(phaseDir);
939
+ if (scan.scope === SCOPE.UNREADABLE) {
853
940
  output({ error: 'Cannot read phase directory' }, raw);
854
941
  return;
855
942
  }
856
- const plans = files.filter((f) => f.match(/-PLAN\.md$/i));
857
- const summaries = files.filter((f) => f.match(/-SUMMARY\.md$/i));
858
- const planIds = new Set(plans.map((p) => p.replace(/-PLAN\.md$/i, '')));
859
- const summaryIds = new Set(summaries.map((s) => s.replace(/-SUMMARY\.md$/i, '')));
860
- const incompletePlans = [...planIds].filter((id) => !summaryIds.has(id));
943
+ const { planFiles, summaryFiles } = scan;
944
+ const incompletePlans = findUnsummarizedPlans(planFiles, summaryFiles);
861
945
  if (incompletePlans.length > 0) {
862
946
  errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`);
863
947
  }
864
- const orphanSummaries = [...summaryIds].filter((id) => !planIds.has(id));
948
+ const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
865
949
  if (orphanSummaries.length > 0) {
866
950
  warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`);
867
951
  }
868
952
  output({
869
953
  complete: errors.length === 0,
870
954
  phase: phaseInfo['phase_number'],
871
- plan_count: plans.length,
872
- summary_count: summaries.length,
955
+ plan_count: planFiles.length,
956
+ summary_count: summaryFiles.length,
873
957
  incomplete_plans: incompletePlans,
874
958
  orphan_summaries: orphanSummaries,
875
959
  errors,
@@ -1080,8 +1164,40 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
1080
1164
  verified: false,
1081
1165
  detail: '',
1082
1166
  };
1083
- const fromPath = link['from'] || '';
1084
- const sourceContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(cwd, fromPath));
1167
+ const fromRaw = link['from'];
1168
+ const fromPath = typeof fromRaw === 'string' ? fromRaw : '';
1169
+ let sourceContent = null;
1170
+ if (fromPath !== '') {
1171
+ // An empty/missing `from:` is a malformed plan, not a path-confinement
1172
+ // violation — validatePath's traversal check (and path_rejected) only
1173
+ // applies to a non-empty path that actually resolves outside the
1174
+ // project. Leave sourceContent as null so the existing not-found /
1175
+ // pending classification below runs unchanged. Note this guard is
1176
+ // narrower than it may look: `from: "."` is a non-empty string, so it
1177
+ // still reaches validatePath and safeReadFile below, and DOES read the
1178
+ // cwd directory (yielding "Source read failed: EISDIR") — this branch
1179
+ // only short-circuits the true empty-string case.
1180
+ const fromCheck = (0, security_cjs_1.validatePath)(fromPath, cwd);
1181
+ if (!fromCheck.safe) {
1182
+ // Do not echo result.error — it embeds absolute host paths.
1183
+ check['path_rejected'] = 'from';
1184
+ check['detail'] = 'Source path rejected — resolves outside the project directory';
1185
+ results.push(check);
1186
+ continue;
1187
+ }
1188
+ try {
1189
+ sourceContent = (0, shell_command_projection_cjs_1.platformReadSync)(fromCheck.resolved);
1190
+ }
1191
+ catch (err) {
1192
+ // Report the errno only — never the message or path (untrusted `from:`
1193
+ // can trigger EISDIR/EACCES, which platformReadSync re-throws for any
1194
+ // non-ENOENT errno). A single bad link must not abort the whole command.
1195
+ const code = err?.code ?? 'unknown';
1196
+ check['detail'] = `Source read failed: ${code}`;
1197
+ results.push(check);
1198
+ continue;
1199
+ }
1200
+ }
1085
1201
  if (!sourceContent) {
1086
1202
  // Check if the missing file is promised by a plan at the same or later wave.
1087
1203
  const promised = getPromisedFiles();
@@ -1096,25 +1212,76 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
1096
1212
  }
1097
1213
  }
1098
1214
  else if (link['pattern']) {
1099
- try {
1100
- const regex = new RegExp(link['pattern']);
1101
- if (regex.test(sourceContent)) {
1102
- check['verified'] = true;
1103
- check['detail'] = 'Pattern found in source';
1215
+ const pat = (0, pattern_cjs_1.compileUserPattern)(link['pattern']);
1216
+ if (pat.neutralized !== null) {
1217
+ // A neutralized pattern was refused and never compiled — that is NOT
1218
+ // the check the plan author wrote, so it must never report verified
1219
+ // regardless of what an unrelated fallback might otherwise have
1220
+ // matched (#3477 regression: pattern "(" previously neutralized to a
1221
+ // literal-escaped match that matched nearly any source file,
1222
+ // producing a false verified: true / all_verified: true). The engine
1223
+ // itself now guarantees `test()` returns false for a refused
1224
+ // pattern; this explicit branch exists to produce the good message.
1225
+ // The match-and-report path below is skipped entirely rather than
1226
+ // run and then overwritten, which used to leave a misleading
1227
+ // "Pattern found in source" detail alongside the neutralization note.
1228
+ check['pattern_neutralized'] = pat.neutralized;
1229
+ let reason;
1230
+ switch (pat.neutralized) {
1231
+ case 'empty':
1232
+ reason = 'pattern is not a usable string — no match attempted';
1233
+ break;
1234
+ case 'too-long':
1235
+ reason = `pattern exceeded ${pattern_cjs_1.MAX_USER_PATTERN_LEN} chars — not evaluated`;
1236
+ break;
1237
+ case 'unsupported':
1238
+ reason = 'pattern is not valid RE2 syntax — backreferences and look-around are not supported';
1239
+ break;
1104
1240
  }
1105
- else {
1106
- const targetContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(cwd, link['to'] || ''));
1107
- if (targetContent && regex.test(targetContent)) {
1241
+ check['detail'] = `Pattern not verified (${reason})`;
1242
+ }
1243
+ else {
1244
+ try {
1245
+ if (pat.test(sourceContent)) {
1108
1246
  check['verified'] = true;
1109
- check['detail'] = 'Pattern found in target';
1247
+ check['detail'] = 'Pattern found in source';
1110
1248
  }
1111
1249
  else {
1112
- check['detail'] = `Pattern "${link['pattern']}" not found in source or target`;
1250
+ const toRaw = link['to'];
1251
+ const toPath = typeof toRaw === 'string' ? toRaw : '';
1252
+ let targetContent = null;
1253
+ if (toPath !== '') {
1254
+ // An empty/missing `to:` is a malformed plan, not a
1255
+ // path-confinement violation — only a non-empty path that
1256
+ // actually resolves outside the project is path_rejected.
1257
+ const toCheck = (0, security_cjs_1.validatePath)(toPath, cwd);
1258
+ if (!toCheck.safe) {
1259
+ // Do not read a rejected `to:` — treat as no target content
1260
+ // and do not echo result.error, which embeds absolute host
1261
+ // paths.
1262
+ check['path_rejected'] = 'to';
1263
+ check['detail'] = `Pattern "${link['pattern']}" not found in source; target path rejected — resolves outside the project directory`;
1264
+ }
1265
+ else {
1266
+ targetContent = (0, shell_command_projection_cjs_1.platformReadSync)(toCheck.resolved);
1267
+ }
1268
+ }
1269
+ if (targetContent && pat.test(targetContent)) {
1270
+ check['verified'] = true;
1271
+ check['detail'] = 'Pattern found in target';
1272
+ }
1273
+ else if (!check['path_rejected']) {
1274
+ check['detail'] = `Pattern "${link['pattern']}" not found in source or target`;
1275
+ }
1113
1276
  }
1114
1277
  }
1115
- }
1116
- catch {
1117
- check['detail'] = `Invalid regex pattern: ${link['pattern']}`;
1278
+ catch (err) {
1279
+ // Report the errno only — never the full error/message, which for a
1280
+ // re-thrown non-ENOENT platformReadSync failure (e.g. EISDIR from an
1281
+ // untrusted `to:` like "../..") embeds an absolute filesystem path.
1282
+ const code = err?.code ?? 'unknown';
1283
+ check['detail'] = `Pattern check failed: ${code}`;
1284
+ }
1118
1285
  }
1119
1286
  }
1120
1287
  else {
@@ -1141,209 +1308,115 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
1141
1308
  links: results,
1142
1309
  }, raw, allVerified ? 'valid' : 'invalid');
1143
1310
  }
1144
- function listMilestoneArchiveDirs(planBase) {
1145
- const milestonesDir = node_path_1.default.join(planBase, 'milestones');
1146
- try {
1147
- return node_fs_1.default
1148
- .readdirSync(milestonesDir, { withFileTypes: true })
1149
- .filter((e) => e.isDirectory() && validate_cjs_2.MILESTONE_ARCHIVE_DIR_RE.test(e.name))
1150
- .map((e) => node_path_1.default.join(milestonesDir, e.name))
1151
- .sort((a, b) => node_path_1.default.basename(a).localeCompare(node_path_1.default.basename(b), undefined, { numeric: true }));
1152
- }
1153
- catch (err) {
1154
- // #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
1155
- // (no milestones/ dir yet) keeps the long-standing [] contract that
1156
- // collectPhaseRoots / forEachArchivedPhaseToken depend on for "no archives";
1157
- // every other error (EACCES, EIO, …) must propagate — otherwise an unreadable
1158
- // milestones/ dir is silently reported as "no archives" and active-milestone
1159
- // resolution / archived-phase filtering misbehaves.
1160
- if (err.code === 'ENOENT')
1161
- return [];
1162
- throw err;
1163
- }
1164
- }
1165
- function forEachArchivedPhaseToken(planBase, onPhase) {
1166
- for (const archiveDir of listMilestoneArchiveDirs(planBase)) {
1167
- try {
1168
- const entries = node_fs_1.default.readdirSync(archiveDir, { withFileTypes: true });
1169
- for (const e of entries) {
1170
- if (!e.isDirectory())
1171
- continue;
1172
- const m = e.name.match(validate_cjs_2.PHASE_TOKEN_FROM_DIR_RE);
1173
- if (m)
1174
- onPhase(m[1]);
1175
- }
1176
- }
1177
- catch {
1178
- /* archive dir absent/unreadable */
1179
- }
1180
- }
1181
- }
1182
- function getActiveMilestoneArchiveDir(planBase) {
1183
- const archiveDirs = listMilestoneArchiveDirs(planBase);
1184
- if (archiveDirs.length === 0)
1185
- return null;
1186
- try {
1187
- const statePath = node_path_1.default.join(planBase, 'STATE.md');
1188
- if (node_fs_1.default.existsSync(statePath)) {
1189
- const state = node_fs_1.default.readFileSync(statePath, 'utf-8');
1190
- const m = state.match(/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*\*{0,2}\s*([^\s*\r\n#][^\s\r\n#]*)/mi);
1191
- if (m && m[1]) {
1192
- const milestone = m[1].trim();
1193
- const candidate = node_path_1.default.join(planBase, 'milestones', `${milestone}-phases`);
1194
- return archiveDirs.includes(candidate) ? candidate : null;
1195
- }
1196
- }
1197
- }
1198
- catch {
1199
- /* intentionally empty — fall through to version-sort below */
1311
+ /**
1312
+ * Wrapper-level fix-text table for `cmdValidateHealth`'s migrated
1313
+ * `HealthDiagnostic` -> `IssueEntry` mapping (Phase 11, #3309). Rules cannot
1314
+ * call `slash()` (forbidden ambient I/O, §8.1 rule 1) so every REPAIRABLE
1315
+ * (non-ADVISE) diagnostic's `fix` text — which the pre-migration code always
1316
+ * built via `${slash('health')} --repair|--backfill ...` — is reconstructed
1317
+ * HERE instead, keyed by `remedy.action`. Each real repair action maps 1:1
1318
+ * back to exactly one pre-migration code (confirmed: no `REMEDY_ACTION`
1319
+ * other than `ADVISE` is used by more than one rule in the migrated table),
1320
+ * so this table reproduces the original `fix` text byte-for-byte, including
1321
+ * its `slash()` calls, without the RULE ever needing to know about `slash`.
1322
+ * Source line refs are the exact pre-migration `addIssue(...)` call each
1323
+ * text was copied from:
1324
+ * - createConfig — verify.cts:1782 (W003)
1325
+ * - resetConfig — verify.cts:1830 (E005)
1326
+ * - regenerateState — verify.cts:1702 (E004)
1327
+ * - addNyquistKey — verify.cts:1847 (W008)
1328
+ * - addAiIntegrationPhaseKey — verify.cts:1857 (W016)
1329
+ * - backfillMilestones — verify.cts:2326 (W018)
1330
+ * ADVISE diagnostics do NOT go through this table — their `fix` is
1331
+ * `diagnostic.remedy.args.command` directly (already a complete, final
1332
+ * string baked in by the rule, confirmed via
1333
+ * `src/health-diagnostic-rules/root-existence.cts`/`config-validation.cts`).
1334
+ */
1335
+ function repairFixText(slash, action) {
1336
+ switch (action) {
1337
+ case REMEDY_ACTION.CREATE_CONFIG:
1338
+ return `Run ${slash('health')} --repair to create with defaults`;
1339
+ case REMEDY_ACTION.RESET_CONFIG:
1340
+ return `Run ${slash('health')} --repair to reset to defaults`;
1341
+ case REMEDY_ACTION.REGENERATE_STATE:
1342
+ return `Run ${slash('health')} --repair to regenerate`;
1343
+ case REMEDY_ACTION.ADD_NYQUIST_KEY:
1344
+ case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY:
1345
+ return `Run ${slash('health')} --repair to add key`;
1346
+ case REMEDY_ACTION.BACKFILL_MILESTONES:
1347
+ return `Run ${slash('health')} --backfill to synthesize missing entries from archive snapshots`;
1348
+ default:
1349
+ return '';
1200
1350
  }
1201
- return archiveDirs[archiveDirs.length - 1];
1202
1351
  }
1203
- function collectPhaseRoots(planBase) {
1204
- const roots = [];
1205
- const flatPhasesDir = node_path_1.default.join(planBase, 'phases');
1206
- if (node_fs_1.default.existsSync(flatPhasesDir))
1207
- roots.push(flatPhasesDir);
1208
- const activeArchive = getActiveMilestoneArchiveDir(planBase);
1209
- if (activeArchive)
1210
- roots.push(activeArchive);
1211
- return roots;
1212
- }
1213
- function collectDiskPhases(planBase) {
1214
- const diskPhases = new Set();
1215
- const phaseRoots = collectPhaseRoots(planBase);
1216
- const scanDir = (dir) => {
1217
- try {
1218
- const entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
1219
- for (const e of entries) {
1220
- if (e.isDirectory()) {
1221
- const m = e.name.match(validate_cjs_2.PHASE_TOKEN_FROM_DIR_RE);
1222
- if (m)
1223
- diskPhases.add(m[1]);
1224
- }
1225
- }
1226
- }
1227
- catch {
1228
- /* dir absent */
1229
- }
1352
+ /**
1353
+ * Map one `HealthDiagnostic` (rule-table shape) back onto the legacy
1354
+ * `IssueEntry` shape `cmdValidateHealth` has always returned (design doc,
1355
+ * "Output-shape preservation" section).
1356
+ *
1357
+ * `repairable` for a DESTRUCTIVE-risk remedy (regenerateState/resetConfig)
1358
+ * is `false` here — NOT the pre-migration `true` those two codes always
1359
+ * carried. This is a deliberate, disclosed decision (see this batch's
1360
+ * dispatch report): the design doc's own "`--repair` behavior change"
1361
+ * section establishes that `--repair` never actually applies a DESTRUCTIVE
1362
+ * remedy post-migration, so marking it `repairable: true` would mislead a
1363
+ * caller that uses this field to decide whether re-running with `--repair`
1364
+ * will fix anything. `repairable` now means "an automatic repair will
1365
+ * actually run", not merely "a remedy exists to describe" — the more
1366
+ * conservative of the two readings the brief identified, chosen because the
1367
+ * question was genuinely ambiguous and this reading cannot itself cause a
1368
+ * caller to skip a fix that would have worked.
1369
+ */
1370
+ function diagnosticToIssueEntry(diagnostic, slash) {
1371
+ const { code, message, remedy } = diagnostic;
1372
+ if (remedy.action === REMEDY_ACTION.ADVISE) {
1373
+ return { code, message, fix: remedy.args['command'], repairable: false };
1374
+ }
1375
+ return {
1376
+ code,
1377
+ message,
1378
+ fix: repairFixText(slash, remedy.action),
1379
+ repairable: remedy.risk !== REMEDY_RISK.DESTRUCTIVE,
1230
1380
  };
1231
- for (const root of phaseRoots)
1232
- scanDir(root);
1233
- return diskPhases;
1234
- }
1235
- function checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId }) {
1236
- const mismatches = [];
1237
- const sections = [];
1238
- const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim;
1239
- let m;
1240
- while ((m = sectionRx.exec(roadmapContent)) !== null) {
1241
- if (sections.length > 0)
1242
- sections[sections.length - 1].end = m.index;
1243
- sections.push({ version: `v${m[1]}`, start: m.index, end: roadmapContent.length });
1244
- }
1245
- for (const section of sections) {
1246
- const content = roadmapContent.slice(section.start, section.end);
1247
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1248
- const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
1249
- let pm;
1250
- while ((pm = phaseRx.exec(content)) !== null) {
1251
- const phaseId = pm[1];
1252
- const expectedMilestone = getMilestoneFromPhaseId(phaseId);
1253
- if (expectedMilestone !== null && expectedMilestone !== section.version) {
1254
- mismatches.push({
1255
- phaseId,
1256
- foundInMilestone: section.version,
1257
- expectedMilestone,
1258
- });
1259
- }
1260
- }
1261
- }
1262
- return mismatches;
1263
1381
  }
1264
1382
  function cmdValidateConsistency(cwd, raw) {
1265
1383
  const planBase = planningDir(cwd);
1266
1384
  const roadmapPath = node_path_1.default.join(planBase, 'ROADMAP.md');
1267
1385
  const errors = [];
1268
1386
  const warnings = [];
1387
+ // Pre-check, stays OUTSIDE the rule table — same shape as `validate.health`'s
1388
+ // E001/E010/I010 (design doc, "Which rules run where"). `.planning/` cannot
1389
+ // build a `PlanningSnapshot` worth evaluating without a ROADMAP.md to read.
1269
1390
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1270
1391
  errors.push('ROADMAP.md not found');
1271
1392
  output({ passed: false, errors, warnings }, raw, 'failed');
1272
1393
  return;
1273
1394
  }
1274
- const roadmapContentRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1275
- const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd);
1276
- const { roadmapPhases } = (0, validate_cjs_1.buildRoadmapPhaseVariants)(roadmapContent);
1277
- const { roadmapPhaseVariants: fullRoadmapPhaseVariants } = (0, validate_cjs_1.buildRoadmapPhaseVariants)(roadmapContentRaw);
1278
- const diskPhases = collectDiskPhases(planBase);
1279
- for (const p of roadmapPhases) {
1280
- if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
1281
- warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`);
1282
- }
1283
- }
1284
- for (const p of diskPhases) {
1285
- const variants = (0, validate_cjs_1.phaseVariants)(p);
1286
- if (![...variants].some((v) => fullRoadmapPhaseVariants.has(v))) {
1287
- warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
1288
- }
1289
- }
1290
- const config = loadConfig(cwd);
1291
- if (config.phase_naming !== 'custom') {
1292
- const integerPhases = [...diskPhases]
1293
- .filter((p) => !p.includes('.'))
1294
- .map((p) => parseInt(p, 10))
1295
- .sort((a, b) => a - b);
1296
- for (let i = 1; i < integerPhases.length; i++) {
1297
- if (integerPhases[i] !== integerPhases[i - 1] + 1) {
1298
- warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
1299
- }
1300
- }
1301
- }
1302
- const phaseRoots = collectPhaseRoots(planBase);
1303
- for (const phaseRoot of phaseRoots) {
1304
- try {
1305
- const entries = node_fs_1.default.readdirSync(phaseRoot, { withFileTypes: true });
1306
- const dirs = entries
1307
- .filter((e) => e.isDirectory())
1308
- .map((e) => e.name)
1309
- .sort();
1310
- for (const dir of dirs) {
1311
- const phasePath = node_path_1.default.join(phaseRoot, dir);
1312
- const phaseLabel = (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.relative(planBase, phasePath));
1313
- const phaseFiles = node_fs_1.default.readdirSync(phasePath);
1314
- const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md')).sort();
1315
- const planNums = plans
1316
- .map((p) => {
1317
- const pm = p.match(/-(\d{2})-PLAN\.md$/);
1318
- return pm ? parseInt(pm[1], 10) : null;
1319
- })
1320
- .filter((n) => n !== null);
1321
- for (let i = 1; i < planNums.length; i++) {
1322
- if (planNums[i] !== planNums[i - 1] + 1) {
1323
- warnings.push(`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} → ${planNums[i]}`);
1324
- }
1325
- }
1326
- const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md'));
1327
- const planIds = new Set(plans.map((p) => p.replace('-PLAN.md', '')));
1328
- const summaryIds = new Set(summaries.map((s) => s.replace('-SUMMARY.md', '')));
1329
- for (const sid of summaryIds) {
1330
- if (!planIds.has(sid)) {
1331
- warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`);
1332
- }
1333
- }
1334
- for (const plan of plans) {
1335
- const planFilePath = node_path_1.default.join(phasePath, plan);
1336
- const content = node_fs_1.default.readFileSync(planFilePath, 'utf-8');
1337
- const fmData = extractFrontmatter(content, planFilePath);
1338
- if (!fmData['wave']) {
1339
- warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`);
1340
- }
1341
- }
1342
- }
1343
- }
1344
- catch {
1345
- /* intentionally empty */
1346
- }
1395
+ // ─── Rule-table evaluation (Phase 12, #3310) ───────────────────────────────
1396
+ // Replaces this function's entire hand-rolled disk-vs-roadmap /
1397
+ // numbering-gap / orphan-summary / wave-missing scan — see the design
1398
+ // doc's "Which rules run where" section. `evaluateConsistencyRules` runs
1399
+ // W006/W007 (the SAME `Rule` objects `validate.health` evaluates, reused
1400
+ // verbatim — not a second, independently-drifting copy) plus the four new
1401
+ // C001-C004 rules, against the same `PlanningSnapshot` `validate.health`
1402
+ // builds.
1403
+ const snapshot = buildPlanningSnapshot(cwd);
1404
+ const diagnostics = evaluateConsistencyRules(snapshot);
1405
+ // Every current C0NN/W006/W007 diagnostic is `SEVERITY.WARNING` (confirmed
1406
+ // by direct read of `consistency.cts`/`roadmap-disk-consistency.cts`) —
1407
+ // matching the pre-migration shape, where only the ROADMAP-missing
1408
+ // pre-check above ever populated `errors`. Bucketed defensively by
1409
+ // severity anyway, mirroring `cmdValidateHealth`'s own pattern, so a
1410
+ // future ERROR-severity rule added to `CONSISTENCY_RULES` lands in the
1411
+ // right bucket without another migration.
1412
+ const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1413
+ const slash = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, _slashRuntime);
1414
+ for (const diagnostic of diagnostics) {
1415
+ const entry = diagnosticToIssueEntry(diagnostic, slash);
1416
+ if (diagnostic.severity === HEALTH_SEVERITY.ERROR)
1417
+ errors.push(entry.message);
1418
+ else
1419
+ warnings.push(entry);
1347
1420
  }
1348
1421
  const passed = errors.length === 0;
1349
1422
  output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
@@ -1366,664 +1439,104 @@ function cmdValidateHealth(cwd, options, raw) {
1366
1439
  }, raw);
1367
1440
  return;
1368
1441
  }
1369
- // rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here)
1370
- // wsBase resolves to .planning/workstreams/<ws>/ when GSD_WORKSTREAM is set (STATE.md, ROADMAP.md, phases/)
1371
- const rootBase = planningRoot(cwd);
1372
- const wsBase = planningDir(cwd);
1373
- // planBase is kept as an alias for wsBase for all the internal helpers (collectDiskPhases, etc.)
1374
- // that are already parameterised on the workstream-aware path.
1375
- const planBase = wsBase;
1376
- const projectPath = node_path_1.default.join(rootBase, 'PROJECT.md');
1377
- const roadmapPath = node_path_1.default.join(wsBase, 'ROADMAP.md');
1378
- const statePath = node_path_1.default.join(wsBase, 'STATE.md');
1379
- const configPath = node_path_1.default.join(rootBase, 'config.json');
1380
- const phasesDir = node_path_1.default.join(wsBase, 'phases');
1442
+ // rootBase resolves to the PROJECT-scoped planning root (`.planning[/<project>]`
1443
+ // — PROJECT.md, config.json live here; #3749). Workstream-free by construction:
1444
+ // planningDir(cwd, null) honors GSD_PROJECT and suppresses GSD_WORKSTREAM.
1445
+ const rootBase = planningDir(cwd, null);
1381
1446
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1382
1447
  const slash = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, _slashRuntime);
1448
+ // Second (and last) pre-check that stays OUTSIDE the rule table entirely
1449
+ // (design doc, "Two guards that stay OUTSIDE the rule table entirely" —
1450
+ // this one must run BEFORE any snapshot is built, since a flat
1451
+ // `evaluateRules` pass over an entirely-absent `.planning/` would produce
1452
+ // spurious per-rule clutter no one asked for, not a clean E001-only
1453
+ // report).
1454
+ if (!node_fs_1.default.existsSync(rootBase)) {
1455
+ const errors = [
1456
+ {
1457
+ code: 'E001',
1458
+ message: '.planning/ directory not found',
1459
+ fix: `Run ${slash('new-project')} to initialize`,
1460
+ repairable: false,
1461
+ },
1462
+ ];
1463
+ output({ status: 'broken', errors, warnings: [], info: [], repairable_count: 0 }, raw);
1464
+ return;
1465
+ }
1466
+ // ─── Rule-table evaluation (Phase 11, #3309) ───────────────────────────────
1467
+ // Replaces the entire hand-rolled addIssue/switch accumulation this
1468
+ // function used to run inline (verify.cts, pre-migration) — see the
1469
+ // design doc's "Output-shape preservation" section for the exact
1470
+ // Diagnostic -> IssueEntry mapping contract this reproduces.
1471
+ const snapshot = buildPlanningSnapshot(cwd);
1472
+ const diagnostics = evaluateRules(snapshot);
1383
1473
  const errors = [];
1384
1474
  const warnings = [];
1385
1475
  const info = [];
1386
- const repairs = [];
1387
- const addIssue = (severity, code, message, fix, repairable = false) => {
1388
- const issue = { code, message, fix, repairable };
1389
- if (severity === 'error')
1390
- errors.push(issue);
1391
- else if (severity === 'warning')
1392
- warnings.push(issue);
1476
+ for (const diagnostic of diagnostics) {
1477
+ const entry = diagnosticToIssueEntry(diagnostic, slash);
1478
+ if (diagnostic.severity === HEALTH_SEVERITY.ERROR)
1479
+ errors.push(entry);
1480
+ else if (diagnostic.severity === HEALTH_SEVERITY.WARNING)
1481
+ warnings.push(entry);
1393
1482
  else
1394
- info.push(issue);
1395
- };
1396
- if (!node_fs_1.default.existsSync(rootBase)) {
1397
- addIssue('error', 'E001', '.planning/ directory not found', `Run ${slash('new-project')} to initialize`);
1398
- output({ status: 'broken', errors, warnings, info, repairable_count: 0 }, raw);
1399
- return;
1400
- }
1401
- if (!node_fs_1.default.existsSync(projectPath)) {
1402
- addIssue('error', 'E002', 'PROJECT.md not found', `Run ${slash('new-project')} to create`);
1403
- }
1404
- else {
1405
- const content = node_fs_1.default.readFileSync(projectPath, 'utf-8');
1406
- const requiredSections = ['## What This Is', '## Core Value', '## Requirements'];
1407
- for (const section of requiredSections) {
1408
- if (!content.includes(section)) {
1409
- addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually');
1410
- }
1411
- }
1412
- }
1413
- if (!node_fs_1.default.existsSync(roadmapPath)) {
1414
- addIssue('error', 'E003', 'ROADMAP.md not found', `Run ${slash('new-milestone')} to create roadmap`);
1415
- }
1416
- if (!node_fs_1.default.existsSync(statePath)) {
1417
- addIssue('error', 'E004', 'STATE.md not found', `Run ${slash('health')} --repair to regenerate`, true);
1418
- repairs.push('regenerateState');
1419
- }
1420
- else {
1421
- const stateContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
1422
- const phaseRefs = [
1423
- ...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')),
1424
- ].map((m) => m[1]);
1425
- const validPhases = collectDiskPhases(planBase);
1426
- try {
1427
- if (node_fs_1.default.existsSync(roadmapPath)) {
1428
- const roadmapRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1429
- const all = [
1430
- ...roadmapRaw.matchAll(new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi')),
1431
- ];
1432
- for (const m of all)
1433
- validPhases.add(m[1]);
1434
- }
1435
- }
1436
- catch {
1437
- /* intentionally empty */
1438
- }
1439
- forEachArchivedPhaseToken(planBase, (token) => validPhases.add(token));
1440
- const normalizedValid = new Set();
1441
- for (const p of validPhases) {
1442
- normalizedValid.add(p);
1443
- const dotIdx = p.indexOf('.');
1444
- const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
1445
- const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
1446
- if (/^\d+$/.test(head)) {
1447
- normalizedValid.add(head.padStart(2, '0') + tail);
1448
- }
1449
- }
1450
- for (const ref of phaseRefs) {
1451
- const dotIdx = ref.indexOf('.');
1452
- const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
1453
- const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
1454
- const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
1455
- if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) {
1456
- if (normalizedValid.size > 0) {
1457
- addIssue('warning', 'W002', `STATE.md references phase ${ref}, but only phases ${[...validPhases].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} are declared`, `Review STATE.md manually before changing it; ${slash('health')} --repair will not overwrite an existing STATE.md for phase mismatches`);
1458
- }
1459
- }
1460
- }
1461
- }
1462
- if (!node_fs_1.default.existsSync(configPath)) {
1463
- addIssue('warning', 'W003', 'config.json not found', `Run ${slash('health')} --repair to create with defaults`, true);
1464
- repairs.push('createConfig');
1465
- }
1466
- else {
1467
- try {
1468
- const rawCfg = node_fs_1.default.readFileSync(configPath, 'utf-8');
1469
- const parsed = JSON.parse(rawCfg);
1470
- if (parsed['model_profile'] && !model_catalog_cjs_1.VALID_PROFILES.includes(parsed['model_profile'])) {
1471
- addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed['model_profile']}"`, `Valid values: ${model_catalog_cjs_1.VALID_PROFILES.join(', ')}`);
1472
- }
1473
- const configModels = parsed['models'];
1474
- if (configModels && typeof configModels === 'object' && !Array.isArray(configModels)) {
1475
- for (const [phaseType, tierValue] of Object.entries(configModels)) {
1476
- if (!model_catalog_cjs_1.VALID_PHASE_TYPES.has(phaseType)) {
1477
- addIssue('warning', 'W022', `config.json: models has an unknown phase type "${phaseType}" which will be ignored`, `Valid phase types: ${[...model_catalog_cjs_1.VALID_PHASE_TYPES].join(', ')}`);
1478
- }
1479
- else if (typeof tierValue !== 'string' || !model_catalog_cjs_1.VALID_TIERS.has(tierValue)) {
1480
- addIssue('warning', 'W022', `config.json: models.${phaseType} has an invalid tier value ${JSON.stringify(tierValue)} which will be ignored`, `Valid tiers: ${[...model_catalog_cjs_1.VALID_TIERS].join(', ')}`);
1481
- }
1482
- }
1483
- }
1484
- else if (configModels !== undefined && configModels !== null) {
1485
- addIssue('warning', 'W022', `config.json: models is set to ${JSON.stringify(configModels)}, but must be an object mapping phase types to tiers — this value will be ignored`, `Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults`);
1486
- }
1487
- }
1488
- catch (err) {
1489
- addIssue('error', 'E005', `config.json: JSON parse error - ${err instanceof Error ? err.message : String(err)}`, `Run ${slash('health')} --repair to reset to defaults`, true);
1490
- repairs.push('resetConfig');
1491
- }
1492
- }
1493
- if (node_fs_1.default.existsSync(configPath)) {
1494
- try {
1495
- const configRaw = node_fs_1.default.readFileSync(configPath, 'utf-8');
1496
- const configParsed = JSON.parse(configRaw);
1497
- const workflow = configParsed['workflow'];
1498
- if (workflow && workflow['nyquist_validation'] === undefined) {
1499
- addIssue('warning', 'W008', 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', `Run ${slash('health')} --repair to add key`, true);
1500
- if (!repairs.includes('addNyquistKey'))
1501
- repairs.push('addNyquistKey');
1502
- }
1503
- if (workflow && workflow['ai_integration_phase'] === undefined) {
1504
- addIssue('warning', 'W016', `config.json: workflow.ai_integration_phase absent (defaults to enabled — run ${slash('ai-integration-phase')} before planning AI system phases)`, `Run ${slash('health')} --repair to add key`, true);
1505
- if (!repairs.includes('addAiIntegrationPhaseKey'))
1506
- repairs.push('addAiIntegrationPhaseKey');
1507
- }
1508
- }
1509
- catch {
1510
- /* intentionally empty */
1511
- }
1512
- }
1513
- let phaseDirEntries = [];
1514
- const phaseDirFiles = new Map();
1515
- try {
1516
- phaseDirEntries = node_fs_1.default
1517
- .readdirSync(phasesDir, { withFileTypes: true })
1518
- .filter((e) => e.isDirectory());
1519
- for (const e of phaseDirEntries) {
1520
- try {
1521
- phaseDirFiles.set(e.name, node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, e.name)));
1522
- }
1523
- catch {
1524
- phaseDirFiles.set(e.name, []);
1525
- }
1526
- }
1527
- }
1528
- catch {
1529
- /* intentionally empty */
1530
- }
1531
- for (const e of phaseDirEntries) {
1532
- if (!e.name.match(validate_cjs_2.phaseDirNameRe)) {
1533
- addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
1534
- }
1535
- }
1536
- // W023 (#2408): detect two or more real on-disk phase directories that
1537
- // normalize to the same phase key (e.g. `05-real/` + `05-real-stray/`).
1538
- // The collision silently breaks /gsd-stats status accuracy (now folded by
1539
- // precedence — see commands.cts foldPhaseStatus) and forces an operator
1540
- // decision. Wording is neutral — never guesses which directory is "real".
1483
+ info.push(entry);
1484
+ }
1485
+ // W024 (#2573): STATE.md commit-age freshness — kept OUTSIDE the rule
1486
+ // table, exactly like E001/E010/I010 above. NOT a preservation nicety: the
1487
+ // committed `RULE_W024` (`src/health-diagnostic-rules/state-consistency.cts`)
1488
+ // is a documented PERMANENT no-op (`check` always returns `[]`) because
1489
+ // `readStateHeadFreshness`'s `git log` shell-out is ambient I/O a
1490
+ // `Rule.check(snapshot)` may never perform (§8.1 rule 1) and no
1491
+ // `PlanningSnapshot` field carries a commits-behind count. Migrating
1492
+ // `cmdValidateHealth` onto the rule table as designed would silently
1493
+ // regress `tests/health-validation.test.cjs`'s "W024 — STATE.md commit-age
1494
+ // freshness advisory" suite (7 currently-passing tests exercising the REAL
1495
+ // git-based check end-to-end) — found while wiring this function to the
1496
+ // rule table, fixed inline per this repo's no-defer policy rather than
1497
+ // silently accepting the loss. `cmdValidateHealth` itself (unlike a Rule)
1498
+ // is licensed to perform its own bounded I/O — the same license
1499
+ // `applyRepairs` already relies on (see `health-diagnostic.cts`'s header
1500
+ // comment) — so this reproduces the exact pre-migration check
1501
+ // (`verify.cts`, W024) verbatim, advisory-only: it only ever appends to
1502
+ // `warnings`, never touches `status`/`errors`/the repair set.
1541
1503
  {
1542
- const groups = new Map();
1543
- for (const e of phaseDirEntries) {
1544
- // extractPhaseToken never returns empty — for unparseable dir names it
1545
- // falls back to the dir name itself. Two distinct unparseable names
1546
- // therefore normalize to distinct keys and cannot false-positive here;
1547
- // only dirs whose tokens collapse to the same key (e.g. `05-real` and
1548
- // `05-real-stray` → token `05`) produce a collision group.
1549
- const token = extractPhaseToken(e.name);
1550
- const key = normalizePhaseName(token);
1551
- const list = groups.get(key);
1552
- if (list)
1553
- list.push(e.name);
1554
- else
1555
- groups.set(key, [e.name]);
1556
- }
1557
- for (const [key, dirs] of groups) {
1558
- if (dirs.length < 2)
1559
- continue;
1560
- // Compute each dir's status independently so the warning is informative.
1561
- // Sort by phase id for stable output regardless of readdir order; tie-
1562
- // break on the dir name itself so two dirs sharing the same phase token
1563
- // (the collision case itself) still sort deterministically (V8's stable
1564
- // sort would otherwise fall back to non-portable fs.readdirSync order).
1565
- const described = dirs
1566
- .slice()
1567
- .sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
1568
- .map((d) => {
1569
- const files = phaseDirFiles.get(d) || [];
1570
- const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1571
- const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1572
- const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, d), 'Not Started');
1573
- return `${d} (${status})`;
1574
- })
1575
- .join(', ');
1576
- addIssue('warning', 'W023', `Phase directories collide on normalized key "${key}": ${described}`, 'Inspect each directory; rename or remove the duplicate so only one directory maps to this phase key');
1577
- }
1578
- }
1579
- for (const e of phaseDirEntries) {
1580
- const phaseFiles = phaseDirFiles.get(e.name) || [];
1581
- const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
1582
- const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1583
- const summaryBases = new Set();
1584
- for (const s of summaries) {
1585
- const summaryBase = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1586
- summaryBases.add(summaryBase);
1587
- summaryBases.add((0, validate_cjs_2.canonicalPlanStem)(summaryBase));
1588
- }
1589
- for (const plan of plans) {
1590
- const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
1591
- const canonicalBase = (0, validate_cjs_2.canonicalPlanStem)(planBase);
1592
- if (!summaryBases.has(planBase) && !summaryBases.has(canonicalBase)) {
1593
- addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
1594
- }
1595
- }
1596
- }
1597
- for (const e of phaseDirEntries) {
1598
- const phaseFiles = phaseDirFiles.get(e.name) || [];
1599
- const hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md'));
1600
- const hasValidation = phaseFiles.some((f) => f.endsWith('-VALIDATION.md'));
1601
- if (hasResearch && !hasValidation) {
1602
- const researchFile = phaseFiles.find((f) => f.endsWith('-RESEARCH.md'));
1504
+ const wsBase = planningDir(cwd);
1505
+ const statePath = node_path_1.default.join(wsBase, 'STATE.md');
1506
+ if (node_fs_1.default.existsSync(statePath)) {
1603
1507
  try {
1604
- const researchContent = node_fs_1.default.readFileSync(node_path_1.default.join(phasesDir, e.name, researchFile), 'utf-8');
1605
- if (researchContent.includes('## Validation Architecture')) {
1606
- addIssue('warning', 'W009', `Phase ${e.name}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`, `Re-run ${slash('plan-phase')} with --research to regenerate`);
1607
- }
1608
- }
1609
- catch {
1610
- /* intentionally empty */
1611
- }
1612
- }
1613
- }
1614
- try {
1615
- const agentStatus = checkAgentsInstalled(_slashRuntime, cwd);
1616
- if (!agentStatus.agents_installed) {
1617
- if ((agentStatus.installed_agents).length === 0) {
1618
- addIssue('warning', 'W010', `No GSD agents found in ${agentStatus.agents_dir} — Task(subagent_type="gsd-*") will fall back to general-purpose`, `Run the GSD installer: npx ${package_identity_cjs_1.PACKAGE_NAME}@latest`);
1619
- }
1620
- else if ((agentStatus.incomplete_agents).length > 0 && (agentStatus.missing_agents).length === 0) {
1621
- addIssue('warning', 'W010', `Incomplete agent installs (missing generated file): ${(agentStatus.incomplete_agents).join(', ')} — affected workflows may fall back to general-purpose`, `Re-run the GSD installer to complete the install: npx ${package_identity_cjs_1.PACKAGE_NAME}@latest`);
1622
- }
1623
- else if ((agentStatus.incomplete_agents).length > 0) {
1624
- addIssue('warning', 'W010', `Missing ${(agentStatus.missing_agents).length} GSD agents: ${(agentStatus.missing_agents).join(', ')}; incomplete agent installs (missing generated file): ${(agentStatus.incomplete_agents).join(', ')} — affected workflows will fall back to general-purpose`, `Run the GSD installer: npx ${package_identity_cjs_1.PACKAGE_NAME}@latest`);
1625
- }
1626
- else {
1627
- addIssue('warning', 'W010', `Missing ${(agentStatus.missing_agents).length} GSD agents: ${(agentStatus.missing_agents).join(', ')} — affected workflows will fall back to general-purpose`, `Run the GSD installer: npx ${package_identity_cjs_1.PACKAGE_NAME}@latest`);
1628
- }
1629
- }
1630
- }
1631
- catch {
1632
- /* intentionally empty — agent check is non-blocking */
1633
- }
1634
- if (node_fs_1.default.existsSync(roadmapPath)) {
1635
- const roadmapContentRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1636
- const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd);
1637
- const { roadmapPhases } = (0, validate_cjs_1.buildRoadmapPhaseVariants)(roadmapContent);
1638
- const { roadmapPhaseVariants: fullRoadmapPhaseVariants } = (0, validate_cjs_1.buildRoadmapPhaseVariants)(roadmapContentRaw);
1639
- const diskPhases = collectDiskPhases(planBase);
1640
- forEachArchivedPhaseToken(planBase, (token) => diskPhases.add(token));
1641
- const activeDiskPhases = collectDiskPhases(planBase);
1642
- const notStartedPhases = (0, validate_cjs_1.buildNotStartedPhaseVariants)(roadmapContent);
1643
- for (const p of roadmapPhases) {
1644
- const variants = (0, validate_cjs_1.phaseVariants)(p);
1645
- const existsOnDisk = [...variants].some((v) => diskPhases.has(v));
1646
- if (!existsOnDisk) {
1647
- const isNotStarted = [...variants].some((v) => notStartedPhases.has(v));
1648
- if (isNotStarted)
1649
- continue;
1650
- addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap');
1651
- }
1652
- }
1653
- for (const p of activeDiskPhases) {
1654
- const variants = (0, validate_cjs_1.phaseVariants)(p);
1655
- if (![...variants].some((v) => fullRoadmapPhaseVariants.has(v))) {
1656
- addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory');
1657
- }
1658
- }
1659
- }
1660
- if (node_fs_1.default.existsSync(statePath) && node_fs_1.default.existsSync(roadmapPath)) {
1661
- try {
1662
- const stateContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
1663
- const roadmapContentFull = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1664
- const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) ||
1665
- stateContent.match(/Current Phase:\s*(\S+)/i);
1666
- if (currentPhaseMatch) {
1667
- const statePhase = currentPhaseMatch[1].replace(/^0+/, '');
1668
- const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
1669
- if (phaseCheckboxRe.test(roadmapContentFull)) {
1670
- const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i);
1671
- const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : '';
1672
- if (statusVal !== 'complete' && statusVal !== 'done') {
1673
- addIssue('warning', 'W011', `STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`, `Run ${slash('progress')} to re-derive current position, or manually update STATE.md`);
1674
- }
1675
- }
1676
- }
1677
- }
1678
- catch {
1679
- /* intentionally empty — cross-validation is advisory */
1680
- }
1681
- }
1682
- if (node_fs_1.default.existsSync(configPath)) {
1683
- try {
1684
- const configRaw = node_fs_1.default.readFileSync(configPath, 'utf-8');
1685
- const configParsed = JSON.parse(configRaw);
1686
- const validStrategies = ['none', 'phase', 'milestone'];
1687
- if (configParsed['branching_strategy'] &&
1688
- !validStrategies.includes(configParsed['branching_strategy'])) {
1689
- addIssue('warning', 'W012', `config.json: invalid branching_strategy "${configParsed['branching_strategy']}"`, `Valid values: ${validStrategies.join(', ')}`);
1690
- }
1691
- if (configParsed['context_window'] !== undefined) {
1692
- const cw = configParsed['context_window'];
1693
- if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) {
1694
- addIssue('warning', 'W013', `config.json: context_window should be a positive integer, got "${cw}"`, 'Set to 200000 (default) or 1000000 (for 1M models)');
1695
- }
1696
- }
1697
- if (configParsed['phase_branch_template'] &&
1698
- !configParsed['phase_branch_template'].includes('{phase}')) {
1699
- addIssue('warning', 'W014', 'config.json: phase_branch_template missing {phase} placeholder', 'Template must include {phase} for phase number substitution');
1700
- }
1701
- if (configParsed['milestone_branch_template'] &&
1702
- !configParsed['milestone_branch_template'].includes('{milestone}')) {
1703
- addIssue('warning', 'W015', 'config.json: milestone_branch_template missing {milestone} placeholder', 'Template must include {milestone} for version substitution');
1704
- }
1705
- }
1706
- catch {
1707
- /* parse error already caught in Check 5 */
1708
- }
1709
- }
1710
- try {
1711
- const worktreeHealth = inspectWorktreeHealth(cwd, { staleAfterMs: 60 * 60 * 1000 }, { execGit: shell_command_projection_cjs_1.execGit, existsSync: node_fs_1.default.existsSync, statSync: node_fs_1.default.statSync });
1712
- if (!worktreeHealth['ok']) {
1713
- if (worktreeHealth['reason'] === 'git_timed_out') {
1714
- addIssue('warning', 'W020', 'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected', 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process');
1715
- }
1716
- if (worktreeHealth['reason'] === 'git_list_failed') {
1717
- addIssue('warning', 'W020', 'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected', 'Run: git worktree list --porcelain to diagnose; check git repository state and permissions');
1718
- }
1719
- }
1720
- else {
1721
- for (const finding of worktreeHealth['findings']) {
1722
- if (finding['kind'] === 'orphan') {
1723
- addIssue('warning', 'W017', `Orphan git worktree: ${finding['path']} (path no longer exists on disk)`, 'Run: git worktree prune');
1724
- continue;
1725
- }
1726
- if (finding['kind'] === 'stale') {
1727
- // Do not flag the active session's worktree — removing it would be harmful.
1728
- const worktreePath = finding['path'];
1729
- const activeCwd = process.cwd();
1730
- const normalizedWorktree = node_path_1.default.resolve(worktreePath);
1731
- const normalizedCwd = node_path_1.default.resolve(activeCwd);
1732
- // Skip if the worktree IS the cwd or is an ancestor of it.
1733
- const isActiveWorktree = normalizedCwd === normalizedWorktree ||
1734
- normalizedCwd.startsWith(normalizedWorktree + node_path_1.default.sep);
1735
- if (isActiveWorktree)
1736
- continue;
1737
- addIssue('warning', 'W017', `Stale git worktree: ${worktreePath} (last modified ${finding['ageMinutes']} minutes ago)`, `Run: git worktree remove ${worktreePath} --force`);
1738
- continue;
1739
- }
1740
- // #3050/#3057 (B5): a 'unverified' finding means existsSync confirmed
1741
- // the worktree is present but statSync threw, so orphan/stale status
1742
- // could not be determined for THIS entry — it must not be silently
1743
- // dropped (that would be the exact fail-open the row exists to close).
1744
- if (finding['kind'] === 'unverified') {
1745
- addIssue('warning', 'W020', `Worktree health check degraded: could not stat ${finding['path']} — presence/staleness could not be verified`, 'Check filesystem permissions on the worktree path, or investigate why statSync failed for it');
1508
+ const stateContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
1509
+ const fm = extractFrontmatter(stateContent);
1510
+ const freshness = readStateHeadFreshness(cwd, fm['state_head']);
1511
+ if (freshness.commits_behind !== null &&
1512
+ freshness.commits_behind >= STATE_HEAD_ADVISORY_COMMITS) {
1513
+ warnings.push({
1514
+ code: 'W024',
1515
+ message: `STATE.md was written ${freshness.commits_behind} commits ago (at ${freshness.state_head}) — treat its contents as approximate`,
1516
+ fix: 'Re-read the current phase artifacts before relying on STATE.md, or run a GSD command that refreshes it',
1517
+ repairable: false,
1518
+ });
1746
1519
  }
1747
1520
  }
1748
- }
1749
- }
1750
- catch {
1751
- /* git worktree not available or not a git repo — skip silently */
1752
- }
1753
- try {
1754
- const phaseConvention = (() => {
1755
- if (!node_fs_1.default.existsSync(configPath))
1756
- return null;
1757
- try {
1758
- const configRaw = node_fs_1.default.readFileSync(configPath, 'utf-8');
1759
- const configParsed = JSON.parse(configRaw);
1760
- return configParsed['phase_id_convention'] || null;
1761
- }
1762
1521
  catch {
1763
- return null;
1764
- }
1765
- })();
1766
- if (phaseConvention === 'milestone-prefixed') {
1767
- if (node_fs_1.default.existsSync(roadmapPath)) {
1768
- const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1769
- const mismatches = checkMilestonePrefixMismatches(roadmapContent, {
1770
- getMilestoneFromPhaseId: getMilestoneFromPhaseId,
1771
- });
1772
- for (const mm of mismatches) {
1773
- addIssue('warning', 'W021', `Phase ${mm.phaseId}: integer prefix implies ${mm.expectedMilestone} but listed under ${mm.foundInMilestone}`, 'Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default)');
1774
- }
1775
- }
1776
- }
1777
- }
1778
- catch {
1779
- /* W021 check is advisory — skip on error */
1780
- }
1781
- const milestonesPath = node_path_1.default.join(rootBase, 'MILESTONES.md');
1782
- const milestonesArchiveDir = node_path_1.default.join(rootBase, 'milestones');
1783
- const missingFromRegistry = [];
1784
- try {
1785
- if (node_fs_1.default.existsSync(milestonesArchiveDir)) {
1786
- const archiveFiles = node_fs_1.default.readdirSync(milestonesArchiveDir);
1787
- const archivedVersions = archiveFiles
1788
- .map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/))
1789
- .filter(Boolean)
1790
- .map((m) => m[1]);
1791
- if (archivedVersions.length > 0) {
1792
- const registryContent = node_fs_1.default.existsSync(milestonesPath)
1793
- ? node_fs_1.default.readFileSync(milestonesPath, 'utf-8')
1794
- : '';
1795
- for (const ver of archivedVersions) {
1796
- if (!registryContent.includes(`## ${ver}`)) {
1797
- missingFromRegistry.push(ver);
1798
- }
1799
- }
1800
- if (missingFromRegistry.length > 0) {
1801
- addIssue('warning', 'W018', `MILESTONES.md missing ${missingFromRegistry.length} archived milestone(s): ${missingFromRegistry.join(', ')}`, `Run ${slash('health')} --backfill to synthesize missing entries from archive snapshots`, true);
1802
- repairs.push('backfillMilestones');
1803
- }
1804
- }
1805
- }
1806
- }
1807
- catch {
1808
- /* intentionally empty — milestone sync check is advisory */
1809
- }
1810
- try {
1811
- const entries = node_fs_1.default.readdirSync(rootBase, { withFileTypes: true });
1812
- for (const entry of entries) {
1813
- if (!entry.isFile())
1814
- continue;
1815
- if (!entry.name.endsWith('.md'))
1816
- continue;
1817
- if (!(0, artifacts_cjs_1.isCanonicalPlanningFile)(entry.name)) {
1818
- addIssue('warning', 'W019', `Unrecognized .planning/ file: ${entry.name} — not a canonical GSD artifact`, 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', false);
1522
+ /* intentionally empty — W024 is advisory */
1819
1523
  }
1820
1524
  }
1821
1525
  }
1822
- catch {
1823
- /* artifact check is advisory — skip on error */
1824
- }
1825
- try {
1826
- if (node_fs_1.default.existsSync(statePath) && node_fs_1.default.existsSync(roadmapPath)) {
1827
- const stateRaw = node_fs_1.default.readFileSync(statePath, 'utf-8');
1828
- const statusMatch = stateRaw.match(/^status:\s*(.+)/im);
1829
- const stateStatus = statusMatch ? statusMatch[1].trim().toLowerCase() : '';
1830
- const isMarkedComplete = /milestone complete|archived/.test(stateStatus);
1831
- if (isMarkedComplete) {
1832
- const roadmapRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1833
- const scopedContent = extractCurrentMilestone(roadmapRaw, cwd);
1834
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1835
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
1836
- const unstarted = [];
1837
- let pm;
1838
- // Non-hoisted: load-order matters (circular dep guard)
1839
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
1840
- const planningWorkspace2 = require('./planning-workspace.cjs');
1841
- const phasesDir2 = planningWorkspace2.planningPaths(cwd).phases;
1842
- const phaseDirNames2 = (() => {
1843
- try {
1844
- return node_fs_1.default
1845
- .readdirSync(phasesDir2, { withFileTypes: true })
1846
- .filter((e) => e.isDirectory())
1847
- .map((e) => e.name);
1848
- }
1849
- catch {
1850
- return [];
1851
- }
1852
- })();
1853
- while ((pm = phasePattern.exec(scopedContent)) !== null) {
1854
- const phaseNum = pm[1];
1855
- const normalizedPh = normalizePhaseName(phaseNum);
1856
- const hasDirectory = phaseDirNames2.some((d) => phaseTokenMatches(d, normalizedPh));
1857
- if (!hasDirectory) {
1858
- unstarted.push(phaseNum);
1859
- }
1860
- }
1861
- if (unstarted.length > 0) {
1862
- addIssue('warning', 'W021', `STATE says milestone complete but ROADMAP lists ${unstarted.length} unstarted phase(s) (e.g. Phase ${unstarted[0]})`, 'Run validate consistency or re-run complete-milestone after verifying all phases are done');
1863
- }
1864
- }
1865
- }
1866
- }
1867
- catch {
1868
- /* W021 check is advisory — skip on error */
1869
- }
1870
1526
  // ─── Perform repairs if requested ─────────────────────────────────────────
1871
- const repairActions = [];
1872
- if (options['repair'] && repairs.length > 0) {
1873
- for (const repair of repairs) {
1874
- try {
1875
- switch (repair) {
1876
- case 'createConfig':
1877
- case 'resetConfig': {
1878
- const defaults = {
1879
- model_profile: CONFIG_DEFAULTS.model_profile,
1880
- commit_docs: CONFIG_DEFAULTS.commit_docs,
1881
- search_gitignored: CONFIG_DEFAULTS.search_gitignored,
1882
- branching_strategy: CONFIG_DEFAULTS.branching_strategy,
1883
- phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
1884
- milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
1885
- quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
1886
- workflow: {
1887
- research: CONFIG_DEFAULTS.research,
1888
- plan_check: CONFIG_DEFAULTS.plan_checker,
1889
- verifier: CONFIG_DEFAULTS.verifier,
1890
- nyquist_validation: CONFIG_DEFAULTS.nyquist_validation,
1891
- },
1892
- parallelization: CONFIG_DEFAULTS.parallelization,
1893
- brave_search: CONFIG_DEFAULTS.brave_search,
1894
- };
1895
- (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(defaults, null, 2));
1896
- repairActions.push({ action: repair, success: true, path: 'config.json' });
1897
- break;
1898
- }
1899
- case 'regenerateState': {
1900
- if (node_fs_1.default.existsSync(statePath)) {
1901
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1902
- const backupPath = `${statePath}.bak-${timestamp}`;
1903
- node_fs_1.default.copyFileSync(statePath, backupPath);
1904
- repairActions.push({ action: 'backupState', success: true, path: backupPath });
1905
- }
1906
- const milestone = getMilestoneInfo(cwd);
1907
- const projectRef = node_path_1.default
1908
- .relative(cwd, node_path_1.default.join(rootBase, 'PROJECT.md'))
1909
- .split(node_path_1.default.sep)
1910
- .join('/');
1911
- let stateContent = `# Session State\n\n`;
1912
- stateContent += `## Project Reference\n\n`;
1913
- stateContent += `See: ${projectRef}\n\n`;
1914
- stateContent += `## Position\n\n`;
1915
- stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
1916
- stateContent += `**Current phase:** (determining...)\n`;
1917
- stateContent += `**Status:** Resuming\n\n`;
1918
- stateContent += `## Session Log\n\n`;
1919
- stateContent += `- ${clock_cjs_1.realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
1920
- writeStateMd(statePath, stateContent, cwd);
1921
- repairActions.push({ action: repair, success: true, path: 'STATE.md' });
1922
- break;
1923
- }
1924
- case 'addNyquistKey': {
1925
- if (node_fs_1.default.existsSync(configPath)) {
1926
- try {
1927
- const configRaw = node_fs_1.default.readFileSync(configPath, 'utf-8');
1928
- const configParsed = JSON.parse(configRaw);
1929
- if (!configParsed['workflow'])
1930
- configParsed['workflow'] = {};
1931
- const wf = configParsed['workflow'];
1932
- if (wf['nyquist_validation'] === undefined) {
1933
- wf['nyquist_validation'] = true;
1934
- (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(configParsed, null, 2));
1935
- }
1936
- repairActions.push({ action: repair, success: true, path: 'config.json' });
1937
- }
1938
- catch (err) {
1939
- repairActions.push({
1940
- action: repair,
1941
- success: false,
1942
- error: err instanceof Error ? err.message : String(err),
1943
- });
1944
- }
1945
- }
1946
- break;
1947
- }
1948
- case 'addAiIntegrationPhaseKey': {
1949
- if (node_fs_1.default.existsSync(configPath)) {
1950
- try {
1951
- const configRaw = node_fs_1.default.readFileSync(configPath, 'utf-8');
1952
- const configParsed = JSON.parse(configRaw);
1953
- if (!configParsed['workflow'])
1954
- configParsed['workflow'] = {};
1955
- const wf = configParsed['workflow'];
1956
- if (wf['ai_integration_phase'] === undefined) {
1957
- wf['ai_integration_phase'] = true;
1958
- (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(configParsed, null, 2));
1959
- }
1960
- repairActions.push({ action: repair, success: true, path: 'config.json' });
1961
- }
1962
- catch (err) {
1963
- repairActions.push({
1964
- action: repair,
1965
- success: false,
1966
- error: err instanceof Error ? err.message : String(err),
1967
- });
1968
- }
1969
- }
1970
- break;
1971
- }
1972
- case 'backfillMilestones': {
1973
- if (!options['backfill'] && !options['repair'])
1974
- break;
1975
- const today = clock_cjs_1.realClock.localToday();
1976
- let backfilled = 0;
1977
- for (const ver of missingFromRegistry) {
1978
- try {
1979
- const snapshotPath = node_path_1.default.join(milestonesArchiveDir, `${ver}-ROADMAP.md`);
1980
- const snapshot = (0, shell_command_projection_cjs_1.platformReadSync)(snapshotPath);
1981
- const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m);
1982
- const milestoneName = titleMatch
1983
- ? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim()
1984
- : ver;
1985
- const entry = `## ${ver}${milestoneName && milestoneName !== ver ? ` ${milestoneName}` : ''} (Backfilled: ${today})\n\n**Note:** Synthesized from archive snapshot by \`${slash('health')} --backfill\`. Original completion date unknown.\n\n---\n\n`;
1986
- const milestonesContent = node_fs_1.default.existsSync(milestonesPath)
1987
- ? node_fs_1.default.readFileSync(milestonesPath, 'utf-8')
1988
- : '';
1989
- if (!milestonesContent.trim()) {
1990
- (0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, `# Milestones\n\n${entry}`);
1991
- }
1992
- else {
1993
- const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
1994
- if (headerMatch) {
1995
- const header = headerMatch[1];
1996
- const rest = milestonesContent.slice(header.length);
1997
- (0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, header + entry + rest);
1998
- }
1999
- else {
2000
- (0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, entry + milestonesContent);
2001
- }
2002
- }
2003
- backfilled++;
2004
- }
2005
- catch {
2006
- /* intentionally empty — partial backfill is acceptable */
2007
- }
2008
- }
2009
- repairActions.push({
2010
- action: repair,
2011
- success: true,
2012
- detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md`,
2013
- });
2014
- break;
2015
- }
2016
- }
2017
- }
2018
- catch (err) {
2019
- repairActions.push({
2020
- action: repair,
2021
- success: false,
2022
- error: err instanceof Error ? err.message : String(err),
2023
- });
2024
- }
2025
- }
2026
- }
1527
+ // `applyRepairs` internally no-ops (produces zero `details` rows, no
1528
+ // filesystem writes) when both `repair` and `backfill` are falsy, so this
1529
+ // call is unconditional — mirroring the original's own
1530
+ // `if (options['repair'] && repairs.length > 0)` gate without needing to
1531
+ // duplicate that condition here. `backfill` is threaded through as its own
1532
+ // boolean (not folded into `repair`), which is what makes `--backfill`
1533
+ // alone now actually trigger `backfillMilestones` — the disclosed latent-
1534
+ // bug fix from `verify.cts:2504`'s previously-unreachable inner gate (see
1535
+ // design doc "Known limits").
1536
+ const repairResult = applyRepairs(cwd, diagnostics, Boolean(options['repair']), Boolean(options['backfill']));
1537
+ // The legacy `repairs_performed` shape never carried a `code` field —
1538
+ // strip it before it reaches JSON output.
1539
+ const repairActions = repairResult.details.map(({ code: _code, ...rest }) => rest);
2027
1540
  let status;
2028
1541
  if (errors.length > 0) {
2029
1542
  status = 'broken';
@@ -2047,8 +1560,28 @@ function cmdValidateHealth(cwd, options, raw) {
2047
1560
  return result;
2048
1561
  }
2049
1562
  function cmdValidateAgents(cwd, raw) {
2050
- const agentStatus = checkAgentsInstalled((0, runtime_slash_cjs_1.resolveRuntime)(cwd), cwd);
1563
+ const runtime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1564
+ const agentStatus = checkAgentsInstalled(runtime, cwd);
2051
1565
  const expected = Object.keys(MODEL_PROFILES);
1566
+ // #3242 ADR-2313 D6 — additive: validates posture (never an Anthropic-flavored
1567
+ // model or an orphaned reasoning-effort pin in a Codex agent .toml), not just
1568
+ // presence. checkAgentsInstalled above is untouched.
1569
+ const codexPosture = checkCodexModelPosture(runtime, cwd);
1570
+ // #3897 rung 3 (ADR-3473 §8.3 criterion 3) — sibling posture check, additive
1571
+ // alongside codex_posture (never nested inside it, never replacing it): a
1572
+ // TOML's `sandbox_mode` disagreeing with its role's derived expectation is a
1573
+ // different defect class than an Anthropic-flavored model. Same short-circuits,
1574
+ // same read-only posture as checkCodexModelPosture above; checkAgentsInstalled
1575
+ // and codex_posture are both left untouched.
1576
+ //
1577
+ // Failure semantics: matches the checkCodexModelPosture precedent exactly.
1578
+ // Neither posture check makes `validate agents` exit non-zero on its own —
1579
+ // both are report-only fields inspected by the caller (or a human) via the
1580
+ // JSON payload. A `validate` verb whose two posture checks disagreed on
1581
+ // fatality would be its own defect (#3897 dispatch note); this keeps them
1582
+ // consistent rather than inventing new exit-code behavior for only one of
1583
+ // the two siblings.
1584
+ const sandboxPosture = checkCodexSandboxPosture(runtime, cwd);
2052
1585
  output({
2053
1586
  agents_dir: agentStatus.agents_dir,
2054
1587
  agents_found: agentStatus.agents_installed,
@@ -2056,6 +1589,8 @@ function cmdValidateAgents(cwd, raw) {
2056
1589
  missing: agentStatus.missing_agents,
2057
1590
  incomplete: agentStatus.incomplete_agents,
2058
1591
  expected,
1592
+ codex_posture: codexPosture,
1593
+ sandbox_posture: sandboxPosture,
2059
1594
  }, raw);
2060
1595
  }
2061
1596
  function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
@@ -2069,21 +1604,20 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
2069
1604
  output({ block: false, drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
2070
1605
  return;
2071
1606
  }
2072
- // Resolve the phase directory with the canonical phase-token matcher
2073
- // (phase-id.cjs), not a naive substring test. A bare `.includes(phaseArg)`
2074
- // lets a non-existent phase silently match a different phase whose directory
2075
- // name merely contains the requested token (e.g. "1" matching "11-expansion"),
2076
- // making the drift gate inspect the wrong phase. This mirrors find-phase /
2077
- // verify phase-completeness, which both use phaseTokenMatches. (#1571)
1607
+ // Resolve the phase directory with the canonical phase-directory matcher
1608
+ // (phase-id.cjs::matchPhaseDirs), not a naive substring test. A bare
1609
+ // `.includes(phaseArg)` lets a non-existent phase silently match a different
1610
+ // phase whose directory name merely contains the requested token (e.g. "1"
1611
+ // matching "11-expansion"), making the drift gate inspect the wrong phase.
1612
+ // This shares the one selection rule with find-phase / verify
1613
+ // phase-completeness rather than restating it. (#1571, #2528)
2078
1614
  let phaseDir = null;
2079
1615
  const normalizedPhase = normalizePhaseName(phaseArg);
2080
1616
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
2081
- for (const entry of entries) {
2082
- if (entry.isDirectory() && phaseTokenMatches(entry.name, normalizedPhase)) {
2083
- phaseDir = node_path_1.default.join(phasesDir, entry.name);
2084
- break;
2085
- }
2086
- }
1617
+ const dirNames = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1618
+ const drift = matchPhaseDirs(dirNames, normalizedPhase).matches[0];
1619
+ if (drift)
1620
+ phaseDir = node_path_1.default.join(phasesDir, drift);
2087
1621
  if (!phaseDir) {
2088
1622
  const exact = node_path_1.default.join(phasesDir, phaseArg);
2089
1623
  if (node_fs_1.default.existsSync(exact))
@@ -2093,8 +1627,14 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
2093
1627
  output({ block: false, drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` }, raw);
2094
1628
  return;
2095
1629
  }
1630
+ // #3183: canonical LIVE plan/summary sets (root+nested,
1631
+ // status: superseded EXCLUDED) from the single owner, rather than a
1632
+ // root-only readdirSync filter — a superseded plan's claimed
1633
+ // files_modified is no longer treated as an expected drift target, and
1634
+ // nested (#3139 layout) plans/summaries are no longer invisible to the
1635
+ // drift check.
1636
+ const { planFiles, summaryFiles } = planScanMod.scanPhasePlans(phaseDir);
2096
1637
  const allFiles = [];
2097
- const planFiles = node_fs_1.default.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md'));
2098
1638
  for (const pf of planFiles) {
2099
1639
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, pf), 'utf-8');
2100
1640
  const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
@@ -2104,7 +1644,6 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
2104
1644
  }
2105
1645
  }
2106
1646
  let executionLog = '';
2107
- const summaryFiles = node_fs_1.default.readdirSync(phaseDir).filter((f) => f.endsWith('-SUMMARY.md'));
2108
1647
  for (const sf of summaryFiles) {
2109
1648
  executionLog += node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, sf), 'utf-8') + '\n';
2110
1649
  }
@@ -2285,9 +1824,5 @@ module.exports = {
2285
1824
  cmdValidateAgents,
2286
1825
  cmdVerifySchemaDrift,
2287
1826
  cmdVerifyCodebaseDrift,
2288
- // Test seam (#1883): listMilestoneArchiveDirs is private and exercised through
2289
- // the validate command, which runs in a subprocess — an fs monkeypatch in the
2290
- // test process cannot reach it. Exposed under a leading underscore so the
2291
- // permission-error path can be unit-tested directly (no chmod 0o000).
2292
- _listMilestoneArchiveDirs: listMilestoneArchiveDirs,
1827
+ STATE_HEAD_ADVISORY_COMMITS,
2293
1828
  };