@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
@@ -43,6 +43,8 @@ exports.projectPersistentPathExportActions = projectPersistentPathExportActions;
43
43
  exports.isSpawnTimeout = isSpawnTimeout;
44
44
  exports.execGit = execGit;
45
45
  exports.execNpm = execNpm;
46
+ exports.resolveExecutableBinary = resolveExecutableBinary;
47
+ exports.projectSpawnInvocation = projectSpawnInvocation;
46
48
  exports.execTool = execTool;
47
49
  exports.resolveGsdToolsPath = resolveGsdToolsPath;
48
50
  exports.dispatchGsdCommand = dispatchGsdCommand;
@@ -58,6 +60,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
58
60
  // can intercept calls from this seam — destructured imports capture references
59
61
  // at load time and become un-mockable.
60
62
  const node_child_process_1 = __importDefault(require("node:child_process"));
63
+ const pattern_cjs_1 = require("./pattern.cjs");
61
64
  /**
62
65
  * Convert a filesystem path to POSIX form (forward slashes) by translating the
63
66
  * platform-native separator. Single seam for native→POSIX conversion.
@@ -306,7 +309,7 @@ function isManagedHookCommand(commandText, opts = {}) {
306
309
  return false;
307
310
  }
308
311
  for (const basename of managedBasenames) {
309
- const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
312
+ const escapedBasename = (0, pattern_cjs_1.escapeRegex)(basename);
310
313
  const pattern = new RegExp(`(^|[\\\\/\\s"'` + '`' + `])${escapedBasename}(?=$|[\\s"'` + '`' + `])`);
311
314
  if (pattern.test(normalizedCommand))
312
315
  return true;
@@ -589,15 +592,283 @@ function execNpm(args, opts = {}) {
589
592
  });
590
593
  return _spawnResult(result, 'npm');
591
594
  }
595
+ /**
596
+ * Default PATHEXT when Windows does not supply one. Matches the value
597
+ * `gsd-tools.cjs`'s `resolveSpawnBinary` shipped in #3275; kept identical so the
598
+ * delegation is a behavior-preserving move rather than a redefinition.
599
+ */
600
+ const DEFAULT_PATHEXT = '.EXE;.CMD;.BAT;.COM';
601
+ /** Windows extensions that must be mediated through cmd.exe rather than spawned. */
602
+ const CMD_MEDIATED_EXT = /\.(cmd|bat)$/i;
603
+ function _isFile(candidate, requireExecutable = false, platform = process.platform) {
604
+ try {
605
+ if (!node_fs_1.default.statSync(candidate).isFile())
606
+ return false;
607
+ if (requireExecutable && platform !== 'win32')
608
+ node_fs_1.default.accessSync(candidate, node_fs_1.default.constants.X_OK);
609
+ return true;
610
+ }
611
+ catch {
612
+ // Missing, unreadable (EACCES), a broken link, or (when requireExecutable
613
+ // is set) not executable — all mean "not this one".
614
+ return false;
615
+ }
616
+ }
617
+ /**
618
+ * Read an environment variable by name, case-insensitively.
619
+ *
620
+ * Windows environment variable names are case-insensitive and conventionally
621
+ * cased `Path` / `ComSpec`, and `process.env` is a case-insensitive proxy that
622
+ * hides the difference. Spreading it (`{ ...process.env, ...opts.env }`, which
623
+ * `execTool` does whenever a caller supplies `opts.env`) produces a PLAIN object
624
+ * that keeps the OS's actual casing and loses the proxy — so an exact-case
625
+ * `env['PATH']` lookup returns undefined there and the PATH scan silently sees
626
+ * nothing. Caught by the Windows CI lane on #3617; the #3445 tests never hit it
627
+ * because they pass uppercase keys explicitly.
628
+ *
629
+ * Exact match wins when present, so a caller who sets the canonical name pays
630
+ * no scan.
631
+ */
632
+ function _envGet(env, name) {
633
+ const exact = env[name];
634
+ if (exact !== undefined)
635
+ return exact;
636
+ const lower = name.toLowerCase();
637
+ for (const key of Object.keys(env)) {
638
+ if (key.toLowerCase() === lower)
639
+ return env[key];
640
+ }
641
+ return undefined;
642
+ }
643
+ /** cmd.exe's own quoting rule inside a `/c` string: a literal quote is doubled. */
644
+ function _cmdQuoteToken(token) {
645
+ return `"${token.replace(/"/g, '""')}"`;
646
+ }
647
+ /**
648
+ * Build a single verbatim command-line string for `cmd.exe /c` where every
649
+ * token is force-quoted, then wrap the whole thing in one more outer pair.
650
+ * cmd.exe strips exactly one outer quote pair when the string begins with a
651
+ * quote and contains at least two — so the outer wrap disappears and what's
652
+ * left is a sequence of individually-quoted tokens. Force-quoting is the
653
+ * point: an unquoted `a&b` is split by cmd's own metacharacter parsing, but a
654
+ * quoted `"a&b"` is one literal argument.
655
+ */
656
+ function _buildVerbatimCmdLine(target, args) {
657
+ return `"${[target, ...args].map(_cmdQuoteToken).join(' ')}"`;
658
+ }
659
+ /**
660
+ * #3411: resolve a DECLARED command name to the file a spawn can actually start.
661
+ * The single canonical answer to "where is this binary?" for the whole tree.
662
+ *
663
+ * This is the seam `CONTEXT.md` declares as "All OS-facing I/O; single platform
664
+ * seam". Four divergent implementations of this logic existed (#3411): `execNpm`'s
665
+ * `shell:true`, `execTool`'s absence of any handling, `gsd-tools.cjs`'s private
666
+ * scan, and `fallow-runner.cts`'s own candidate array. #3275 folded two of them
667
+ * together in `bin/`; this lifts that resolver into the seam so `bin/` delegates
668
+ * instead of owning a copy.
669
+ *
670
+ * **win32** tries PATHEXT entries ONLY — never the bare name. npm global installs
671
+ * drop an EXTENSIONLESS POSIX sh shim (`...\npm\codex`) next to `codex.CMD`; a
672
+ * bare-name-first scan resolves to it, the cmd.exe mediation gate sees no `.cmd`,
673
+ * and the ENOENT returns unchanged (field-reported on Windows 11 — see #3275).
674
+ * A name that ALREADY carries a PATHEXT-listed extension is tried as-is first, so
675
+ * `foo.exe` resolves to `foo.exe` rather than being probed as `foo.exe.EXE`; a
676
+ * suffix that is not in PATHEXT (`foo.txt`) is not an extension and only feeds the
677
+ * append loop.
678
+ *
679
+ * **POSIX** answers EXISTENCE by scanning PATH for the bare name. `execTool` does
680
+ * NOT consult this on POSIX — the bare name goes to spawnSync unchanged and Node's
681
+ * own PATH search does the work, so macOS/Linux behavior is untouched (#3275
682
+ * acceptance contract).
683
+ *
684
+ * Path-like names (any `/` or `\`) bypass the PATH scan: the name is already an
685
+ * address, so it passes through when it names an existing file.
686
+ *
687
+ * **`opts.prependPaths`** — directories searched BEFORE `env.PATH`, in array
688
+ * order (e.g. a project-local `node_modules/.bin`). Defaults to `[]`, so
689
+ * Phase 1's callers (`execTool`, and `gsd-tools.cjs`'s `resolveSpawnBinary` /
690
+ * `deps.spawn` / `hasBinary`), which set neither new option, are byte-identical
691
+ * to today. The existing per-directory candidate logic (win32 as-is-then-append-
692
+ * PATHEXT; POSIX bare name) applies to prepended directories exactly as it does
693
+ * to `PATH` segments — there is no special-casing.
694
+ *
695
+ * **`opts.requireExecutable`** — when `true` and the platform is not `win32`,
696
+ * a candidate must additionally pass `fs.accessSync(candidate, fs.constants.X_OK)`
697
+ * to count as a match. On `win32` this is a no-op (mode bits do not mean
698
+ * execute on Windows — the same carve-out `fallow-runner`'s prior private
699
+ * resolver already had). Defaults to `false`, so `accessSync` is never called
700
+ * unless a caller opts in. It is opt-in rather than the default because making
701
+ * `X_OK` unconditional would break #3445's suite: those tests stage candidates
702
+ * with plain `fs.writeFileSync` and never set an exec bit (the repo bans
703
+ * `chmod` in tests), so every one of them would resolve to `null` on POSIX.
704
+ *
705
+ * **`opts.pathOverride`** — "search THIS PATH, but read everything else —
706
+ * PATHEXT included — from the ambient environment." When set (`!== undefined`),
707
+ * the PATH search segments come from splitting `pathOverride` on `path.delimiter`
708
+ * instead of from `env.PATH`; `pathOverride: ''` means an explicit EMPTY search
709
+ * path (zero segments), never a fallback to `env.PATH` — use `!== undefined`,
710
+ * not truthiness, to tell "caller supplied a PATH string" apart from "caller
711
+ * supplied nothing". `opts.prependPaths` still comes first. PATHEXT resolution
712
+ * is UNAFFECTED by this option — it still reads from `env` (which defaults to
713
+ * `process.env`) exactly as it does when `pathOverride` is omitted. This exists
714
+ * so a caller that already has its own search path in hand (e.g.
715
+ * `resolveFallowBinary`'s `envPath`) does not have to hand-thread PATHEXT
716
+ * alongside it — a private PATHEXT read is the very shape
717
+ * `local/no-private-binary-resolution` forbids outside this seam.
718
+ *
719
+ * @returns the resolved path, or `null` when nothing matched. Callers fall back to
720
+ * the declared name on `null` so a genuine ENOENT still surfaces (#3086).
721
+ */
722
+ function resolveExecutableBinary(name, opts = {}) {
723
+ if (!name)
724
+ return null;
725
+ const requireExecutable = opts.requireExecutable ?? false;
726
+ const platform = opts.platform ?? process.platform;
727
+ if (name.includes('/') || name.includes('\\')) {
728
+ return _isFile(name, requireExecutable, platform) ? name : null;
729
+ }
730
+ const env = opts.env ?? process.env;
731
+ const rawPath = opts.pathOverride !== undefined ? opts.pathOverride : (_envGet(env, 'PATH') || '');
732
+ const pathSegments = String(rawPath).split(node_path_1.default.delimiter).filter(Boolean);
733
+ const segments = [...(opts.prependPaths ?? []), ...pathSegments];
734
+ if (platform !== 'win32') {
735
+ for (const dir of segments) {
736
+ const candidate = node_path_1.default.join(dir, name);
737
+ if (_isFile(candidate, requireExecutable, platform))
738
+ return candidate;
739
+ }
740
+ return null;
741
+ }
742
+ const exts = String(_envGet(env, 'PATHEXT') || DEFAULT_PATHEXT).split(';').filter(Boolean);
743
+ // A name already ending in a PATHEXT-listed extension is an address, not a stem:
744
+ // probing `foo.exe` as `foo.exe.EXE` would miss the file sitting right there.
745
+ // Compared case-insensitively because PATHEXT casing is not guaranteed.
746
+ const lower = name.toLowerCase();
747
+ const carriesKnownExt = exts.some((ext) => lower.endsWith(ext.toLowerCase()));
748
+ for (const dir of segments) {
749
+ if (carriesKnownExt) {
750
+ const asIs = node_path_1.default.join(dir, name);
751
+ if (_isFile(asIs, requireExecutable, platform))
752
+ return asIs;
753
+ }
754
+ for (const ext of exts) {
755
+ const candidate = node_path_1.default.join(dir, name + ext);
756
+ if (_isFile(candidate, requireExecutable, platform))
757
+ return candidate;
758
+ }
759
+ }
760
+ return null;
761
+ }
762
+ /**
763
+ * #3411: project a declared `(command, args)` into the pair `spawnSync` can
764
+ * actually execute on this platform.
765
+ *
766
+ * Resolution alone does not make Windows work: `CreateProcess` cannot execute a
767
+ * `.cmd`/`.bat` at all, so the cmd.exe mediation is inseparable from the lookup.
768
+ * Exporting only the resolver would leave every caller to re-derive that half —
769
+ * which is precisely how #3411's four copies accumulated.
770
+ *
771
+ * cmd.exe is invoked as an ordinary program with an EXPLICIT argv array, never via
772
+ * `shell: true`. `shell:true` on Windows is the mechanism behind CVE-2024-27980
773
+ * (argument injection through `.bat`/`.cmd`), and Node 26 deprecates it with an
774
+ * args array (DEP0190) because arguments are concatenated rather than escaped.
775
+ *
776
+ * Mediation fires when the target — the resolved path, or the declared name when
777
+ * resolution found nothing — carries a `.cmd`/`.bat` extension. A BARE name that
778
+ * resolved to nothing is passed through verbatim so the spawn fails with ENOENT;
779
+ * mediating it would turn `{exitCode:127, 'foo: not found'}` into cmd.exe's exit
780
+ * 9009 and silently change the not-found contract callers depend on.
781
+ *
782
+ * POSIX is a strict no-op: the declared command is returned unchanged and the
783
+ * environment is never consulted.
784
+ *
785
+ * The mediated command line is built VERBATIM rather than left to libuv: libuv's
786
+ * `quote_cmd_arg` only force-quotes an argument that contains a space, tab, or
787
+ * quote — it does not know about cmd.exe metacharacters (`&`, `|`, `>`, `<`,
788
+ * `^`, ...) at all, so an argument like `a&calc` reaches cmd.exe unquoted and
789
+ * gets re-parsed as two commands (the CVE-2024-27980 argument-injection class).
790
+ * Node's own CVE-2024-27980 escaping does not help here because it only fires
791
+ * when the spawned FILE itself is a `.bat`/`.cmd` — in this seam the spawned
792
+ * file is `cmd.exe`, not the target. Building the line ourselves and passing
793
+ * `windowsVerbatimArguments: true` (the shape Rust's std uses for the sibling
794
+ * CVE-2024-24576) means every token is force-quoted inside one outer pair, so
795
+ * a metacharacter inside a quoted token can never split the command line.
796
+ *
797
+ * KNOWN LIMIT: `%VAR%` still expands inside a cmd `/c` string, and there is no
798
+ * escape for `%` outside a batch file — an argument containing `%FOO%` is
799
+ * substituted with the environment value regardless of quoting. That is an
800
+ * information-disclosure limit, not arbitrary execution, and it's the same
801
+ * limit Rust's std documents for its own `CommandExt::raw_arg` escape hatch.
802
+ *
803
+ * CALLER CHOICE: this function's return value carries two independently
804
+ * adoptable pieces of information, and a caller may take either, both, or
805
+ * neither. `windowsVerbatimArguments: true` marks the cases where mediation
806
+ * was REQUIRED — the caller MUST adopt `command`+`args` together, since a
807
+ * `.cmd`/`.bat` genuinely cannot be spawned any other way. A merely-resolved
808
+ * `.exe` path (no mediation flag set) is only an OFFER: a caller may decline
809
+ * it and keep spawning the declared name instead, to hold its own observable
810
+ * contract stable. `execTool` (this file, below) is exactly such a caller —
811
+ * it adopts the mediated pair when `windowsVerbatimArguments` is set, but
812
+ * otherwise passes the declared `program`/`args` through untouched.
813
+ */
814
+ function projectSpawnInvocation(command, args = [], opts = {}) {
815
+ const platform = opts.platform ?? process.platform;
816
+ if (platform !== 'win32')
817
+ return { command, args, resolved: null };
818
+ const env = opts.env ?? process.env;
819
+ const resolved = resolveExecutableBinary(command, { platform, env });
820
+ // Mediate against the resolved path when we have one, else against the declared
821
+ // name. An unresolved name is mediated ONLY when it already declares .cmd/.bat:
822
+ // PATH-only resolution misses a batch file sitting in the current directory,
823
+ // which `cmd.exe /c` still finds — the behavior gsd-tools.cjs shipped before
824
+ // this consolidation, preserved here rather than silently narrowed.
825
+ const target = resolved ?? command;
826
+ if (!CMD_MEDIATED_EXT.test(node_path_1.default.basename(target))) {
827
+ // A BARE name that resolved to nothing is passed through untouched so the
828
+ // spawn fails with ENOENT. Mediating it would turn {exitCode:127,
829
+ // '<name>: not found'} into cmd.exe's exit 9009 and silently change the
830
+ // not-found contract `_spawnResult` and its 53 dependent files rely on.
831
+ return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
832
+ }
833
+ // A CR/LF cannot be represented inside a Windows command line at all — cmd.exe
834
+ // treats it as a line terminator, so mediating it would silently truncate the
835
+ // argument rather than pass it through. Fail visibly instead: fall back to the
836
+ // unmediated shape so the spawn either fails with ENOENT (bare unresolved name)
837
+ // or hands the raw string to CreateProcess, whichever the caller was already
838
+ // prepared to see for a non-.cmd/.bat target.
839
+ if (/[\r\n]/.test(target) || args.some((a) => /[\r\n]/.test(a))) {
840
+ return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
841
+ }
842
+ return {
843
+ command: String(_envGet(env, 'ComSpec') || 'cmd.exe'),
844
+ args: ['/d', '/s', '/c', _buildVerbatimCmdLine(target, args)],
845
+ resolved,
846
+ windowsVerbatimArguments: true,
847
+ };
848
+ }
592
849
  function execTool(program, args, opts = {}) {
593
- const result = node_child_process_1.default.spawnSync(program, args, {
850
+ // #3411: Windows cannot spawn a .cmd/.bat at all — CreateProcess refuses it —
851
+ // so those are mediated through cmd.exe. Everything else keeps the DECLARED
852
+ // program name: libuv's CreateProcess path already performs PATH + PATHEXT
853
+ // search, so resolving a .exe here would buy nothing and would change what
854
+ // this seam's 167 dependents observe being spawned. `tests/graphify.test.cjs`
855
+ // pins that contract by spying on spawnSync's first argument. POSIX never
856
+ // reaches the mediation branch at all.
857
+ const spawnEnv = opts.env ? { ...process.env, ...opts.env } : undefined;
858
+ const invocation = projectSpawnInvocation(program, args, { env: spawnEnv ?? process.env });
859
+ const mediated = invocation.windowsVerbatimArguments === true;
860
+ const result = node_child_process_1.default.spawnSync(mediated ? invocation.command : program, mediated ? invocation.args : args, {
594
861
  cwd: opts.cwd,
595
- env: opts.env ? { ...process.env, ...opts.env } : undefined,
862
+ env: spawnEnv,
596
863
  encoding: 'utf-8',
597
864
  stdio: 'pipe',
598
865
  timeout: opts.timeout ?? 30_000,
599
866
  windowsHide: true,
867
+ ...(mediated ? { windowsVerbatimArguments: true } : {}),
600
868
  });
869
+ // Stamp the DECLARED name, never the resolved path: `_spawnResult` renders
870
+ // `${program}: not found`, and callers across 53 files match on the string they
871
+ // passed. Resolution must not leak an absolute path into that message.
601
872
  return _spawnResult(result, program);
602
873
  }
603
874
  /**
@@ -704,6 +975,7 @@ function probeTty(opts = {}) {
704
975
  const ttyPath = node_child_process_1.default.execFileSync('tty', [], {
705
976
  encoding: 'utf-8',
706
977
  stdio: ['inherit', 'pipe', 'ignore'],
978
+ timeout: 5_000,
707
979
  }).trim();
708
980
  if (!ttyPath || ttyPath === 'not a tty')
709
981
  return null;
@@ -57,10 +57,13 @@ const phaseLifecycle = require("./phase-lifecycle.cjs");
57
57
  const { deriveProgressFromRoadmap } = phaseLifecycle;
58
58
  // eslint-disable-next-line @typescript-eslint/no-require-imports
59
59
  const stateDocument = require("./state-document.cjs");
60
- const { stateExtractField } = stateDocument;
60
+ const { stateFieldValue } = stateDocument;
61
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports
62
62
  const phaseId = require("./phase-id.cjs");
63
- const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId;
63
+ const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, stripProjectCodePrefix } = phaseId;
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
65
+ const stateMod = require("./state.cjs");
66
+ const { readStateHeadFreshness } = stateMod;
64
67
  // eslint-disable-next-line @typescript-eslint/no-require-imports
65
68
  const unusableInput = require("./unusable-input.cjs");
66
69
  const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
@@ -86,15 +89,6 @@ exports.SITUATIONS = Object.freeze([
86
89
  'unknown',
87
90
  ]);
88
91
  // ─── Detection ─────────────────────────────────────────────────────────────────
89
- /** Frontmatter scalar helper: prefer YAML frontmatter, fall back to body field. */
90
- function fmScalar(fm, body, key, bodyField) {
91
- const v = fm[key];
92
- if (typeof v === 'string' && v.trim())
93
- return v.trim();
94
- if (typeof v === 'number' || typeof v === 'boolean')
95
- return String(v);
96
- return stateExtractField(body, bodyField);
97
- }
98
92
  /** Read a scalar value from a nested frontmatter object (e.g. progress.total_phases). */
99
93
  function fmScalarKey(obj, key) {
100
94
  if (!obj || typeof obj !== 'object')
@@ -115,17 +109,115 @@ function parseIntOrNull(s) {
115
109
  }
116
110
  function phaseTokenFromDirName(name) {
117
111
  const token = extractPhaseToken(name);
118
- return /^\d+(?:[A-Z])?(?:\.\d+)*(?:-|$)/i.test(token) ? token : null;
112
+ // #2528: the shape probe runs on the PROJECT-CODE-STRIPPED token. A prefixed
113
+ // directory tokenizes to `MEM-05-80-20`, which does not start with a digit,
114
+ // so the unstripped probe rejected it and the entry was dropped before any
115
+ // resolution ran — every phase in a project-coded plan was invisible here.
116
+ // The full token is still what is returned: `comparePhaseNum` strips the
117
+ // prefix itself, so the sort is unaffected, and `matchPhaseDirs` needs the
118
+ // real directory name.
119
+ const probe = stripProjectCodePrefix(token);
120
+ return /^\d+(?:[A-Z])?(?:\.\d+)*(?:-|$)/i.test(probe) ? token : null;
119
121
  }
120
122
  /**
121
123
  * Parse a `last_activity` value that may be an ISO date or a free-form string
122
124
  * into an epoch-ms timestamp. Returns null when unparseable.
125
+ *
126
+ * #2570: `last_activity` routinely carries a trailing " — <description>" — the
127
+ * shape `templates/state.md` itself prescribes (`Last activity: [YYYY-MM-DD] —
128
+ * [What happened]`), which gsd-core's own STATE.md mirrors into frontmatter.
129
+ * `Date.parse` on the whole string returns NaN, and because `staleActivity`
130
+ * treats null as "not stale" (fails open), the ONLY idle/staleness detector
131
+ * never fired on any project whose last_activity retained its description.
132
+ * Be liberal in what we accept (Postel): read the leading ISO date/time token
133
+ * when the value carries one, so the description suffix — whatever separator
134
+ * (em dash or hyphen) it uses — no longer silently blinds the detector; fall
135
+ * back to a whole-string parse for any other shape a hand edit might use.
123
136
  */
137
+ /** Leading ISO date, with an optional time-of-day and offset. */
138
+ const ISO_LEADING_RE = /^(\d{4})-(\d{2})-(\d{2})((?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)/;
139
+ /**
140
+ * A NAMED timezone designator at the start of the un-reconstructable remainder
141
+ * (#2571). ISO_LEADING_RE's offset group captures only `Z` / `±HH:MM`, so a
142
+ * named zone (GMT, EST, ...) is not in the leading token — it sits here.
143
+ * Reconstructing the token without it would let Date.parse read the time as
144
+ * LOCAL, shifting the instant by the host's offset (a wrong, host-dependent
145
+ * value), so a zone-shaped remainder must fail open (ADR-227).
146
+ *
147
+ * The shape is a short all-caps run (2–5 letters) that stands alone — the
148
+ * negative lookahead excludes the first letter of a Capitalised word like
149
+ * "Milestone", and an optional trailing offset is subsumed because the leading
150
+ * all-caps run already matches. Everything else — a lowercase or Capitalised
151
+ * description, a separator — is describable text and is reconstructed from the
152
+ * leading date.
153
+ *
154
+ * Consulted ONLY when the leading token captured a time-of-day (see the caller):
155
+ * a zone designator qualifies a clock time, so a BARE date can carry no zone
156
+ * hazard — reconstructing it is always just that date's UTC midnight, whatever
157
+ * trails it. Gating on the time keeps a description that merely opens with a
158
+ * tech acronym ("2026-06-08 CI green", "API refactor") on the reconstruct path
159
+ * instead of failing open. The prior "any letter" guard was too liberal — it
160
+ * failed open on every letter-led description and re-opened #2570.
161
+ */
162
+ const ZONE_DESIGNATOR_RE = /^\s*[A-Z]{2,5}(?![A-Za-z])/;
163
+ /**
164
+ * True only when y/m/d name a date that actually exists on the calendar.
165
+ *
166
+ * `Date.parse` validates shape but not value: it rolls an out-of-range day
167
+ * FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`,
168
+ * `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore
169
+ * propagate a different, wrong instant instead of failing safe — precisely
170
+ * what ADR-227 ("validate shape AND value; on failure of either layer coerce
171
+ * to the contract's safe default, never propagate") exists to prevent. A
172
+ * round-trip through Date.UTC detects the rollover: any component the
173
+ * constructor normalised comes back changed.
174
+ */
175
+ function isRealCalendarDate(year, month, day) {
176
+ if (month < 1 || month > 12 || day < 1 || day > 31)
177
+ return false;
178
+ const probe = new Date(Date.UTC(year, month - 1, day));
179
+ return (probe.getUTCFullYear() === year &&
180
+ probe.getUTCMonth() === month - 1 &&
181
+ probe.getUTCDate() === day);
182
+ }
124
183
  function parseActivityTimestamp(raw) {
125
184
  if (!raw)
126
185
  return null;
127
- const ms = Date.parse(raw);
128
- return Number.isNaN(ms) ? null : ms;
186
+ const trimmed = raw.trim();
187
+ const iso = trimmed.match(ISO_LEADING_RE);
188
+ if (iso) {
189
+ const [, year, month, day, time] = iso;
190
+ // Reject an impossible calendar date outright rather than letting
191
+ // Date.parse substitute a rolled-forward one. null = "no activity signal",
192
+ // the safe default staleActivity already fails open on.
193
+ if (!isRealCalendarDate(Number(year), Number(month), Number(day)))
194
+ return null;
195
+ // The date is real, so stay as liberal as before (Postel): a whole-string
196
+ // parse still wins when the engine can make sense of the value. Reading the
197
+ // token first would silently DROP a trailing zone name -- "2026-06-08
198
+ // 12:34:56 GMT" parses whole as 12:34:56Z but as local time from the token,
199
+ // shifting the instant by the host's UTC offset.
200
+ const whole = Date.parse(trimmed);
201
+ if (!Number.isNaN(whole))
202
+ return whole;
203
+ // Whole-string failed: the value carries a suffix the engine can't read as
204
+ // one instant (#2570). Reconstruct from the leading token UNLESS the remainder
205
+ // is a named zone the token dropped (GMT, EST, ...): reconstructing without it
206
+ // reads the time as LOCAL and shifts the instant by the host's offset, so a
207
+ // zone-shaped remainder fails open (ADR-227: never propagate a wrong instant;
208
+ // null is the base's not-stale default). An ordinary description -- the #2570
209
+ // template's " -- description", or a hand edit's bare-space/tab/colon suffix --
210
+ // carries no zone and IS reconstructed. See ZONE_DESIGNATOR_RE for the shape;
211
+ // the earlier "any letter" guard failed open on every description and re-opened
212
+ // #2570 for whitespace-separated suffixes.
213
+ const rest = trimmed.slice(iso[0].length);
214
+ if (time && ZONE_DESIGNATOR_RE.test(rest))
215
+ return null;
216
+ const ms = Date.parse(`${year}-${month}-${day}${time}`);
217
+ return Number.isNaN(ms) ? null : ms;
218
+ }
219
+ const direct = Date.parse(trimmed);
220
+ return Number.isNaN(direct) ? null : direct;
129
221
  }
130
222
  /** Read-only git signals. Any git error is swallowed → "no git signal". */
131
223
  function readGitSignals(cwd) {
@@ -137,6 +229,7 @@ function readGitSignals(cwd) {
137
229
  maxBuffer: 4 * 1024 * 1024,
138
230
  windowsHide: true,
139
231
  stdio: ['pipe', 'pipe', 'pipe'],
232
+ timeout: 10_000,
140
233
  });
141
234
  }
142
235
  catch {
@@ -186,7 +279,16 @@ function detectVerifyFailed(cwd, currentPhaseRaw) {
186
279
  let targetDir;
187
280
  if (phaseToken) {
188
281
  const normalized = normalizePhaseName(phaseToken);
189
- targetDir = entries.find((name) => phaseTokenMatches(name, normalized));
282
+ // #2528: the fourth directory-resolution site, and the one where a miss is
283
+ // silent — a phase whose directory cannot be found reports "not failed",
284
+ // which reads identically to a healthy phase. It must therefore apply the
285
+ // same canonical selection as the locator and the two command scans, or a
286
+ // dir like `05-80-20-cleanup` (phase 5 named "80/20 Cleanup") never
287
+ // surfaces its own failed verification. `entries` is already sorted, and
288
+ // `matchPhaseDirs` filters without reordering, so taking the first match
289
+ // preserves the previous `.find()` selection exactly.
290
+ const { matches } = matchPhaseDirs(entries, normalized);
291
+ targetDir = matches[0];
190
292
  if (!targetDir)
191
293
  return false;
192
294
  }
@@ -253,6 +355,9 @@ function detectSignals(cwd, now = Date.now) {
253
355
  stale_activity: false,
254
356
  roadmap_total_phases: null,
255
357
  roadmap_completed_phases: null,
358
+ // No STATE.md (or unreadable) → no stamp to compare. Unknown, not fresh.
359
+ state_commits_behind: null,
360
+ state_commit_stale: null,
256
361
  };
257
362
  if (!hasPlanning)
258
363
  return empty;
@@ -266,21 +371,29 @@ function detectSignals(cwd, now = Date.now) {
266
371
  // - body prose: `Phase: 3`, `**Status:** verifying`, `Total Phases: 5`
267
372
  // Read each field across every form, scalar-first then nested then body, so
268
373
  // the classifier works on real STATE.md files written by current GSD.
269
- const statusRaw = fmScalar(fm, body, 'status', 'Status');
270
- const pausedAtRaw = fmScalar(fm, body, 'paused_at', 'Paused At');
271
- const lastActivityRaw = fmScalar(fm, body, 'last_activity', 'Last Activity');
374
+ const statusRaw = stateFieldValue(fm, body, 'status', 'Status').value;
375
+ const pausedAtRaw = stateFieldValue(fm, body, 'paused_at', 'Paused At').value;
376
+ const lastActivityRaw = stateFieldValue(fm, body, 'last_activity', 'Last Activity').value;
272
377
  // current_phase: scalar fm → nested (none) → body "Current Phase" → body "Phase".
273
378
  // The body `Phase:` field is the canonical location in prose-form STATE.md
274
379
  // (e.g. "Phase: 3" or "Phase: 3 — ui-review"); parse the leading number.
275
- const currentPhaseRaw = fmScalar(fm, body, 'current_phase', 'Current Phase') ??
276
- stateExtractField(body, 'Phase');
380
+ // #3187 / ADR-3180 Amendment 3 ("0.x split"): this read is DELIBERATELY
381
+ // unscoped — smart-entry classifies `gsd next` routing over the whole body,
382
+ // whereas state.cts's copies of this same field scope it to `## Current
383
+ // Position` (#1776/#2956). Those are two different questions sharing a
384
+ // name; folding the scoped read in here would silently change smart-entry's
385
+ // routing, an undisclosed Tier-2 change (design's Rejected #3). Both owner
386
+ // calls below intentionally pass the unscoped `body`, never a Current-
387
+ // Position slice.
388
+ const currentPhaseRaw = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value ??
389
+ stateFieldValue(fm, body, null, 'Phase').value;
277
390
  // total_phases & percent: nested `progress:` object takes precedence in the
278
391
  // nested schema; scalar fm / body fields cover the flat schema.
279
392
  const progressFm = typeof fm.progress === 'object' ? fm.progress : null;
280
393
  const totalPhasesRaw = fmScalarKey(progressFm, 'total_phases') ??
281
- fmScalar(fm, body, 'total_phases', 'Total Phases');
394
+ stateFieldValue(fm, body, 'total_phases', 'Total Phases').value;
282
395
  const progressRaw = fmScalarKey(progressFm, 'percent') ??
283
- fmScalar(fm, body, 'progress', 'Progress');
396
+ stateFieldValue(fm, body, 'progress', 'Progress').value;
284
397
  // Blockers list: `- <text>` items under a `## Blockers` heading.
285
398
  const blockers = [];
286
399
  const blockersSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true });
@@ -326,6 +439,11 @@ function detectSignals(cwd, now = Date.now) {
326
439
  /* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
327
440
  }
328
441
  }
442
+ // #2573: commit-age freshness proxy. Derived through state.cjs's
443
+ // readStateHeadFreshness so the tri-state and the hash fence stay identical
444
+ // to validate.health's W024 — one derivation, two surfaces.
445
+ const stateHeadRaw = stateFieldValue(fm, body, 'state_head', 'State Head').value;
446
+ const freshness = readStateHeadFreshness(cwd, stateHeadRaw);
329
447
  return {
330
448
  current_phase: parseIntOrNull(currentPhaseRaw),
331
449
  total_phases: parseIntOrNull(totalPhasesRaw),
@@ -342,6 +460,8 @@ function detectSignals(cwd, now = Date.now) {
342
460
  stale_activity: staleActivity,
343
461
  roadmap_total_phases: roadmapTotalPhases,
344
462
  roadmap_completed_phases: roadmapCompletedPhases,
463
+ state_commits_behind: freshness.commits_behind,
464
+ state_commit_stale: freshness.commit_stale,
345
465
  };
346
466
  }
347
467
  // ─── Situation classification ─────────────────────────────────────────────────
@@ -144,7 +144,11 @@ function routeStateCommand({ state, args, cwd, raw, error }) {
144
144
  'signal-resume': () => state.cmdSignalResume(cwd, raw),
145
145
  'planned-phase': () => {
146
146
  const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['phase', 'name', 'plans']);
147
- state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), parsePlans(strArg(a, 'plans')), raw);
147
+ // #3395: --name was parsed here but never forwarded (the StateModule
148
+ // signature had no channel for it), so the argument was silently
149
+ // dropped. It now persists into the Current Position `Phase:` line and
150
+ // the authoritative current_phase_name, mirroring begin-phase.
151
+ state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
148
152
  },
149
153
  validate: () => state.cmdStateValidate(cwd, raw),
150
154
  sync: () => {