@opengsd/gsd-core 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -9,6 +9,14 @@ const os = require('os');
9
9
  // Namespace (not destructured) so tests can inject spawn failures by
10
10
  // monkeypatching childProcess.execFileSync.
11
11
  const childProcess = require('child_process');
12
+ const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
13
+
14
+ // This hook's build-seam outer catch (require.main guard just below) has
15
+ // always exited 0 (fail open — the statusline renders on EVERY prompt, so a
16
+ // build failure must degrade to a blank line rather than break Claude Code's
17
+ // per-render hook). Declared ONCE so that catch's crash() call states its
18
+ // policy explicitly rather than inheriting a default (#3911).
19
+ const ON_CRASH = HOOK_ON_CRASH.ALLOW;
12
20
  // #3582: gsd-core/bin/lib/*.cjs (semver-compare.cjs, state-document.cjs,
13
21
  // active-workstream-store.cjs, planning-workspace.cjs — required below) and
14
22
  // package-identity.cjs are tsc build artifacts (ADR-457), gitignored and
@@ -23,8 +31,12 @@ if (require.main === module) {
23
31
  const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
24
32
  ensureRuntimeBuild();
25
33
  } catch (e) {
26
- process.stdout.write('');
27
- process.exit(0);
34
+ // #3911: crash(ON_CRASH, ...) with an undefined payload preserves the
35
+ // pre-migration `process.stdout.write(''); process.exit(0);` byte-for-
36
+ // byte — undefined makes terminateNow's stdout JSON.stringify throw
37
+ // internally (swallowed there), so fd 1 stays untouched, same as writing
38
+ // an explicit empty string did.
39
+ crash(ON_CRASH, undefined);
28
40
  }
29
41
  }
30
42
  const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
@@ -169,13 +181,27 @@ function readStateFileOrNull(statePath) {
169
181
  * - null when no .planning marker is found at all (GSD not present), or
170
182
  * when a workstream DOES resolve but its STATE.md doesn't exist yet
171
183
  * (negative space: mirrors flat-mode's own silent pre-STATE.md window)
184
+ *
185
+ * @param {string} dir
186
+ * @param {{ stateFreshness?: boolean }} [opts] — #2734, additive/default-off.
187
+ * When true and the resolved state carries a truthy `.stateHead`, attaches
188
+ * `state.freshness` (deriveStateFreshness) before returning — `current` at
189
+ * that point is the project root the walk resolved, which is what AC-4's
190
+ * repo-pinning check needs. Never derived when false or the stamp is
191
+ * absent, so existing callers (default opts) spend zero extra spawns.
172
192
  */
173
- function readGsdState(dir) {
193
+ function readGsdState(dir, opts = {}) {
194
+ const { stateFreshness = false } = opts;
174
195
  const home = os.homedir();
175
196
  let current = dir;
176
197
  for (let i = 0; i < 10; i++) {
177
198
  const flatState = readStateFileOrNull(path.join(current, '.planning', 'STATE.md'));
178
- if (flatState !== null) return flatState;
199
+ if (flatState !== null) {
200
+ if (stateFreshness && flatState.stateHead) {
201
+ flatState.freshness = deriveStateFreshness(current, flatState.stateHead);
202
+ }
203
+ return flatState;
204
+ }
179
205
 
180
206
  if (listAvailableWorkstreams(current).length > 0) {
181
207
  let resolvedWs = null;
@@ -187,7 +213,11 @@ function readGsdState(dir) {
187
213
 
188
214
  if (!resolvedWs) return { noActiveWorkstream: true };
189
215
 
190
- return readStateFileOrNull(planningPaths(current, resolvedWs).state);
216
+ const wsState = readStateFileOrNull(planningPaths(current, resolvedWs).state);
217
+ if (wsState !== null && stateFreshness && wsState.stateHead) {
218
+ wsState.freshness = deriveStateFreshness(current, wsState.stateHead);
219
+ }
220
+ return wsState;
191
221
  }
192
222
 
193
223
  const parent = path.dirname(current);
@@ -238,6 +268,10 @@ function parseStateMd(content) {
238
268
  if (key === 'active_phase') state.activePhase = (v === 'null' || v === '') ? null : v;
239
269
  // next_action: recommended command when idle (discuss-phase / plan-phase / execute-phase / verify-phase)
240
270
  if (key === 'next_action') state.nextAction = (v === 'null' || v === '') ? null : v;
271
+ // #2734: state_head — the commit STATE.md was written against, consumed
272
+ // by deriveStateFreshness() below. Mirrors active_phase/next_action's
273
+ // null/empty handling exactly.
274
+ if (key === 'state_head') state.stateHead = (v === 'null' || v === '') ? null : v;
241
275
  }
242
276
  // next_phases supports both flow array and block-list YAML forms.
243
277
  const npFlowMatch = fm.match(/^next_phases:\s*\[([^\]]*)\]/m);
@@ -371,6 +405,10 @@ function formatGsdState(s) {
371
405
  }
372
406
  }
373
407
 
408
+ // #2734: STATE.md freshness marker — opt-in, appended last.
409
+ const fresh = formatStateFreshness(s.freshness);
410
+ if (fresh) parts.push(fresh);
411
+
374
412
  return parts.join(' · ');
375
413
  }
376
414
 
@@ -476,6 +514,10 @@ function formatGsdStateCompact(s) {
476
514
  }
477
515
  }
478
516
 
517
+ // #2734: STATE.md freshness marker \u2014 opt-in, appended last.
518
+ const fresh = formatStateFreshness(s.freshness);
519
+ if (fresh) parts.push(fresh);
520
+
479
521
  return parts.join(' \u00b7 ');
480
522
  }
481
523
 
@@ -572,13 +614,153 @@ function buildGitSegment(info) {
572
614
  return ` │ \x1b[2m${info.branch}\x1b[0m${state}`;
573
615
  }
574
616
 
617
+ // --- STATE.md freshness marker (opt-in, #2734) --------------------------------
618
+ //
619
+ // Opt-in via `statusline.show_state_freshness: true`. Renders `state ~N
620
+ // commits back` inside the GSD-state segment when STATE.md's `state_head`
621
+ // stamp (#2573) is at least STATE_HEAD_ADVISORY_COMMITS commits behind HEAD.
622
+ // Same impure-reader -> pure-IR -> pure-formatter shape as the git segment
623
+ // above. See .gsd/phase/feat-2734-statusline-state-freshness/40-design.md.
624
+
625
+ // Deliberate mirror of the fence in src/state.cts (STATE_HEAD_HASH_RE) — kept
626
+ // hook-side rather than requiring state.cjs on the per-render path (measured
627
+ // ~20ms; see design doc "Laws that apply"). tests/gsd-statusline.test.cjs
628
+ // asserts behavioral parity against readStateHeadFreshness rather than
629
+ // comparing source (local/no-source-grep forbids the latter anyway).
630
+ const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
631
+
632
+ // Mirror of the constant verify.cts's W024 health check thresholds on
633
+ // (STATE_HEAD_ADVISORY_COMMITS). A test asserts equality with verify.cjs's
634
+ // export so the two copies can't drift.
635
+ const STATE_HEAD_ADVISORY_COMMITS = 20;
636
+
637
+ // Same bound class as GIT_STATUS_TIMEOUT_MS above.
638
+ const STATE_FRESHNESS_GIT_TIMEOUT_MS = 1500;
639
+
640
+ /**
641
+ * Pure function: does raw pass the state_head hash fence? Must run BEFORE any
642
+ * value from STATE.md reaches a git argv slot.
643
+ */
644
+ function isValidStateHeadStamp(raw) {
645
+ return typeof raw === 'string' && STATE_HEAD_HASH_RE.test(raw.trim());
646
+ }
647
+
648
+ /**
649
+ * Run `git rev-list --left-right --count <stamp>...HEAD` in root. Returns raw
650
+ * stdout, or null when git is missing, root isn't a repo, the stamp is
651
+ * unknown, or the call times out. Never throws. Only call with a stamp that
652
+ * already passed isValidStateHeadStamp/the hash fence above.
653
+ */
654
+ function readStateHeadCommits(root, stamp) {
655
+ try {
656
+ return childProcess.execFileSync('git',
657
+ ['-C', root, 'rev-list', '--left-right', '--count', `${stamp}...HEAD`],
658
+ { encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
659
+ stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
660
+ } catch (e) {
661
+ return null;
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Pure function: parse `git rev-list --left-right --count A...B` output
667
+ * ("<left>\t<right>"). Returns { left, right } as non-negative integers, or
668
+ * null when text isn't a matching string (covers null, '', 'garbage', '1',
669
+ * 'a\tb', '\t', and any other unparseable shape).
670
+ */
671
+ function parseRevListCounts(text) {
672
+ if (typeof text !== 'string') return null;
673
+ const m = text.match(/^(\d+)\s+(\d+)\s*$/);
674
+ if (!m) return null;
675
+ return { left: parseInt(m[1], 10), right: parseInt(m[2], 10) };
676
+ }
677
+
678
+ /**
679
+ * Impure -> pure IR: derive the freshness signal for a recorded state_head
680
+ * stamp. Returns { state_head, commits_behind, commit_stale } — never throws,
681
+ * every unresolvable input degrades to the all-null-but-state_head shape.
682
+ *
683
+ * Order (each failure returns immediately, no further work):
684
+ * a. hash fence — malformed/absent stamp never reaches a spawn
685
+ * b. repo pinning — root must own its own .git (mirrors projectOwnsItsRepo
686
+ * in src/state.cts: a filesystem-identity check, not a --show-toplevel
687
+ * string compare, which is unreliable on macOS /private/var and Windows
688
+ * 8.3 paths). Costs no subprocess.
689
+ * c. sub_repos guard — a planning.sub_repos workspace's outer HEAD never
690
+ * advances when code lands in nested children, so a "fresh" answer here
691
+ * would be a confident lie. Costs no subprocess.
692
+ * d. one bounded git spawn: rev-list --left-right --count answers ancestry
693
+ * and distance together. left > 0 means the stamp is not an ancestor of
694
+ * HEAD (reset/rebase/force-push) -> unknown, never "fresh".
695
+ */
696
+ function deriveStateFreshness(root, stamp, deps = {}) {
697
+ const { existsSync = fs.existsSync, readConfig = readGsdConfig, readCounts = readStateHeadCommits } = deps;
698
+
699
+ const raw = typeof stamp === 'string' ? stamp.trim() : '';
700
+ const valid = STATE_HEAD_HASH_RE.test(raw);
701
+ const state_head = valid ? raw.slice(0, 7) : null;
702
+ const nullResult = { state_head, commits_behind: null, commit_stale: null };
703
+ if (!valid || !root) return nullResult;
704
+
705
+ try {
706
+ if (!existsSync(path.join(root, '.git'))) return nullResult;
707
+ } catch (e) {
708
+ return nullResult;
709
+ }
710
+
711
+ try {
712
+ const cfg = readConfig(root);
713
+ const sub = getConfigValue(cfg, 'planning.sub_repos') ?? getConfigValue(cfg, 'sub_repos');
714
+ if (Array.isArray(sub) && sub.length > 0) return nullResult;
715
+ } catch (e) {
716
+ return nullResult;
717
+ }
718
+
719
+ const counts = parseRevListCounts(readCounts(root, raw));
720
+ if (!counts || counts.left > 0) return nullResult;
721
+
722
+ return { state_head, commits_behind: counts.right, commit_stale: counts.right > 0 };
723
+ }
724
+
725
+ /**
726
+ * Pure function: format the freshness IR into the marker text, or '' below
727
+ * STATE_HEAD_ADVISORY_COMMITS (including when commits_behind is absent/null —
728
+ * the unknown case must never render, never mind alarm on it).
729
+ */
730
+ function formatStateFreshness(fresh) {
731
+ if (!fresh || typeof fresh.commits_behind !== 'number' || fresh.commits_behind < STATE_HEAD_ADVISORY_COMMITS) return '';
732
+ return `state ~${fresh.commits_behind} commits back`;
733
+ }
734
+
735
+ /**
736
+ * Pure function: single source of truth for statusline config resolution.
737
+ * `runStatusline()` and `renderStatusline()` previously read this config
738
+ * independently, which had drifted into a live divergence between the two
739
+ * entry points — this collapses both onto one resolver.
740
+ *
741
+ * @param {object} cfg — parsed .planning/config.json (readGsdConfig())
742
+ * @returns {{ showLastCommand: boolean, position: 'end'|'front', stateFormat: 'full'|'compact', showGit: boolean, showStateFreshness: boolean }}
743
+ */
744
+ function resolveStatuslineOptions(cfg) {
745
+ const showLastCommand = getConfigValue(cfg, 'statusline.show_last_command') === true;
746
+ const cfgPos = getConfigValue(cfg, 'statusline.context_position');
747
+ // Clamp any non-'front' value (including absent/null) to 'end' — the single
748
+ // source of truth for this default; composeStatusline's own coercion stays
749
+ // as belt-and-suspenders defense for direct callers.
750
+ const position = cfgPos === 'front' ? 'front' : 'end';
751
+ const stateFormat = getConfigValue(cfg, 'statusline.state_format') === 'compact' ? 'compact' : 'full';
752
+ const showGit = getConfigValue(cfg, 'statusline.show_git') === true;
753
+ const showStateFreshness = getConfigValue(cfg, 'statusline.show_state_freshness') === true;
754
+ return { showLastCommand, position, stateFormat, showGit, showStateFreshness };
755
+ }
756
+
575
757
  // --- stdin ------------------------------------------------------------------
576
758
 
577
759
  function runStatusline() {
578
760
  let input = '';
579
761
  // Timeout guard: if stdin doesn't close within 3s (e.g. pipe issues on
580
762
  // Windows/Git Bash), exit silently instead of hanging. See #775.
581
- const stdinTimeout = setTimeout(() => process.exit(0), 3000);
763
+ const stdinTimeout = setTimeout(() => allow(undefined), 3000);
582
764
  process.stdin.setEncoding('utf8');
583
765
  process.stdin.on('data', chunk => input += chunk);
584
766
  process.stdin.on('end', () => {
@@ -717,31 +899,34 @@ function runStatusline() {
717
899
  // Last-slash-command suffix and context_position config (#2538, #2937).
718
900
  // Reads the active session transcript for the most recent <command-name> tag.
719
901
  // Failure here must never break the statusline — wrap the entire lookup.
902
+ // #2734: config resolution moved to resolveStatuslineOptions() — the single
903
+ // source of truth shared with renderStatusline() below. The two entry
904
+ // points duplicated this resolution byte-for-byte; one copy is what keeps
905
+ // a new key from reaching only one of them.
720
906
  let lastCmdSuffix = '';
721
- let position = 'end';
722
- let stateFormat = 'full';
723
907
  let gitSuffix = '';
908
+ const options = resolveStatuslineOptions(cfg);
724
909
  try {
725
- if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
910
+ if (options.showLastCommand) {
726
911
  const transcriptPath = data.transcript_path;
727
912
  const lastCmd = readLastSlashCommand(transcriptPath);
728
913
  if (lastCmd) {
729
914
  lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`;
730
915
  }
731
916
  }
732
- const cfgPos = getConfigValue(cfg, 'statusline.context_position');
733
- if (cfgPos != null) position = cfgPos;
734
- if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
735
- if (getConfigValue(cfg, 'statusline.show_git') === true) {
917
+ if (options.showGit) {
736
918
  gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
737
919
  }
738
920
  } catch (e) {
739
921
  // Never break the statusline on config/transcript/git errors
740
922
  }
741
923
 
924
+ // #2734: readGsdState is inside `if (!task)` deliberately — when a todo
925
+ // task is in flight the GSD-state segment is not rendered, so spending a
926
+ // freshness git spawn here would spend a subprocess on discarded output.
742
927
  if (!task) {
743
- const state = readGsdState(dir) || {};
744
- gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
928
+ const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {};
929
+ gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
745
930
  }
746
931
 
747
932
  // Output
@@ -752,7 +937,7 @@ function runStatusline() {
752
937
  ? `\x1b[2m${gsdStateStr}\x1b[0m`
753
938
  : null;
754
939
 
755
- process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position }));
940
+ process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position: options.position }));
756
941
  } catch (e) {
757
942
  // Silent fail - don't break statusline on parse errors
758
943
  }
@@ -846,6 +1031,9 @@ module.exports = {
846
1031
  shortGsdStatus, formatGsdStateCompact,
847
1032
  compactModelName,
848
1033
  readGitStatus, parseGitStatus, buildGitSegment,
1034
+ STATE_HEAD_ADVISORY_COMMITS, isValidStateHeadStamp,
1035
+ readStateHeadCommits, parseRevListCounts, deriveStateFreshness,
1036
+ formatStateFreshness, resolveStatuslineOptions,
849
1037
  };
850
1038
 
851
1039
  /**
@@ -857,30 +1045,31 @@ function renderStatusline(data) {
857
1045
  const dir = data.workspace?.current_dir || process.cwd();
858
1046
  const dirname = path.basename(dir);
859
1047
 
1048
+ // #2734: config resolution moved to resolveStatuslineOptions() — the single
1049
+ // source of truth shared with runStatusline() above. The two entry points
1050
+ // duplicated this resolution byte-for-byte; one copy is what keeps a new
1051
+ // key from reaching only one of them.
860
1052
  let lastCmdSuffix = '';
861
- let position = 'end';
862
- let stateFormat = 'full';
863
1053
  let gitSuffix = '';
1054
+ let options = { showLastCommand: false, position: 'end', stateFormat: 'full', showGit: false, showStateFreshness: false };
864
1055
  try {
865
1056
  const cfg = readGsdConfig(dir);
866
- if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
1057
+ options = resolveStatuslineOptions(cfg);
1058
+ if (options.showLastCommand) {
867
1059
  const lastCmd = readLastSlashCommand(data.transcript_path);
868
1060
  if (lastCmd) {
869
1061
  lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`;
870
1062
  }
871
1063
  }
872
- const cfgPos = getConfigValue(cfg, 'statusline.context_position');
873
- if (cfgPos != null) position = cfgPos;
874
- if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
875
- if (getConfigValue(cfg, 'statusline.show_git') === true) {
1064
+ if (options.showGit) {
876
1065
  gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
877
1066
  }
878
1067
  } catch (e) { /* swallow */ }
879
1068
 
880
- const state = readGsdState(dir) || {};
881
- const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
1069
+ const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {};
1070
+ const gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
882
1071
  const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
883
- return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
1072
+ return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position: options.position });
884
1073
  }
885
1074
 
886
1075
  module.exports.renderStatusline = renderStatusline;
@@ -7,10 +7,43 @@
7
7
  #
8
8
  # OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
9
9
  # Enable with: "hooks": { "community": true } in .planning/config.json
10
+ set -euo pipefail
11
+
12
+ # Temp files created below for subprocess stderr capture (config read, command
13
+ # extraction, classifier). A single EXIT trap replaces three hand-rolled
14
+ # mktemp/rm-f pairs so an early or unexpected exit path can never leak one —
15
+ # and a future fourth check does not need its own copy (#3911 review).
16
+ # Idempotent and failure-proof by construction: unset vars expand to "" (a
17
+ # no-op rm -f target), and `|| true` guarantees the trap itself never changes
18
+ # the script's exit status.
19
+ ENABLED_ERR=""
20
+ CMD_ERR=""
21
+ CLASSIFY_ERR=""
22
+ cleanup_temp_files() {
23
+ rm -f "${ENABLED_ERR:-}" "${CMD_ERR:-}" "${CLASSIFY_ERR:-}" 2>/dev/null || true
24
+ }
25
+ trap cleanup_temp_files EXIT
10
26
 
11
27
  # Check opt-in config — exit silently if not enabled
12
28
  if [ -f .planning/config.json ]; then
13
- ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
29
+ ENABLED_ERR=$(mktemp)
30
+ ENABLED=$(node -e "
31
+ try{
32
+ const c=require('./.planning/config.json');
33
+ process.stdout.write(c.hooks?.community===true?'1':'0');
34
+ }catch(e){
35
+ process.stderr.write('CONFIG_READ_FAILED: '+(e&&e.message?e.message:String(e)));
36
+ process.exit(3);
37
+ }
38
+ " 2>"$ENABLED_ERR") || CONFIG_STATUS=$?
39
+ CONFIG_STATUS=${CONFIG_STATUS:-0}
40
+ if [ "$CONFIG_STATUS" != "0" ]; then
41
+ # Could not determine the opt-in flag at all (node missing, JSON parse
42
+ # error other than absence, etc.) — distinct from ".planning/config.json
43
+ # exists and legitimately disables the hook". Say so and pass, per #3838.
44
+ echo "gsd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
45
+ exit 0
46
+ fi
14
47
  if [ "$ENABLED" != "1" ]; then exit 0; fi
15
48
  else
16
49
  exit 0
@@ -19,17 +52,58 @@ fi
19
52
  INPUT=$(cat)
20
53
 
21
54
  # Extract command from JSON using Node (handles escaping correctly, no jq needed)
22
- CMD=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})" 2>/dev/null)
55
+ CMD_ERR=$(mktemp)
56
+ CMD=$(echo "$INPUT" | node -e "
57
+ let d='';
58
+ process.stdin.on('data',c=>d+=c);
59
+ process.stdin.on('end',()=>{
60
+ try{
61
+ process.stdout.write(JSON.parse(d).tool_input?.command||'');
62
+ }catch(e){
63
+ process.stderr.write('COMMAND_EXTRACTION_FAILED: '+(e&&e.message?e.message:String(e)));
64
+ process.exit(3);
65
+ }
66
+ });
67
+ " 2>"$CMD_ERR") || CMD_STATUS=$?
68
+ CMD_STATUS=${CMD_STATUS:-0}
69
+ if [ "$CMD_STATUS" != "0" ]; then
70
+ # Could not extract tool_input.command at all (node missing, malformed
71
+ # JSON, etc.) — distinct from "there is genuinely no command field". Say
72
+ # so and pass, per #3838.
73
+ echo "gsd-validate-commit.sh: could not extract tool_input.command from the hook payload — validator disabled for this call. $(cat "$CMD_ERR")" >&2
74
+ exit 0
75
+ fi
23
76
 
24
77
  # Only check git commit commands.
25
78
  # Delegates to hooks/lib/git-cmd.js isGitSubcommand() — the canonical token-walk
26
79
  # classifier that handles env-prefix, -C path, and full-path git invocations.
27
80
  # A naive `^git\s+commit` regex misses all three; this guard fixes that (#3129).
28
81
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
29
- if GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" node -e "
30
- const {isGitSubcommand}=require(process.env.GIT_CMD_LIB);
31
- process.exit(isGitSubcommand(process.argv[1],'commit')?0:1);
32
- " "$CMD" 2>/dev/null; then
82
+ CLASSIFY_ERR=$(mktemp)
83
+ GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" node -e "
84
+ try {
85
+ const {isGitSubcommand}=require(process.env.GIT_CMD_LIB);
86
+ process.exit(isGitSubcommand(process.argv[1],'commit')?0:1);
87
+ } catch(e) {
88
+ process.stderr.write('CLASSIFIER_THREW: '+(e&&e.message?e.message:String(e)));
89
+ process.exit(3);
90
+ }
91
+ " "$CMD" 2>"$CLASSIFY_ERR" || CLASSIFY_STATUS=$?
92
+ CLASSIFY_STATUS=${CLASSIFY_STATUS:-0}
93
+ if [ "$CLASSIFY_STATUS" != "0" ] && [ "$CLASSIFY_STATUS" != "1" ]; then
94
+ # 0 = is a git commit (validate below); 1 = genuinely not a git commit
95
+ # (real negative, pass silently) — the ONLY intentional non-zero exit the
96
+ # script above ever produces on success. Any other status — 127 node
97
+ # missing, or 3 from the try/catch above when the git-cmd.js require chain
98
+ # throws (e.g. its built dependency, gsd-core/bin/lib/token-scanner.cjs, is
99
+ # a gitignored build artifact and absent on a fresh checkout — run
100
+ # `npm run build:lib`) — means the classifier could not run at all. Say so
101
+ # on stderr and pass (#3838): PreToolUse stderr does not disturb the JSON
102
+ # protocol.
103
+ echo "gsd-validate-commit.sh: could not classify the command via hooks/lib/git-cmd.js (exit $CLASSIFY_STATUS) — validator disabled for this call. If this persists, run \`npm run build:lib\`. $(cat "$CLASSIFY_ERR")" >&2
104
+ exit 0
105
+ fi
106
+ if [ "$CLASSIFY_STATUS" = "0" ]; then
33
107
  # Extract message from -m flag
34
108
  MSG=""
35
109
  if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
@@ -47,6 +47,16 @@
47
47
 
48
48
  'use strict';
49
49
 
50
+ const { allow, deny } = require('./lib/hook-exit.js');
51
+
52
+ // #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
53
+ // migrated to hook-exit.js's deny(undefined, reason) now that
54
+ // hooks/lib/cli-exit.js's terminateNow emits fd 1 and fd 2 (stderrPayload)
55
+ // in INDEPENDENT try/catch blocks — `payload=undefined` cleanly skips the
56
+ // fd 1 write (preserving "nothing written to stdout") instead of throwing
57
+ // into a shared catch that used to also swallow the fd 2 write, which is
58
+ // what silently dropped the deny reason before this fix.
59
+
50
60
  // No realistic destructive command comes anywhere close to this length.
51
61
  const MAX_COMMAND_LENGTH = 4096;
52
62
 
@@ -244,16 +254,11 @@ function destructiveReason(cmd) {
244
254
  }
245
255
 
246
256
  function block(reason) {
247
- process.stderr.write(`GSD windsurf pre_run_command guard: ${reason}\n`);
248
- process.exit(2);
249
- }
250
-
251
- function allow() {
252
- process.exit(0);
257
+ deny(undefined, `GSD windsurf pre_run_command guard: ${reason}\n`);
253
258
  }
254
259
 
255
260
  let input = '';
256
- const stdinTimeout = setTimeout(() => process.exit(0), 10000);
261
+ const stdinTimeout = setTimeout(() => allow(undefined), 10000);
257
262
  process.stdin.setEncoding('utf8');
258
263
  process.stdin.on('data', (chunk) => { input += chunk; });
259
264
  process.stdin.on('end', () => {
@@ -262,14 +267,14 @@ process.stdin.on('end', () => {
262
267
  const data = JSON.parse(input || '{}');
263
268
  const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
264
269
  const commandLine = typeof toolInfo.command_line === 'string' ? toolInfo.command_line : '';
265
- if (!commandLine) { allow(); return; }
266
- if (commandLine.length > MAX_COMMAND_LENGTH) { allow(); return; }
270
+ if (!commandLine) { allow(undefined); return; }
271
+ if (commandLine.length > MAX_COMMAND_LENGTH) { allow(undefined); return; }
267
272
 
268
273
  const reason = destructiveReason(commandLine);
269
274
  if (reason) { block(reason); return; }
270
- allow();
275
+ allow(undefined);
271
276
  } catch {
272
277
  // Silent fail-open — never block a valid tool call due to a hook bug.
273
- allow();
278
+ allow(undefined);
274
279
  }
275
280
  });
@@ -30,6 +30,13 @@
30
30
  const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { spawnSync } = require('child_process');
33
+ const { allow, deny } = require('./lib/hook-exit.js');
34
+ const { reportIfUndetermined } = require('./lib/git-probe.js');
35
+
36
+ // #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
37
+ // migrated to hook-exit.js's deny(undefined, reason) — see
38
+ // gsd-windsurf-pre-command.js's identical note for the fixed defect
39
+ // (terminateNow's fd 1/fd 2 writes now run in independent try/catch blocks).
33
40
 
34
41
  const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true };
35
42
 
@@ -54,16 +61,11 @@ function nearestExistingDir(start) {
54
61
  }
55
62
 
56
63
  function block(reason) {
57
- process.stderr.write(`GSD windsurf pre_write_code guard: ${reason}\n`);
58
- process.exit(2);
59
- }
60
-
61
- function allow() {
62
- process.exit(0);
64
+ deny(undefined, `GSD windsurf pre_write_code guard: ${reason}\n`);
63
65
  }
64
66
 
65
67
  let input = '';
66
- const stdinTimeout = setTimeout(() => process.exit(0), 10000);
68
+ const stdinTimeout = setTimeout(() => allow(undefined), 10000);
67
69
  process.stdin.setEncoding('utf8');
68
70
  process.stdin.on('data', (chunk) => { input += chunk; });
69
71
  process.stdin.on('end', () => {
@@ -72,14 +74,19 @@ process.stdin.on('end', () => {
72
74
  const data = JSON.parse(input || '{}');
73
75
  const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
74
76
  const rawFilePath = typeof toolInfo.file_path === 'string' ? toolInfo.file_path : '';
75
- if (!rawFilePath) { allow(); return; }
77
+ if (!rawFilePath) { allow(undefined); return; }
76
78
 
77
79
  const cwd = process.cwd();
78
80
 
79
81
  // Determine the active project's git root. No git root at all -> nothing
80
82
  // to enforce a boundary against -> fail open.
81
83
  const cwdTopResult = git(['rev-parse', '--show-toplevel'], cwd);
82
- if (cwdTopResult.status !== 0 || !cwdTopResult.stdout) { allow(); return; }
84
+ // #3911: a timeout/spawn-failure result is indistinguishable from a clean
85
+ // "no git root here" answer by status/stdout alone — reportIfUndetermined
86
+ // is a no-op on a genuine negative and only fires when the probe itself
87
+ // could not run. The allow() below is UNCHANGED either way.
88
+ reportIfUndetermined('gsd-windsurf-pre-write', 'git rev-parse --show-toplevel (cwd)', cwdTopResult);
89
+ if (cwdTopResult.status !== 0 || !cwdTopResult.stdout) { allow(undefined); return; }
83
90
  const cwdTopRaw = cwdTopResult.stdout.trim();
84
91
 
85
92
  const filePath = path.isAbsolute(rawFilePath) ? path.resolve(rawFilePath) : path.resolve(cwd, rawFilePath);
@@ -95,14 +102,16 @@ process.stdin.on('end', () => {
95
102
  }
96
103
  })(),
97
104
  );
98
- if (!checkDir) { allow(); return; } // synthetic path with no existing ancestor — fail open
105
+ if (!checkDir) { allow(undefined); return; } // synthetic path with no existing ancestor — fail open
99
106
 
100
107
  const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
108
+ reportIfUndetermined('gsd-windsurf-pre-write', 'git rev-parse --show-toplevel (file location)', fileTopResult);
101
109
  if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
102
110
  // Not inside any git worktree. Distinguish "inside a .git/ internals
103
111
  // directory" (dangerous — BLOCK) from "outside all git repos entirely"
104
112
  // (not the escape vector this guard targets — fail open).
105
113
  const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
114
+ reportIfUndetermined('gsd-windsurf-pre-write', 'git rev-parse --is-inside-git-dir', insideGitDir);
106
115
  if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
107
116
  block(
108
117
  `'${filePath}' is inside a git internal (.git) directory, not the active project at ` +
@@ -111,12 +120,12 @@ process.stdin.on('end', () => {
111
120
  );
112
121
  return;
113
122
  }
114
- allow();
123
+ allow(undefined);
115
124
  return;
116
125
  }
117
126
 
118
127
  const fileTopRaw = fileTopResult.stdout.trim();
119
- if (fileTopRaw === cwdTopRaw) { allow(); return; }
128
+ if (fileTopRaw === cwdTopRaw) { allow(undefined); return; }
120
129
 
121
130
  // BLOCK: file resolves to a different git root than the active project.
122
131
  block(
@@ -127,6 +136,6 @@ process.stdin.on('end', () => {
127
136
  );
128
137
  } catch {
129
138
  // Silent fail-open — never block a valid tool call due to a hook bug.
130
- allow();
139
+ allow(undefined);
131
140
  }
132
141
  });