@opengsd/gsd-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -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 } = 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;
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
59
  const { planningDir, planningRoot } = 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;
@@ -655,17 +665,22 @@ function extractPlanTaskInfos(content) {
655
665
  name,
656
666
  type,
657
667
  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),
668
+ // #3193: child-tag presence uses /<tag[\s>]/ (attribute-tolerant) so an
669
+ // opener like <verify type="auto"> still counts as present, matching how
670
+ // the parent <task type="…"> is read by PLAN_TASK_BLOCK_RE. The [\s>]
671
+ // terminator (not \b) keeps <verify> from satisfying <verification> and
672
+ // prevents a hyphenated sibling like <verify-mode> from masking <verify>.
673
+ hasFiles: /<files[\s>]/.test(body),
674
+ hasAction: /<action[\s>]/.test(body),
675
+ hasVerify: /<verify[\s>]/.test(body),
676
+ hasDone: /<done[\s>]/.test(body),
677
+ hasWhatBuilt: /<what-built[\s>]/.test(body),
678
+ hasHowToVerify: /<how-to-verify[\s>]/.test(body),
679
+ hasDecision: /<decision[\s>]/.test(body),
680
+ hasOptions: /<options[\s>]/.test(body),
681
+ hasInstructions: /<instructions[\s>]/.test(body),
682
+ hasVerification: /<verification[\s>]/.test(body),
683
+ hasResumeSignal: /<resume-signal[\s>]/.test(body),
669
684
  });
670
685
  // Guard against zero-length matches looping forever.
671
686
  if (match.index === PLAN_TASK_BLOCK_RE.lastIndex) {
@@ -759,7 +774,7 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
759
774
  // #2701: fail loud on NUL/binary corruption before structure checks. A
760
775
  // structurally intact-but-NUL-corrupted plan otherwise passes as valid and is
761
776
  // silently skipped by recursive/binary-skipping searchers downstream.
762
- const encErr = (0, validate_cjs_2.textEncodingError)(content, filePath);
777
+ const encErr = (0, validate_cjs_1.textEncodingError)(content, filePath);
763
778
  if (encErr) {
764
779
  output({ valid: false, errors: [encErr] }, raw);
765
780
  return;
@@ -845,31 +860,34 @@ function cmdVerifyPhaseCompleteness(cwd, phase, raw) {
845
860
  const errors = [];
846
861
  const warnings = [];
847
862
  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 {
863
+ // #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source plans/
864
+ // summaries and their pairing from the single owner (scanPhasePlans +
865
+ // findUnsummarizedPlans/findOrphanSummaries) instead of a bespoke
866
+ // root-only `-PLAN.md`/`-SUMMARY.md` filter and exact-suffix-stem
867
+ // Set-diff. The prior pairing missed bare PLAN.md/SUMMARY.md, nested
868
+ // (#3139 layout) plans, and any of the canonical pairing's other
869
+ // recognized naming forms — producing false "Plans without summaries" /
870
+ // "Summaries without plans" for names it could not recognize as paired
871
+ // (the same failure class #1988/#2648 fixed for the owner's own callers).
872
+ const scan = planScanMod.scanPhasePlans(phaseDir);
873
+ if (scan.scope === SCOPE.UNREADABLE) {
853
874
  output({ error: 'Cannot read phase directory' }, raw);
854
875
  return;
855
876
  }
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));
877
+ const { planFiles, summaryFiles } = scan;
878
+ const incompletePlans = findUnsummarizedPlans(planFiles, summaryFiles);
861
879
  if (incompletePlans.length > 0) {
862
880
  errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`);
863
881
  }
864
- const orphanSummaries = [...summaryIds].filter((id) => !planIds.has(id));
882
+ const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
865
883
  if (orphanSummaries.length > 0) {
866
884
  warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`);
867
885
  }
868
886
  output({
869
887
  complete: errors.length === 0,
870
888
  phase: phaseInfo['phase_number'],
871
- plan_count: plans.length,
872
- summary_count: summaries.length,
889
+ plan_count: planFiles.length,
890
+ summary_count: summaryFiles.length,
873
891
  incomplete_plans: incompletePlans,
874
892
  orphan_summaries: orphanSummaries,
875
893
  errors,
@@ -1080,8 +1098,40 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
1080
1098
  verified: false,
1081
1099
  detail: '',
1082
1100
  };
1083
- const fromPath = link['from'] || '';
1084
- const sourceContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(cwd, fromPath));
1101
+ const fromRaw = link['from'];
1102
+ const fromPath = typeof fromRaw === 'string' ? fromRaw : '';
1103
+ let sourceContent = null;
1104
+ if (fromPath !== '') {
1105
+ // An empty/missing `from:` is a malformed plan, not a path-confinement
1106
+ // violation — validatePath's traversal check (and path_rejected) only
1107
+ // applies to a non-empty path that actually resolves outside the
1108
+ // project. Leave sourceContent as null so the existing not-found /
1109
+ // pending classification below runs unchanged. Note this guard is
1110
+ // narrower than it may look: `from: "."` is a non-empty string, so it
1111
+ // still reaches validatePath and safeReadFile below, and DOES read the
1112
+ // cwd directory (yielding "Source read failed: EISDIR") — this branch
1113
+ // only short-circuits the true empty-string case.
1114
+ const fromCheck = (0, security_cjs_1.validatePath)(fromPath, cwd);
1115
+ if (!fromCheck.safe) {
1116
+ // Do not echo result.error — it embeds absolute host paths.
1117
+ check['path_rejected'] = 'from';
1118
+ check['detail'] = 'Source path rejected — resolves outside the project directory';
1119
+ results.push(check);
1120
+ continue;
1121
+ }
1122
+ try {
1123
+ sourceContent = (0, shell_command_projection_cjs_1.platformReadSync)(fromCheck.resolved);
1124
+ }
1125
+ catch (err) {
1126
+ // Report the errno only — never the message or path (untrusted `from:`
1127
+ // can trigger EISDIR/EACCES, which platformReadSync re-throws for any
1128
+ // non-ENOENT errno). A single bad link must not abort the whole command.
1129
+ const code = err?.code ?? 'unknown';
1130
+ check['detail'] = `Source read failed: ${code}`;
1131
+ results.push(check);
1132
+ continue;
1133
+ }
1134
+ }
1085
1135
  if (!sourceContent) {
1086
1136
  // Check if the missing file is promised by a plan at the same or later wave.
1087
1137
  const promised = getPromisedFiles();
@@ -1096,25 +1146,76 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
1096
1146
  }
1097
1147
  }
1098
1148
  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';
1149
+ const pat = (0, pattern_cjs_1.compileUserPattern)(link['pattern']);
1150
+ if (pat.neutralized !== null) {
1151
+ // A neutralized pattern was refused and never compiled — that is NOT
1152
+ // the check the plan author wrote, so it must never report verified
1153
+ // regardless of what an unrelated fallback might otherwise have
1154
+ // matched (#3477 regression: pattern "(" previously neutralized to a
1155
+ // literal-escaped match that matched nearly any source file,
1156
+ // producing a false verified: true / all_verified: true). The engine
1157
+ // itself now guarantees `test()` returns false for a refused
1158
+ // pattern; this explicit branch exists to produce the good message.
1159
+ // The match-and-report path below is skipped entirely rather than
1160
+ // run and then overwritten, which used to leave a misleading
1161
+ // "Pattern found in source" detail alongside the neutralization note.
1162
+ check['pattern_neutralized'] = pat.neutralized;
1163
+ let reason;
1164
+ switch (pat.neutralized) {
1165
+ case 'empty':
1166
+ reason = 'pattern is not a usable string — no match attempted';
1167
+ break;
1168
+ case 'too-long':
1169
+ reason = `pattern exceeded ${pattern_cjs_1.MAX_USER_PATTERN_LEN} chars — not evaluated`;
1170
+ break;
1171
+ case 'unsupported':
1172
+ reason = 'pattern is not valid RE2 syntax — backreferences and look-around are not supported';
1173
+ break;
1104
1174
  }
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)) {
1175
+ check['detail'] = `Pattern not verified (${reason})`;
1176
+ }
1177
+ else {
1178
+ try {
1179
+ if (pat.test(sourceContent)) {
1108
1180
  check['verified'] = true;
1109
- check['detail'] = 'Pattern found in target';
1181
+ check['detail'] = 'Pattern found in source';
1110
1182
  }
1111
1183
  else {
1112
- check['detail'] = `Pattern "${link['pattern']}" not found in source or target`;
1184
+ const toRaw = link['to'];
1185
+ const toPath = typeof toRaw === 'string' ? toRaw : '';
1186
+ let targetContent = null;
1187
+ if (toPath !== '') {
1188
+ // An empty/missing `to:` is a malformed plan, not a
1189
+ // path-confinement violation — only a non-empty path that
1190
+ // actually resolves outside the project is path_rejected.
1191
+ const toCheck = (0, security_cjs_1.validatePath)(toPath, cwd);
1192
+ if (!toCheck.safe) {
1193
+ // Do not read a rejected `to:` — treat as no target content
1194
+ // and do not echo result.error, which embeds absolute host
1195
+ // paths.
1196
+ check['path_rejected'] = 'to';
1197
+ check['detail'] = `Pattern "${link['pattern']}" not found in source; target path rejected — resolves outside the project directory`;
1198
+ }
1199
+ else {
1200
+ targetContent = (0, shell_command_projection_cjs_1.platformReadSync)(toCheck.resolved);
1201
+ }
1202
+ }
1203
+ if (targetContent && pat.test(targetContent)) {
1204
+ check['verified'] = true;
1205
+ check['detail'] = 'Pattern found in target';
1206
+ }
1207
+ else if (!check['path_rejected']) {
1208
+ check['detail'] = `Pattern "${link['pattern']}" not found in source or target`;
1209
+ }
1113
1210
  }
1114
1211
  }
1115
- }
1116
- catch {
1117
- check['detail'] = `Invalid regex pattern: ${link['pattern']}`;
1212
+ catch (err) {
1213
+ // Report the errno only — never the full error/message, which for a
1214
+ // re-thrown non-ENOENT platformReadSync failure (e.g. EISDIR from an
1215
+ // untrusted `to:` like "../..") embeds an absolute filesystem path.
1216
+ const code = err?.code ?? 'unknown';
1217
+ check['detail'] = `Pattern check failed: ${code}`;
1218
+ }
1118
1219
  }
1119
1220
  }
1120
1221
  else {
@@ -1146,204 +1247,134 @@ function listMilestoneArchiveDirs(planBase) {
1146
1247
  try {
1147
1248
  return node_fs_1.default
1148
1249
  .readdirSync(milestonesDir, { withFileTypes: true })
1149
- .filter((e) => e.isDirectory() && validate_cjs_2.MILESTONE_ARCHIVE_DIR_RE.test(e.name))
1250
+ .filter((e) => e.isDirectory() && validate_cjs_1.MILESTONE_ARCHIVE_DIR_RE.test(e.name))
1150
1251
  .map((e) => node_path_1.default.join(milestonesDir, e.name))
1151
1252
  .sort((a, b) => node_path_1.default.basename(a).localeCompare(node_path_1.default.basename(b), undefined, { numeric: true }));
1152
1253
  }
1153
1254
  catch (err) {
1154
1255
  // #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.
1256
+ // (no milestones/ dir yet) keeps the long-standing [] contract callers of
1257
+ // this function depend on for "no archives"; every other error (EACCES,
1258
+ // EIO, …) must propagate — otherwise an unreadable milestones/ dir is
1259
+ // silently reported as "no archives" and archived-phase resolution
1260
+ // misbehaves. As of Phase 12 (#3310), `cmdValidateConsistency`'s own
1261
+ // caller of this function (`collectPhaseRoots` -> `getActiveMilestoneArchiveDir`)
1262
+ // was migrated onto `buildPlanningSnapshot` and deleted; this function is
1263
+ // retained solely for its `_listMilestoneArchiveDirs` test seam below.
1160
1264
  if (err.code === 'ENOENT')
1161
1265
  return [];
1162
1266
  throw err;
1163
1267
  }
1164
1268
  }
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 */
1269
+ /**
1270
+ * Wrapper-level fix-text table for `cmdValidateHealth`'s migrated
1271
+ * `HealthDiagnostic` -> `IssueEntry` mapping (Phase 11, #3309). Rules cannot
1272
+ * call `slash()` (forbidden ambient I/O, §8.1 rule 1) so every REPAIRABLE
1273
+ * (non-ADVISE) diagnostic's `fix` text — which the pre-migration code always
1274
+ * built via `${slash('health')} --repair|--backfill ...` — is reconstructed
1275
+ * HERE instead, keyed by `remedy.action`. Each real repair action maps 1:1
1276
+ * back to exactly one pre-migration code (confirmed: no `REMEDY_ACTION`
1277
+ * other than `ADVISE` is used by more than one rule in the migrated table),
1278
+ * so this table reproduces the original `fix` text byte-for-byte, including
1279
+ * its `slash()` calls, without the RULE ever needing to know about `slash`.
1280
+ * Source line refs are the exact pre-migration `addIssue(...)` call each
1281
+ * text was copied from:
1282
+ * - createConfig — verify.cts:1782 (W003)
1283
+ * - resetConfig — verify.cts:1830 (E005)
1284
+ * - regenerateState — verify.cts:1702 (E004)
1285
+ * - addNyquistKey — verify.cts:1847 (W008)
1286
+ * - addAiIntegrationPhaseKey — verify.cts:1857 (W016)
1287
+ * - backfillMilestones — verify.cts:2326 (W018)
1288
+ * ADVISE diagnostics do NOT go through this table — their `fix` is
1289
+ * `diagnostic.remedy.args.command` directly (already a complete, final
1290
+ * string baked in by the rule, confirmed via
1291
+ * `src/health-diagnostic-rules/root-existence.cts`/`config-validation.cts`).
1292
+ */
1293
+ function repairFixText(slash, action) {
1294
+ switch (action) {
1295
+ case REMEDY_ACTION.CREATE_CONFIG:
1296
+ return `Run ${slash('health')} --repair to create with defaults`;
1297
+ case REMEDY_ACTION.RESET_CONFIG:
1298
+ return `Run ${slash('health')} --repair to reset to defaults`;
1299
+ case REMEDY_ACTION.REGENERATE_STATE:
1300
+ return `Run ${slash('health')} --repair to regenerate`;
1301
+ case REMEDY_ACTION.ADD_NYQUIST_KEY:
1302
+ case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY:
1303
+ return `Run ${slash('health')} --repair to add key`;
1304
+ case REMEDY_ACTION.BACKFILL_MILESTONES:
1305
+ return `Run ${slash('health')} --backfill to synthesize missing entries from archive snapshots`;
1306
+ default:
1307
+ return '';
1200
1308
  }
1201
- return archiveDirs[archiveDirs.length - 1];
1202
1309
  }
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
- }
1310
+ /**
1311
+ * Map one `HealthDiagnostic` (rule-table shape) back onto the legacy
1312
+ * `IssueEntry` shape `cmdValidateHealth` has always returned (design doc,
1313
+ * "Output-shape preservation" section).
1314
+ *
1315
+ * `repairable` for a DESTRUCTIVE-risk remedy (regenerateState/resetConfig)
1316
+ * is `false` here — NOT the pre-migration `true` those two codes always
1317
+ * carried. This is a deliberate, disclosed decision (see this batch's
1318
+ * dispatch report): the design doc's own "`--repair` behavior change"
1319
+ * section establishes that `--repair` never actually applies a DESTRUCTIVE
1320
+ * remedy post-migration, so marking it `repairable: true` would mislead a
1321
+ * caller that uses this field to decide whether re-running with `--repair`
1322
+ * will fix anything. `repairable` now means "an automatic repair will
1323
+ * actually run", not merely "a remedy exists to describe" — the more
1324
+ * conservative of the two readings the brief identified, chosen because the
1325
+ * question was genuinely ambiguous and this reading cannot itself cause a
1326
+ * caller to skip a fix that would have worked.
1327
+ */
1328
+ function diagnosticToIssueEntry(diagnostic, slash) {
1329
+ const { code, message, remedy } = diagnostic;
1330
+ if (remedy.action === REMEDY_ACTION.ADVISE) {
1331
+ return { code, message, fix: remedy.args['command'], repairable: false };
1332
+ }
1333
+ return {
1334
+ code,
1335
+ message,
1336
+ fix: repairFixText(slash, remedy.action),
1337
+ repairable: remedy.risk !== REMEDY_RISK.DESTRUCTIVE,
1230
1338
  };
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
1339
  }
1264
1340
  function cmdValidateConsistency(cwd, raw) {
1265
1341
  const planBase = planningDir(cwd);
1266
1342
  const roadmapPath = node_path_1.default.join(planBase, 'ROADMAP.md');
1267
1343
  const errors = [];
1268
1344
  const warnings = [];
1345
+ // Pre-check, stays OUTSIDE the rule table — same shape as `validate.health`'s
1346
+ // E001/E010/I010 (design doc, "Which rules run where"). `.planning/` cannot
1347
+ // build a `PlanningSnapshot` worth evaluating without a ROADMAP.md to read.
1269
1348
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1270
1349
  errors.push('ROADMAP.md not found');
1271
1350
  output({ passed: false, errors, warnings }, raw, 'failed');
1272
1351
  return;
1273
1352
  }
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
- }
1353
+ // ─── Rule-table evaluation (Phase 12, #3310) ───────────────────────────────
1354
+ // Replaces this function's entire hand-rolled disk-vs-roadmap /
1355
+ // numbering-gap / orphan-summary / wave-missing scan — see the design
1356
+ // doc's "Which rules run where" section. `evaluateConsistencyRules` runs
1357
+ // W006/W007 (the SAME `Rule` objects `validate.health` evaluates, reused
1358
+ // verbatim — not a second, independently-drifting copy) plus the four new
1359
+ // C001-C004 rules, against the same `PlanningSnapshot` `validate.health`
1360
+ // builds.
1361
+ const snapshot = buildPlanningSnapshot(cwd);
1362
+ const diagnostics = evaluateConsistencyRules(snapshot);
1363
+ // Every current C0NN/W006/W007 diagnostic is `SEVERITY.WARNING` (confirmed
1364
+ // by direct read of `consistency.cts`/`roadmap-disk-consistency.cts`) —
1365
+ // matching the pre-migration shape, where only the ROADMAP-missing
1366
+ // pre-check above ever populated `errors`. Bucketed defensively by
1367
+ // severity anyway, mirroring `cmdValidateHealth`'s own pattern, so a
1368
+ // future ERROR-severity rule added to `CONSISTENCY_RULES` lands in the
1369
+ // right bucket without another migration.
1370
+ const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1371
+ const slash = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, _slashRuntime);
1372
+ for (const diagnostic of diagnostics) {
1373
+ const entry = diagnosticToIssueEntry(diagnostic, slash);
1374
+ if (diagnostic.severity === HEALTH_SEVERITY.ERROR)
1375
+ errors.push(entry.message);
1376
+ else
1377
+ warnings.push(entry);
1347
1378
  }
1348
1379
  const passed = errors.length === 0;
1349
1380
  output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
@@ -1367,663 +1398,101 @@ function cmdValidateHealth(cwd, options, raw) {
1367
1398
  return;
1368
1399
  }
1369
1400
  // 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
1401
  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');
1381
1402
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1382
1403
  const slash = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, _slashRuntime);
1404
+ // Second (and last) pre-check that stays OUTSIDE the rule table entirely
1405
+ // (design doc, "Two guards that stay OUTSIDE the rule table entirely" —
1406
+ // this one must run BEFORE any snapshot is built, since a flat
1407
+ // `evaluateRules` pass over an entirely-absent `.planning/` would produce
1408
+ // spurious per-rule clutter no one asked for, not a clean E001-only
1409
+ // report).
1410
+ if (!node_fs_1.default.existsSync(rootBase)) {
1411
+ const errors = [
1412
+ {
1413
+ code: 'E001',
1414
+ message: '.planning/ directory not found',
1415
+ fix: `Run ${slash('new-project')} to initialize`,
1416
+ repairable: false,
1417
+ },
1418
+ ];
1419
+ output({ status: 'broken', errors, warnings: [], info: [], repairable_count: 0 }, raw);
1420
+ return;
1421
+ }
1422
+ // ─── Rule-table evaluation (Phase 11, #3309) ───────────────────────────────
1423
+ // Replaces the entire hand-rolled addIssue/switch accumulation this
1424
+ // function used to run inline (verify.cts, pre-migration) — see the
1425
+ // design doc's "Output-shape preservation" section for the exact
1426
+ // Diagnostic -> IssueEntry mapping contract this reproduces.
1427
+ const snapshot = buildPlanningSnapshot(cwd);
1428
+ const diagnostics = evaluateRules(snapshot);
1383
1429
  const errors = [];
1384
1430
  const warnings = [];
1385
1431
  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);
1432
+ for (const diagnostic of diagnostics) {
1433
+ const entry = diagnosticToIssueEntry(diagnostic, slash);
1434
+ if (diagnostic.severity === HEALTH_SEVERITY.ERROR)
1435
+ errors.push(entry);
1436
+ else if (diagnostic.severity === HEALTH_SEVERITY.WARNING)
1437
+ warnings.push(entry);
1393
1438
  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".
1439
+ info.push(entry);
1440
+ }
1441
+ // W024 (#2573): STATE.md commit-age freshness — kept OUTSIDE the rule
1442
+ // table, exactly like E001/E010/I010 above. NOT a preservation nicety: the
1443
+ // committed `RULE_W024` (`src/health-diagnostic-rules/state-consistency.cts`)
1444
+ // is a documented PERMANENT no-op (`check` always returns `[]`) because
1445
+ // `readStateHeadFreshness`'s `git log` shell-out is ambient I/O a
1446
+ // `Rule.check(snapshot)` may never perform (§8.1 rule 1) and no
1447
+ // `PlanningSnapshot` field carries a commits-behind count. Migrating
1448
+ // `cmdValidateHealth` onto the rule table as designed would silently
1449
+ // regress `tests/health-validation.test.cjs`'s "W024 — STATE.md commit-age
1450
+ // freshness advisory" suite (7 currently-passing tests exercising the REAL
1451
+ // git-based check end-to-end) — found while wiring this function to the
1452
+ // rule table, fixed inline per this repo's no-defer policy rather than
1453
+ // silently accepting the loss. `cmdValidateHealth` itself (unlike a Rule)
1454
+ // is licensed to perform its own bounded I/O — the same license
1455
+ // `applyRepairs` already relies on (see `health-diagnostic.cts`'s header
1456
+ // comment) — so this reproduces the exact pre-migration check
1457
+ // (`verify.cts`, W024) verbatim, advisory-only: it only ever appends to
1458
+ // `warnings`, never touches `status`/`errors`/the repair set.
1541
1459
  {
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'));
1460
+ const wsBase = planningDir(cwd);
1461
+ const statePath = node_path_1.default.join(wsBase, 'STATE.md');
1462
+ if (node_fs_1.default.existsSync(statePath)) {
1603
1463
  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`);
1464
+ const stateContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
1465
+ const fm = extractFrontmatter(stateContent);
1466
+ const freshness = readStateHeadFreshness(cwd, fm['state_head']);
1467
+ if (freshness.commits_behind !== null &&
1468
+ freshness.commits_behind >= STATE_HEAD_ADVISORY_COMMITS) {
1469
+ warnings.push({
1470
+ code: 'W024',
1471
+ message: `STATE.md was written ${freshness.commits_behind} commits ago (at ${freshness.state_head}) — treat its contents as approximate`,
1472
+ fix: 'Re-read the current phase artifacts before relying on STATE.md, or run a GSD command that refreshes it',
1473
+ repairable: false,
1474
+ });
1607
1475
  }
1608
1476
  }
1609
1477
  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
- }
1478
+ /* intentionally empty — W024 is advisory */
1676
1479
  }
1677
1480
  }
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');
1746
- }
1747
- }
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
- 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);
1819
- }
1820
- }
1821
- }
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
1481
  }
1870
1482
  // ─── 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
- }
1483
+ // `applyRepairs` internally no-ops (produces zero `details` rows, no
1484
+ // filesystem writes) when both `repair` and `backfill` are falsy, so this
1485
+ // call is unconditional — mirroring the original's own
1486
+ // `if (options['repair'] && repairs.length > 0)` gate without needing to
1487
+ // duplicate that condition here. `backfill` is threaded through as its own
1488
+ // boolean (not folded into `repair`), which is what makes `--backfill`
1489
+ // alone now actually trigger `backfillMilestones` — the disclosed latent-
1490
+ // bug fix from `verify.cts:2504`'s previously-unreachable inner gate (see
1491
+ // design doc "Known limits").
1492
+ const repairResult = applyRepairs(cwd, diagnostics, Boolean(options['repair']), Boolean(options['backfill']));
1493
+ // The legacy `repairs_performed` shape never carried a `code` field —
1494
+ // strip it before it reaches JSON output.
1495
+ const repairActions = repairResult.details.map(({ code: _code, ...rest }) => rest);
2027
1496
  let status;
2028
1497
  if (errors.length > 0) {
2029
1498
  status = 'broken';
@@ -2047,8 +1516,13 @@ function cmdValidateHealth(cwd, options, raw) {
2047
1516
  return result;
2048
1517
  }
2049
1518
  function cmdValidateAgents(cwd, raw) {
2050
- const agentStatus = checkAgentsInstalled((0, runtime_slash_cjs_1.resolveRuntime)(cwd), cwd);
1519
+ const runtime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1520
+ const agentStatus = checkAgentsInstalled(runtime, cwd);
2051
1521
  const expected = Object.keys(MODEL_PROFILES);
1522
+ // #3242 ADR-2313 D6 — additive: validates posture (never an Anthropic-flavored
1523
+ // model or an orphaned reasoning-effort pin in a Codex agent .toml), not just
1524
+ // presence. checkAgentsInstalled above is untouched.
1525
+ const codexPosture = checkCodexModelPosture(runtime, cwd);
2052
1526
  output({
2053
1527
  agents_dir: agentStatus.agents_dir,
2054
1528
  agents_found: agentStatus.agents_installed,
@@ -2056,6 +1530,7 @@ function cmdValidateAgents(cwd, raw) {
2056
1530
  missing: agentStatus.missing_agents,
2057
1531
  incomplete: agentStatus.incomplete_agents,
2058
1532
  expected,
1533
+ codex_posture: codexPosture,
2059
1534
  }, raw);
2060
1535
  }
2061
1536
  function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
@@ -2069,21 +1544,20 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
2069
1544
  output({ block: false, drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
2070
1545
  return;
2071
1546
  }
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)
1547
+ // Resolve the phase directory with the canonical phase-directory matcher
1548
+ // (phase-id.cjs::matchPhaseDirs), not a naive substring test. A bare
1549
+ // `.includes(phaseArg)` lets a non-existent phase silently match a different
1550
+ // phase whose directory name merely contains the requested token (e.g. "1"
1551
+ // matching "11-expansion"), making the drift gate inspect the wrong phase.
1552
+ // This shares the one selection rule with find-phase / verify
1553
+ // phase-completeness rather than restating it. (#1571, #2528)
2078
1554
  let phaseDir = null;
2079
1555
  const normalizedPhase = normalizePhaseName(phaseArg);
2080
1556
  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
- }
1557
+ const dirNames = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1558
+ const drift = matchPhaseDirs(dirNames, normalizedPhase).matches[0];
1559
+ if (drift)
1560
+ phaseDir = node_path_1.default.join(phasesDir, drift);
2087
1561
  if (!phaseDir) {
2088
1562
  const exact = node_path_1.default.join(phasesDir, phaseArg);
2089
1563
  if (node_fs_1.default.existsSync(exact))
@@ -2093,8 +1567,14 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
2093
1567
  output({ block: false, drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` }, raw);
2094
1568
  return;
2095
1569
  }
1570
+ // #3183: canonical LIVE plan/summary sets (root+nested,
1571
+ // status: superseded EXCLUDED) from the single owner, rather than a
1572
+ // root-only readdirSync filter — a superseded plan's claimed
1573
+ // files_modified is no longer treated as an expected drift target, and
1574
+ // nested (#3139 layout) plans/summaries are no longer invisible to the
1575
+ // drift check.
1576
+ const { planFiles, summaryFiles } = planScanMod.scanPhasePlans(phaseDir);
2096
1577
  const allFiles = [];
2097
- const planFiles = node_fs_1.default.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md'));
2098
1578
  for (const pf of planFiles) {
2099
1579
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, pf), 'utf-8');
2100
1580
  const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
@@ -2104,7 +1584,6 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
2104
1584
  }
2105
1585
  }
2106
1586
  let executionLog = '';
2107
- const summaryFiles = node_fs_1.default.readdirSync(phaseDir).filter((f) => f.endsWith('-SUMMARY.md'));
2108
1587
  for (const sf of summaryFiles) {
2109
1588
  executionLog += node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, sf), 'utf-8') + '\n';
2110
1589
  }
@@ -2285,6 +1764,7 @@ module.exports = {
2285
1764
  cmdValidateAgents,
2286
1765
  cmdVerifySchemaDrift,
2287
1766
  cmdVerifyCodebaseDrift,
1767
+ STATE_HEAD_ADVISORY_COMMITS,
2288
1768
  // Test seam (#1883): listMilestoneArchiveDirs is private and exercised through
2289
1769
  // the validate command, which runs in a subprocess — an fs monkeypatch in the
2290
1770
  // test process cannot reach it. Exposed under a leading underscore so the