@opengsd/gsd-core 1.9.1 → 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 (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -20,6 +20,9 @@
20
20
  * resolve-model <agent-type> Get model for agent based on profile
21
21
  * find-phase <phase> Find phase directory by number
22
22
  * commit <message> [--files f1 f2] [--no-verify] Commit planning docs
23
+ * commit-docs-guard enable|disable Opt-in .git/hooks/pre-commit guard
24
+ * that refuses a commit staging
25
+ * .planning/ when commit_docs is false
23
26
  * commit-to-subrepo <msg> --files f1 f2 Route commits to sub-repos
24
27
  * verify-summary <path> Verify a SUMMARY.md file
25
28
  * generate-slug <text> Convert text to URL-safe slug
@@ -68,6 +71,14 @@
68
71
  * milestone complete <version> Archive milestone, create MILESTONES.md
69
72
  * [--name <name>]
70
73
  * [--no-archive-phases] Skip moving phase dirs to milestones/vX.Y-phases/ (archived by default)
74
+ * [--archive-quick] Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
75
+ * Quick Tasks Completed table (#2142; opt-in, default OFF)
76
+ *
77
+ * milestone archive-quick <version> Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
78
+ * Quick Tasks Completed table, WITHOUT the milestone complete close-out
79
+ * (no ROADMAP/REQUIREMENTS/MILESTONES.md writes, no completion guards);
80
+ * safe against an already-completed milestone (#2142 escalation)
81
+ * [--dry-run] Preview what would move, mutates nothing
71
82
  *
72
83
  * User Story Validation:
73
84
  * user-story validate --story "..." Validate "As a / I want to / so that" format
@@ -80,6 +91,7 @@
80
91
  * drift-guard severity --status <S> Classify a symbol verdict into { severity, hardBlock }
81
92
  * [--authority <A>] Status: VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE
82
93
  * Authority: grep|intel|treesitter|lsp|scip (default: config-resolved)
94
+ * drift-guard phase-status [--phase N] Compare STATE.md vs ROADMAP.md phase status
83
95
  *
84
96
  * Validation:
85
97
  * validate consistency Check phase numbering, disk/roadmap sync
@@ -259,11 +271,13 @@ try {
259
271
  }
260
272
  } catch { /* advisory — never block */ }
261
273
 
262
- const { getActiveWorkstream } = require('./lib/planning-workspace.cjs');
263
- const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs');
274
+ const { resolveActiveWorkstream, applyResolvedWorkstreamEnv, peekActiveWorkstream } = require('./lib/active-workstream-store.cjs');
264
275
  const state = require('./lib/state.cjs');
265
276
  const phase = require('./lib/phase.cjs');
266
277
  const roadmap = require('./lib/roadmap.cjs');
278
+ // #3024: resolve skills root for the sync-skills workflow (install.js is not
279
+ // shipped in installed trees; gsd-tools IS shipped, so the workflow calls this).
280
+ const { getGlobalSkillsBase, isRegisteredRuntimeId } = require('./lib/runtime-homes.cjs');
267
281
  // #1561 — assumption-delta advisory checkpoint detector (pure function).
268
282
  const { detectAssumptionDelta } = require('./lib/assumption-delta.cjs');
269
283
  const verify = require('./lib/verify.cjs');
@@ -302,7 +316,7 @@ const { routeCheckCommand } = require('./lib/check-command-router.cjs');
302
316
  const { routeTaskCommand } = require('./lib/task-command-router.cjs');
303
317
  const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
304
318
  const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
305
- const { getEffectiveAuthority, classifyDriftSeverity } = require('./lib/plan-drift-guard.cjs');
319
+ const { getEffectiveAuthority, classifyDriftSeverity, comparePhaseStatus } = require('./lib/plan-drift-guard.cjs');
306
320
 
307
321
  // ─── Bridge collapsed (Phase 4) ────────────────────────────────────────────────
308
322
  // Non-family commands now run through their CJS handlers directly. Keep the
@@ -918,6 +932,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
918
932
  commands.cmdCheckCommit(cwd, raw);
919
933
  }
920
934
 
935
+ function routeCommitDocsGuard({ args, cwd, raw, error }) {
936
+ const subcommand = args[1];
937
+ if (subcommand === 'enable') {
938
+ commands.cmdCommitDocsGuardEnable(cwd, raw);
939
+ } else if (subcommand === 'disable') {
940
+ commands.cmdCommitDocsGuardDisable(cwd, raw);
941
+ } else {
942
+ error('Unknown commit-docs-guard subcommand. Available: enable, disable', ERROR_REASON.SDK_UNKNOWN_COMMAND);
943
+ }
944
+ }
945
+
921
946
  function routeCommitToSubrepo({ args, cwd, raw, error }) {
922
947
  const message = args[1];
923
948
  const filesIndex = args.indexOf('--files');
@@ -1044,6 +1069,37 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1044
1069
  commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1045
1070
  }
1046
1071
 
1072
+ function routeSkillsRoot({ args, raw, error }) {
1073
+ // #3024: resolve the global skills base directory for a runtime.
1074
+ // The sync-skills workflow previously shelled out to install.js --skills-root,
1075
+ // but install.js is not shipped in installed trees. gsd-tools IS shipped, so
1076
+ // the workflow now calls `gsd-tools query skills-root <runtime>` instead.
1077
+ const runtime = args[1];
1078
+ if (!runtime) {
1079
+ error('Usage: gsd-tools query skills-root <runtime>');
1080
+ }
1081
+ // Defect B (#3024): validate the runtime id against the shipped capability
1082
+ // registry's canonical runtime set BEFORE resolving anything.
1083
+ // getGlobalSkillsBase falls through getGlobalConfigDir's unknown-runtime
1084
+ // branch to claude's skills root for ANY id it doesn't recognize, so an
1085
+ // unknown, empty/whitespace-only, path-traversal, or shell-metacharacter
1086
+ // runtime arg would otherwise silently resolve to claude's path instead of
1087
+ // failing loudly. isRegisteredRuntimeId does an own-property lookup (not a
1088
+ // bare index), rejecting `__proto__`/`constructor`/`prototype` runtime
1089
+ // ids, and is the SAME validator install.js's `--skills-root` entry point
1090
+ // calls, so the two shipped entry points can never diverge on which
1091
+ // runtime ids they accept.
1092
+ if (!isRegisteredRuntimeId(runtime)) {
1093
+ error(`Unknown runtime "${runtime}" — must be a registered runtime id`);
1094
+ }
1095
+ const trimmedRuntime = typeof runtime === 'string' ? runtime.trim() : '';
1096
+ const skillsRoot = getGlobalSkillsBase(trimmedRuntime);
1097
+ if (skillsRoot === null) {
1098
+ error(`No skills root found for runtime "${trimmedRuntime}"`);
1099
+ }
1100
+ output({ skills_root: skillsRoot }, raw, skillsRoot);
1101
+ }
1102
+
1047
1103
  function routeProjectInstructionFile({ args, cwd, raw, error }) {
1048
1104
  // #1529: pure runtime→filename projection. Backs the
1049
1105
  // `gsd_run query project-instruction-file --runtime <r>` call in
@@ -1145,16 +1201,33 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1145
1201
  const cp = require('node:child_process');
1146
1202
  const fsx = require('node:fs');
1147
1203
  const os = require('node:os');
1148
- const { REVIEWER_LANES } = require('./lib/review-lane-descriptor.cjs');
1204
+ const { REVIEWER_LANES, mergeReviewerLanes } = require('./lib/review-lane-descriptor.cjs');
1149
1205
  const { resolveLanePlan } = require('./lib/review-lane-invocation.cjs');
1150
1206
  const runner = require('./lib/review-lane-runner.cjs');
1151
1207
  const cfgLoader = require('./lib/config-loader.cjs');
1208
+ const capabilityLoader = require('./lib/capability-loader.cjs');
1152
1209
 
1153
1210
  const flag = (name) => {
1154
1211
  const i = args.indexOf(name);
1155
1212
  return i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith('--') ? args[i + 1] : null;
1156
1213
  };
1157
1214
  const sub = args[1];
1215
+ // Fail fast on an unrecognized subcommand. Without this check, `sub` fell through
1216
+ // to the `sub !== 'invoke'` usage-error branch far below (after loading the
1217
+ // capability registry AND building a per-lane plan for every lane — which itself
1218
+ // spawns one child `query resolve-execution` process per lane via `effortFor`,
1219
+ // up to 12 subprocess spawns for the default lane set) before ever reporting the
1220
+ // error. That made an invalid subcommand slow instead of instant, and under bench
1221
+ // load (many sequential node spawns) `review-lane bogus` could exceed a caller's
1222
+ // spawn timeout and be killed before writing anything to stderr — the CI-observed
1223
+ // failure was empty stdout AND stderr, not the expected usage message (#3148).
1224
+ // `plan`/`invoke` are the only subs that need the expensive plan-building path
1225
+ // below; `sections`/`flags` return earlier still. Anything else errors here, before
1226
+ // any of that work starts.
1227
+ if (!['plan', 'invoke', 'sections', 'flags'].includes(sub)) {
1228
+ error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
1229
+ return;
1230
+ }
1158
1231
  const runDir = flag('--run-dir') || '.';
1159
1232
  const repoRoot = flag('--repo-root') || cwd;
1160
1233
 
@@ -1176,8 +1249,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1176
1249
 
1177
1250
  const selected = (flag('--selected') || '')
1178
1251
  .split(',').map((s) => s.trim()).filter(Boolean);
1179
- const laneBySlug = new Map(REVIEWER_LANES.map((l) => [l.slug, l]));
1180
- const chosen = selected.length ? selected : REVIEWER_LANES.map((l) => l.slug);
1252
+ // ADR-2782 D8 (#2927): the lane map is first-party ∪ INSTALLED overlay
1253
+ // `reviewer` bodies, first-party winning on slug collision. Before this merge
1254
+ // the map was built from the frozen REVIEWER_LANES array alone, so an installed,
1255
+ // consented third-party reviewer lane was roster-visible (deriveReviewerSlugs)
1256
+ // and disclosed at install (collectReviewerLaneSurfaces) but never selectable,
1257
+ // plannable, or invocable — `sections`/`flags`/`plan`/`invoke` all consumed this
1258
+ // one map. The overlay body is field-identical to a ReviewerLane (ADR-2782 D1,
1259
+ // "no translation layer"), so `mergeReviewerLanes` is a pure merge, not a
1260
+ // projection. loadRegistry is TOTAL and never throws on a malformed overlay
1261
+ // (it skips the cap with a warning), and mergeReviewerLanes is total in turn,
1262
+ // so a bad third-party manifest cannot take the first-party lanes down with it.
1263
+ // `includeInstalled` is what merges project + global overlay caps into the
1264
+ // registry; without it the base is first-party-only and this is a no-op.
1265
+ let mergedLanes = REVIEWER_LANES;
1266
+ try {
1267
+ const registry = capabilityLoader.loadRegistry({ includeInstalled: true, cwd });
1268
+ mergedLanes = mergeReviewerLanes(REVIEWER_LANES, registry);
1269
+ } catch {
1270
+ // A registry load failure must never block first-party review. Degrade to the
1271
+ // static set — identical to pre-fix behavior — rather than crashing review-lane.
1272
+ mergedLanes = REVIEWER_LANES;
1273
+ }
1274
+ const laneBySlug = new Map(mergedLanes.map((l) => [l.slug, l]));
1275
+ const chosen = selected.length ? selected : mergedLanes.map((l) => l.slug);
1181
1276
 
1182
1277
  if (sub === 'sections') {
1183
1278
  const rows = chosen
@@ -1212,30 +1307,45 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1212
1307
  return;
1213
1308
  }
1214
1309
 
1215
- // Effort argv is resolved per lane by the host's own execution policy, exactly as the legs did
1216
- // via `resolve-execution … --pick effort_argv_string`. A lane whose slug is not a known host
1217
- // simply gets none.
1218
- // Resolved through the SAME `resolve-execution` surface the bash legs used
1219
- // (`--host <slug> --pick effort_argv_string`), so the host's negotiated effortSurface still
1220
- // decides whether an argument is emitted and the catalog still owns the syntax (ADR-1239 #2481,
1221
- // ADR-443's escalation ladder). `cmdResolveExecution` writes to stdout and exits, so it cannot
1222
- // be called in-process for a value — this spawns the same bounded query the legs did, once per
1223
- // selected lane. A lane whose slug is not a known host resolves to no effort argument at all.
1310
+ // Effort argv is resolved per lane by the host's own execution policy, through the SAME
1311
+ // `resolve-execution` surface the bash legs used (`--host <slug>`), so the host's negotiated
1312
+ // effortSurface still decides whether an argument is emitted and the catalog still owns the
1313
+ // syntax (ADR-1239 #2481, ADR-443's escalation ladder). `cmdResolveExecution` writes to
1314
+ // stdout and exits, so it cannot be called in-process for a value — this spawns the same
1315
+ // bounded query the legs did, once per selected lane. A lane whose slug is not a known host
1316
+ // resolves to no effort argument at all.
1317
+ //
1318
+ // NOT `--raw` and NOT `--pick` (#2295). `--raw` prints only the resolved EFFORT ('low') with
1319
+ // no host-specific rendering at all. `--pick effort_argv_string` used to be the answer — the
1320
+ // rendered array re-joined into a string ('-c model_reasoning_effort=low') — but the caller
1321
+ // then had to `.split(/\s+/)` that string back apart to get an argv array, and re-splitting a
1322
+ // string the callee just joined is a lossy round trip: any argv element that legitimately
1323
+ // contains a space would come back split into two argv elements, corrupting the very argv it
1324
+ // was rendered to preserve. Reading the UNPICKED object instead gives both `effort_argv` (a
1325
+ // real string array, used verbatim, no re-splitting) and `effort_argv_value` (the bare level,
1326
+ // #2295's `plan.effort`) from the one spawn.
1327
+ const EMPTY_EFFORT = { argv: [], value: null };
1224
1328
  const effortFor = (slug) => {
1225
1329
  try {
1226
1330
  const r = cp.spawnSync(
1227
1331
  process.execPath,
1228
- [__filename, 'query', 'resolve-execution', 'gsd-plan-checker',
1229
- // NOT `--raw`: that prints the resolved EFFORT ('low'), ignoring --pick. The picked
1230
- // field is what carries the host-specific syntax ('--effort low' for claude,
1231
- // '-c model_reasoning_effort=low' for codex), which is the whole point of asking.
1232
- '--host', slug, '--pick', 'effort_argv_string'],
1332
+ [__filename, 'query', 'resolve-execution', 'gsd-plan-checker', '--host', slug],
1233
1333
  { cwd, encoding: 'utf8', timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 },
1234
1334
  );
1235
- if (r.status !== 0) return [];
1236
- const s = String(r.stdout || '').trim();
1237
- return s ? s.split(/\s+/).filter(Boolean) : [];
1238
- } catch { return []; }
1335
+ if (r.status !== 0) return EMPTY_EFFORT;
1336
+ let parsed;
1337
+ try {
1338
+ parsed = JSON.parse(String(r.stdout || ''));
1339
+ } catch { return EMPTY_EFFORT; }
1340
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return EMPTY_EFFORT;
1341
+ const argv = Array.isArray(parsed.effort_argv)
1342
+ ? parsed.effort_argv.filter((a) => typeof a === 'string' && a !== '')
1343
+ : [];
1344
+ const value = typeof parsed.effort_argv_value === 'string' && parsed.effort_argv_value
1345
+ ? parsed.effort_argv_value
1346
+ : null;
1347
+ return { argv, value };
1348
+ } catch { return EMPTY_EFFORT; }
1239
1349
  };
1240
1350
 
1241
1351
  /**
@@ -1268,7 +1378,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1268
1378
  // so losing all of them to one bad manifest is strictly worse. Belt and braces on purpose.
1269
1379
  let r;
1270
1380
  try {
1271
- r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effortFor(slug) });
1381
+ const effort = effortFor(slug);
1382
+ r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effort.argv, effortValue: effort.value });
1272
1383
  } catch (e) {
1273
1384
  return { slug, ok: false, reason: 'malformed_lane', detail: `resolver threw: ${e && e.message ? e.message : String(e)}` };
1274
1385
  }
@@ -1307,13 +1418,40 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1307
1418
  // cannot be interrupted by --test-force-exit and hangs a whole CI chunk to its 10-minute kill.
1308
1419
  const deps = {
1309
1420
  spawn: (binary, argv, opts) => {
1310
- const r = cp.spawnSync(binary, argv, {
1421
+ // #3086: on Windows, reviewer CLIs (gemini, codex, etc.) are installed
1422
+ // as .cmd shims. spawnSync with a bare name + shell:false fails with
1423
+ // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1424
+ // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1425
+ // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1426
+ //
1427
+ // #3275: descriptors declare BARE names, so the gate above never saw an
1428
+ // extension — resolve through the shared PATH+PATHEXT resolver FIRST
1429
+ // (the same one `hasBinary` uses, so probe and spawn can never disagree
1430
+ // about what the lane's binary is). POSIX keeps the bare name: Node's own
1431
+ // PATH search already worked there, and the #3275 acceptance contract
1432
+ // holds macOS/Linux behavior unchanged. A name that resolves to nothing
1433
+ // falls back to the declared name so the ENOENT still surfaces (#3086).
1434
+ // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
1435
+ // private copies here; `projectSpawnInvocation` owns them, so a fix to either
1436
+ // reaches every spawn site instead of only this one.
1437
+ //
1438
+ // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
1439
+ // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
1440
+ // from the same resolver, so probe and spawn must agree on the exact file.
1441
+ const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
1442
+ const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
1443
+ const r = cp.spawnSync(spawnBinary, spawnArgv, {
1311
1444
  input: opts.input,
1312
1445
  encoding: 'utf8',
1313
1446
  timeout: opts.timeoutMs,
1314
1447
  killSignal: 'SIGKILL',
1315
1448
  maxBuffer: 64 * 1024 * 1024,
1316
1449
  shell: false, // argv array only — never a shell string (no interpolation of config values)
1450
+ // #2483: a lane's declared env pairs merged OVER this process's environment, for this
1451
+ // child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
1452
+ // into the orchestrating session or into the next lane.
1453
+ ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
1454
+ ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
1317
1455
  });
1318
1456
  return {
1319
1457
  status: r.status,
@@ -1342,24 +1480,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1342
1480
  // all (a probe that costs a process is a probe you avoid running, which is how the original
1343
1481
  // Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
1344
1482
  // Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
1345
- hasBinary: (name) => {
1346
- if (!name || name.includes('/') || name.includes('\\')) {
1347
- try { return fsx.statSync(name).isFile(); } catch { return false; }
1348
- }
1349
- const exts = process.platform === 'win32'
1350
- ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
1351
- : [''];
1352
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
1353
- for (const ext of exts) {
1354
- const candidate = path.join(dir, name + ext);
1355
- try {
1356
- const st = fsx.statSync(candidate);
1357
- if (st.isFile()) return true;
1358
- } catch { /* next candidate */ }
1359
- }
1360
- }
1361
- return false;
1362
- },
1483
+ //
1484
+ // #3275: the scan lives in `resolveSpawnBinary` now, SHARED with `deps.spawn`
1485
+ // above. Two private copies of "what is this declared binary?" is how the
1486
+ // defect hid: the probe resolved WITH PATHEXT while spawn resolved WITHOUT,
1487
+ // so a lane reported available for a spawn that could never start. One
1488
+ // resolver, both seams — if one changes, the other changes with it.
1489
+ hasBinary: (name) => resolveSpawnBinary(name) !== null,
1363
1490
  configGet,
1364
1491
  homeDir: os.homedir(),
1365
1492
  warn: (m) => process.stderr.write(`${m}\n`),
@@ -1411,12 +1538,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1411
1538
  `model override (it declares no modelConfigKey). The review will use the CLI's own default.\n`,
1412
1539
  );
1413
1540
  }
1541
+ const instanceEffort = effortFor(entry.slug);
1414
1542
  const overridden = resolveLanePlan({
1415
1543
  lane,
1416
1544
  configGet: (k) => (key && k === key ? instanceModel : configGet(k)),
1417
1545
  runDir,
1418
1546
  repoRoot,
1419
- effortArgs: effortFor(entry.slug),
1547
+ effortArgs: instanceEffort.argv,
1548
+ effortValue: instanceEffort.value,
1420
1549
  });
1421
1550
  if (overridden.ok) {
1422
1551
  // Preserve any instance retargeting already applied above.
@@ -1493,7 +1622,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1493
1622
  function routeDispatchShouldFlatten({ args, cwd, raw, error }) {
1494
1623
  // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
1495
1624
  //
1496
- // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
1625
+ // Resolves the current runtime (GSD_RUNTIME > config.runtime > per-install
1626
+ // .gsd-runtime marker > 'claude'),
1497
1627
  // looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and
1498
1628
  // calls shouldFlattenDispatch(dispatch) from host-integration.cjs.
1499
1629
  //
@@ -1547,7 +1677,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1547
1677
  // #853) for exactly this reason — it replaced a `RUNTIME === 'codex'`
1548
1678
  // prose rule.
1549
1679
  //
1550
- // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
1680
+ // Resolves the current runtime (GSD_RUNTIME > config.runtime > per-install
1681
+ // .gsd-runtime marker > 'claude'),
1551
1682
  // reads registry.runtimes[id].runtime.hostIntegration.dispatch.isolation,
1552
1683
  // and validates it against the closed vocabulary.
1553
1684
  //
@@ -1564,12 +1695,101 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1564
1695
  // `orchestrator-worktree`. It requires `--cwd-target` (the GSD-created
1565
1696
  // worktree path) and optionally `--prompt`; without a target there is
1566
1697
  // nothing to bind, so `exec` is null.
1567
- const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1698
+ //
1699
+ // #3045 CORE REDESIGN: this is now the SOLE resolver of "what isolation
1700
+ // applies to this dispatch", and — as an unconditional side effect — it
1701
+ // PERSISTS that resolved decision (mode + harnessFlag + phase/plan
1702
+ // identifiers, written together in one atomic write) to the sentinel the
1703
+ // guard hooks read. Previously the sentinel was written by prose-gated
1704
+ // shell blocks in `executor-isolation-dispatch.md` that a model was told
1705
+ // to "read and run" — a prose-gated writer for a guard against
1706
+ // prose-gated values is the same defect class the guard exists to close.
1707
+ // The workflow MUST call this query to learn ISOLATION at all, so
1708
+ // recording here is structurally unskippable. `--phase`/`--plan` are
1709
+ // optional identifiers threaded through from the caller (workflow shell
1710
+ // variables); `--force-isolation <mode>` lets a caller that has
1711
+ // additional context this resolver cannot see (the #2474 per-plan
1712
+ // submodule intersection, computed in shell in
1713
+ // `per-plan-worktree-gate.md`) override the naturally-resolved mode
1714
+ // while still going through this single write path. Best-effort: a
1715
+ // sentinel write failure here must never fail the wave.
1716
+ const decision = resolveDispatchIsolationDecision({ args, cwd });
1717
+ const runtimeId = decision.runtimeId;
1718
+ let { isolation, exec, harnessFlag } = decision;
1719
+
1720
+ // `--force-isolation <mode>` overrides the naturally-resolved mode with
1721
+ // context this resolver has no way to see on its own (e.g. the #2474
1722
+ // per-plan submodule intersection). Invalid/unrecognized values are
1723
+ // ignored rather than erroring — this is a best-effort recording call,
1724
+ // not a hard usage gate. Forcing to 'none' clears harnessFlag/exec since
1725
+ // neither applies to sequential dispatch.
1726
+ const forceIdx = args.indexOf('--force-isolation');
1727
+ const forcedIsolation = forceIdx !== -1 ? args[forceIdx + 1] : undefined;
1728
+ if (forcedIsolation && DISPATCH_ISOLATION_VOCABULARY.has(forcedIsolation)) {
1729
+ isolation = forcedIsolation;
1730
+ if (isolation === 'none') {
1731
+ harnessFlag = null;
1732
+ exec = null;
1733
+ }
1734
+ }
1735
+
1736
+ const phaseIdx = args.indexOf('--phase');
1737
+ const phaseArg = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
1738
+ ? args[phaseIdx + 1]
1739
+ : null;
1740
+ const planIdx = args.indexOf('--plan');
1741
+ const planArg = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
1742
+ ? args[planIdx + 1]
1743
+ : null;
1744
+
1745
+ // Side-effect write (#3045 CORE REDESIGN) — see the doc comment above.
1746
+ // Never allowed to affect this query's own stdout contract or throw.
1747
+ try {
1748
+ writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase: phaseArg, plan: planArg });
1749
+ } catch {
1750
+ // writeDispatchIsolationSentinel already swallows its own errors into
1751
+ // a { recorded: false } result; this catch is defense in depth only.
1752
+ }
1753
+
1754
+ if (args.indexOf('--json') !== -1) {
1755
+ output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
1756
+ } else {
1757
+ process.stdout.write(isolation);
1758
+ }
1759
+ }
1760
+
1761
+ const DISPATCH_ISOLATION_VOCABULARY = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1762
+
1763
+ /**
1764
+ * Shared, side-effect-free resolution of the negotiated dispatch isolation:
1765
+ * runtime (GSD_RUNTIME > config.runtime > per-install .gsd-runtime marker >
1766
+ * 'claude') → declared
1767
+ * `dispatch.isolation` → harness-flag / orchestrator-exec degrade rules.
1768
+ * Extracted (#2486) so `routeDispatchIsolation` (the #3045 recording
1769
+ * dispatch path) and `routeInspectDispatchIsolation` (the read-only
1770
+ * inspection path) share exactly one negotiation implementation and cannot
1771
+ * drift apart. Resolution only — the caller decides whether the decision is
1772
+ * recorded to the sentinel.
1773
+ */
1774
+ function resolveDispatchIsolationDecision({ args, cwd }) {
1568
1775
  let isolation = 'none';
1569
1776
  let runtimeId = null;
1570
1777
  let exec = null;
1571
1778
  let harnessFlag = null;
1572
1779
  try {
1780
+ // Deliberately `resolveRuntime`, NOT `resolveActiveRuntime`/`loadConfig`:
1781
+ // loadConfig normalizes and rewrites legacy keys back to disk, and this
1782
+ // resolver backs the sentinel-free `inspect-dispatch-isolation` verb,
1783
+ // which must never write. resolveRuntime reads config.json directly.
1784
+ //
1785
+ // KNOWN LIMITATION, tracked separately: resolveRuntime stops at
1786
+ // GSD_RUNTIME > config.runtime > 'claude' and does not consult the
1787
+ // per-install `.gsd-runtime` marker, so on a non-Claude install whose
1788
+ // project config carries no `runtime` key this resolves 'claude'. That is
1789
+ // open-gsd/gsd-core#2395 — a pre-existing defect in the canonical
1790
+ // resolver, not introduced here, and deliberately NOT fixed in this PR
1791
+ // (its blast radius reaches every consumer of that resolver, so it is
1792
+ // being handled on its own).
1573
1793
  const { resolveRuntime } = require('./lib/runtime-slash.cjs');
1574
1794
  runtimeId = resolveRuntime(cwd);
1575
1795
 
@@ -1578,7 +1798,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1578
1798
  ? registry.runtimes[runtimeId]
1579
1799
  : null;
1580
1800
  const declared = runtimeEntry?.runtime?.hostIntegration?.dispatch?.isolation ?? null;
1581
- if (typeof declared === 'string' && VALID_ISOLATION.has(declared)) {
1801
+ if (typeof declared === 'string' && DISPATCH_ISOLATION_VOCABULARY.has(declared)) {
1582
1802
  isolation = declared;
1583
1803
  }
1584
1804
 
@@ -1621,7 +1841,52 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1621
1841
  exec = null;
1622
1842
  harnessFlag = null;
1623
1843
  }
1844
+ return { runtimeId, isolation, exec, harnessFlag };
1845
+ }
1624
1846
 
1847
+ function routeInspectDispatchIsolation({ args, cwd, raw }) {
1848
+ // #2486: sentinel-free sibling of `dispatch-isolation` for INSPECTION
1849
+ // surfaces — /gsd:health's W025 check and /gsd:settings' Worktrees
1850
+ // branching. The dispatch verb above intentionally records its resolved
1851
+ // decision to the isolation sentinel as an unconditional side effect
1852
+ // (#3045 CORE REDESIGN): correct for executor dispatch, where the record
1853
+ // must be structurally unskippable — but wrong for a read-only
1854
+ // diagnostic. A health check that records a phase:null/plan:null
1855
+ // sentinel can hard-block every executor dispatch for the sentinel's
1856
+ // lifetime, across sessions sharing the main checkout. Inspection
1857
+ // surfaces call this verb instead. Two claims, both narrower than
1858
+ // "side-effect-free", and both exactly true (#2486 review, Majors 2 & 4):
1859
+ //
1860
+ // 1. SENTINEL-FREE, not write-free. This route writes nothing itself, and
1861
+ // in particular never writes .gsd/dispatch-isolation-sentinel.json —
1862
+ // the only write that can hard-block a later executor dispatch. It is
1863
+ // NOT an unconditional claim of total filesystem purity: like every
1864
+ // gsd-tools invocation, it runs the shared bootstrap and
1865
+ // active-workstream resolution first. As of #3579's root-cause fix
1866
+ // that bootstrap resolves via the non-mutating peekActiveWorkstream
1867
+ // (never unlinks); an actual stale/invalid pointer is still
1868
+ // self-healed, but only by whichever verb's own getActiveWorkstream
1869
+ // call later consumes it for real — this inspection route makes no
1870
+ // such call, so it is now also side-effect-free on the pointer file.
1871
+ //
1872
+ // 2. SHARED NEGOTIATION, for the arguments this verb accepts. Both verbs
1873
+ // call resolveDispatchIsolationDecision, so the natural resolution
1874
+ // cannot drift. It is NOT a claim of byte-identical output for every
1875
+ // argv: routeDispatchIsolation applies --force-isolation AFTER the
1876
+ // shared helper returns, so the same argv could otherwise yield
1877
+ // 'none' there and the declared capability here. Rather than let a
1878
+ // caller receive a silently different answer, this verb REJECTS the
1879
+ // recording-only knobs outright — they exist to be recorded, and a
1880
+ // read has nothing to record.
1881
+ const RECORDING_ONLY_ARGS = ['--force-isolation', '--phase', '--plan'];
1882
+ const rejected = RECORDING_ONLY_ARGS.filter((flag) => args.indexOf(flag) !== -1);
1883
+ if (rejected.length > 0) {
1884
+ error(
1885
+ `inspect-dispatch-isolation: ${rejected.join(', ')} ${rejected.length === 1 ? 'is a' : 'are'} recording-only argument${rejected.length === 1 ? '' : 's'} and cannot be used on the inspection verb — it resolves the runtime's DECLARED capability and records nothing. Use 'query dispatch-isolation' if you need the override applied and the decision recorded.`,
1886
+ ERROR_REASON.USAGE,
1887
+ );
1888
+ }
1889
+ const { runtimeId, isolation, exec, harnessFlag } = resolveDispatchIsolationDecision({ args, cwd });
1625
1890
  if (args.indexOf('--json') !== -1) {
1626
1891
  output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
1627
1892
  } else {
@@ -1629,6 +1894,110 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1629
1894
  }
1630
1895
  }
1631
1896
 
1897
+ /**
1898
+ * Atomically persist the resolved dispatch-isolation decision to the
1899
+ * run-scoped sentinel both isolation guard hooks read
1900
+ * (hooks/gsd-agent-isolation-guard.js, hooks/gsd-cursor-subagent-start.js;
1901
+ * shared reader hooks/lib/isolation-sentinel.js). Extracted so
1902
+ * `routeDispatchIsolation` (the #3045 CORE REDESIGN primary write path)
1903
+ * and `routeRecordDispatchIsolation` (the explicit verb, kept for the
1904
+ * per-plan degrade call site and back-compat/tests) share exactly one
1905
+ * write implementation. Never throws — returns `{ recorded, path, error? }`.
1906
+ */
1907
+ function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }) {
1908
+ const nodePath = require('path');
1909
+ const nodeFs = require('fs');
1910
+ const sentinelDir = nodePath.join(cwd, '.gsd');
1911
+ const sentinelPath = nodePath.join(sentinelDir, 'dispatch-isolation-sentinel.json');
1912
+ const payload = {
1913
+ isolation,
1914
+ harness_flag: harnessFlag || null,
1915
+ phase: phase || null,
1916
+ plan: plan || null,
1917
+ written_at: Date.now(),
1918
+ };
1919
+ try {
1920
+ nodeFs.mkdirSync(sentinelDir, { recursive: true });
1921
+ // Atomic write: unique temp file + rename, so a concurrent reader (a
1922
+ // guard hook firing mid-write) never observes a partially-written
1923
+ // sentinel. Unique per-process+time so concurrent orchestrator-worktree
1924
+ // invocations sharing the same sentinelDir never collide on the temp name.
1925
+ const tmpPath = `${sentinelPath}.tmp-${process.pid}-${Date.now()}`;
1926
+ nodeFs.writeFileSync(tmpPath, JSON.stringify(payload));
1927
+ nodeFs.renameSync(tmpPath, sentinelPath);
1928
+ return { recorded: true, path: '.gsd/dispatch-isolation-sentinel.json' };
1929
+ } catch (err) {
1930
+ return { recorded: false, path: '.gsd/dispatch-isolation-sentinel.json', error: err && err.message };
1931
+ }
1932
+ }
1933
+
1934
+ function routeRecordDispatchIsolation({ args, cwd, raw, error }) {
1935
+ // #3045: `routeDispatchIsolation` (the `dispatch-isolation` query) is now
1936
+ // the PRIMARY write path for the sentinel (CORE REDESIGN) — it records
1937
+ // as an unconditional side effect of resolving ISOLATION, which the
1938
+ // workflow must call to learn the value at all. This verb remains as an
1939
+ // explicit fallback for callers that resolve isolation through some
1940
+ // other means (or need to force a specific value, e.g. a caller with no
1941
+ // access to `--force-isolation` context) and for direct test coverage of
1942
+ // the write primitive. Both verbs share exactly one write implementation
1943
+ // (`writeDispatchIsolationSentinel`) so there is only one atomic-write
1944
+ // code path to reason about.
1945
+ //
1946
+ // Best-effort: a write failure here must never fail the workflow — the
1947
+ // guard hooks' own sentinel-absent path degrades to a conservative
1948
+ // registry+config check, so a missing sentinel is safe, just less precise.
1949
+ //
1950
+ // Output: { recorded: true|false, path, error? }
1951
+ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1952
+ const isoIdx = args.indexOf('--isolation');
1953
+ const isolation = isoIdx !== -1 ? args[isoIdx + 1] : undefined;
1954
+ if (!isolation || !VALID_ISOLATION.has(isolation)) {
1955
+ error(
1956
+ 'Usage: record-dispatch-isolation --isolation <harness-worktree|orchestrator-worktree|none> ' +
1957
+ '[--harness-flag <flag>|--harness-flag=<flag>] [--phase <n>] [--plan <id>]',
1958
+ ERROR_REASON.USAGE,
1959
+ );
1960
+ return;
1961
+ }
1962
+ // #3045 MAJOR: the space-separated form rejects any value starting with
1963
+ // `--` (to avoid swallowing a missing value followed by another flag),
1964
+ // but that is exactly the shape of Cursor's real `harnessIsolationFlag`
1965
+ // — it declares the bare CLI flag `--worktree`
1966
+ // (gsd-core/bin/lib/capability-registry.cjs), which could therefore
1967
+ // never be persisted. (Windsurf declares NO `harnessIsolationFlag` at
1968
+ // all — its `hostIntegration.dispatch.isolation` is `none`; per
1969
+ // ADR-1239 it "genuinely cannot benefit" from worktree isolation
1970
+ // because it lacks named/concurrent subagent dispatch, so this is not a
1971
+ // gap to close for Windsurf.) The `--harness-flag=<value>` equals form
1972
+ // (mirrors the `--cwd=<path>` convention already used by this
1973
+ // dispatcher's top-level arg parsing above) carries the value
1974
+ // unambiguously and is never subject to that guard — any future runtime
1975
+ // whose registered flag happens to be bare-CLI-shaped benefits the same
1976
+ // way Cursor's does.
1977
+ let harnessFlag = null;
1978
+ const flagEqArg = args.find((a) => a.startsWith('--harness-flag='));
1979
+ if (flagEqArg) {
1980
+ const value = flagEqArg.slice('--harness-flag='.length);
1981
+ harnessFlag = value.length > 0 ? value : null;
1982
+ } else {
1983
+ const flagIdx = args.indexOf('--harness-flag');
1984
+ harnessFlag = flagIdx !== -1 && args[flagIdx + 1] && !args[flagIdx + 1].startsWith('--')
1985
+ ? args[flagIdx + 1]
1986
+ : null;
1987
+ }
1988
+ const phaseIdx = args.indexOf('--phase');
1989
+ const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
1990
+ ? args[phaseIdx + 1]
1991
+ : null;
1992
+ const planIdx = args.indexOf('--plan');
1993
+ const plan = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
1994
+ ? args[planIdx + 1]
1995
+ : null;
1996
+
1997
+ const result = writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase, plan });
1998
+ output(result, raw);
1999
+ }
2000
+
1632
2001
  function routeResolveDispatchType({ args, cwd, raw, error }) {
1633
2002
  // #2508 Phase 4 Option A: resolve a requested GSD subagent name to the
1634
2003
  // type an Agent() call should use on the current runtime. On
@@ -1668,6 +2037,50 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1668
2037
  }
1669
2038
  }
1670
2039
 
2040
+ function routeResolveAgent({ args, cwd, raw, error }) {
2041
+ // #1689: resolve a per-plan agent_hint specialist name to the subagent_type
2042
+ // an Agent() call should use. Returns the name unchanged when a
2043
+ // matching agent file exists in the active runtime's agent dir(s);
2044
+ // 'gsd-executor' when the name is absent, blank, or does not resolve.
2045
+ // Fail-closed is the fallback (gsd-executor) — never echo an
2046
+ // unvalidated name, which would make Agent() error and block the wave.
2047
+ //
2048
+ // Output:
2049
+ // --raw (default) -> prints the resolved type (the hint, or 'gsd-executor')
2050
+ // --json -> prints { runtime, requested, resolved, fallback }
2051
+ const FALLBACK = 'gsd-executor';
2052
+ try {
2053
+ const nameIdx = args.indexOf('--name');
2054
+ const requested = nameIdx !== -1 ? args[nameIdx + 1] : '';
2055
+ const { resolveRuntime } = require('./lib/runtime-slash.cjs');
2056
+ const runtimeId = resolveRuntime(cwd);
2057
+ const { resolveAgentHint } = require('./lib/agent-install-check.cjs');
2058
+ let resolved = FALLBACK;
2059
+ let resolvedOk = false; // true only when resolveAgentHint returned a hit
2060
+ if (requested && !requested.startsWith('-')) {
2061
+ const hit = resolveAgentHint(requested, runtimeId, cwd);
2062
+ if (hit !== null) {
2063
+ resolved = hit;
2064
+ resolvedOk = true;
2065
+ }
2066
+ }
2067
+ // `fallback` = we did NOT honor a resolvable hint (absent/flag-shaped name,
2068
+ // the named agent did not resolve, or resolution errored). Requesting
2069
+ // gsd-executor explicitly and resolving to it is NOT a fallback.
2070
+ const fellBack = !resolvedOk;
2071
+ const jsonIdx = args.indexOf('--json');
2072
+ if (jsonIdx !== -1) {
2073
+ output({ runtime: runtimeId, requested: requested || null, resolved, fallback: fellBack }, raw);
2074
+ } else {
2075
+ process.stdout.write(String(resolved));
2076
+ }
2077
+ } catch {
2078
+ // Fail-closed: degrade to the legacy executor on any error so dispatch
2079
+ // never blocks on resolution.
2080
+ process.stdout.write(FALLBACK);
2081
+ }
2082
+ }
2083
+
1671
2084
  function routeAgentSkills({ args, cwd, raw, error }) {
1672
2085
  // --json emits typed IR { agent_type, block, skills_count } for test assertions
1673
2086
  // (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
@@ -1768,9 +2181,20 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1768
2181
  const force = args.includes('--force');
1769
2182
  // #2118: --dry-run prints a preview plan without mutating.
1770
2183
  const dryRun = args.includes('--dry-run');
1771
- milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun }, raw);
2184
+ // #2142: quick-task archival is opt-in (default OFF) — unlike
2185
+ // --no-archive-phases' inverted shape, absence of this flag means
2186
+ // "do nothing" rather than "skip a default-on behavior".
2187
+ const archiveQuick = args.includes('--archive-quick');
2188
+ milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick }, raw);
2189
+ } else if (subcommand === 'archive-quick') {
2190
+ // #2142 escalation: narrow archival-only entry point (does NOT
2191
+ // touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion
2192
+ // guards) — safe to call against an already-completed milestone,
2193
+ // unlike `milestone complete --archive-quick`.
2194
+ const dryRun = args.includes('--dry-run');
2195
+ milestone.cmdQuickArchive(cwd, args[2], { dryRun }, raw);
1772
2196
  } else {
1773
- error('Unknown milestone subcommand. Available: complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2197
+ error('Unknown milestone subcommand. Available: complete, archive-quick', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1774
2198
  }
1775
2199
  }
1776
2200
 
@@ -2033,6 +2457,86 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2033
2457
  teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
2034
2458
  }
2035
2459
 
2460
+ // #3023 follow-up (adversarial review finding): the shared hook bundle's
2461
+ // directory name is runtime-descriptor-driven (bin/install.js
2462
+ // `hostBehaviors.sharedHooksDirName`; default 'hooks', pi renames it to
2463
+ // 'gsd-hooks'). A hardcoded 'hooks' literal in GSD_PREFIX_MANAGED_DIRS left
2464
+ // this scan blind to a renamed bundle: `fs.existsSync(configDir/hooks)` is
2465
+ // false for a pi install, so the ENTIRE gsd-hooks/ tree — including any
2466
+ // user-added file inside it — was invisible to detect-custom-files and
2467
+ // therefore never backed up before the next clean-install wipe (silent
2468
+ // data loss).
2469
+ //
2470
+ // Resolution order, mirroring bin/install.js's own resolveSharedHooksDirName:
2471
+ // 1. Read the per-install runtime marker written by the installer at
2472
+ // <configDir>/gsd-core/.gsd-runtime (#2297).
2473
+ // 2. Look up that runtime's `hostBehaviors.sharedHooksDirName` in the
2474
+ // SHIPPED capability registry (./lib/capability-registry.cjs — a data
2475
+ // module in the same installed tree as this file). Deliberately NOT
2476
+ // `require('bin/install.js')`: that file is never shipped into an
2477
+ // installed tree (the #3024/#2071 bug class), so only the shipped data
2478
+ // module is read here.
2479
+ //
2480
+ // Asymmetric fallback: when the runtime or its descriptor cannot be
2481
+ // determined (an install predating the marker, an unreadable/corrupt
2482
+ // registry, or an unrecognized runtime id) this does NOT guess a single
2483
+ // name — it returns every known candidate name instead. Over-scanning is
2484
+ // safe here: a candidate directory that does not exist is silently skipped
2485
+ // by the caller's `fs.existsSync` guard, and a file already tracked in the
2486
+ // manifest is never reported as custom. Under-scanning is the actual bug
2487
+ // being fixed: it would make a user's file vanish on the next wipe without
2488
+ // ever being backed up.
2489
+ function resolveSharedHooksDirCandidates(configDir) {
2490
+ const DEFAULT_NAME = 'hooks';
2491
+ // A resolved name is joined onto configDir and read back — reject
2492
+ // anything that isn't a plain, separator-free segment so a corrupt
2493
+ // registry value can never walk the scan outside the config root.
2494
+ const isSafeSegment = (name) =>
2495
+ typeof name === 'string' &&
2496
+ name.trim() !== '' &&
2497
+ name.trim() === name &&
2498
+ name !== '.' &&
2499
+ name !== '..' &&
2500
+ !name.includes('/') &&
2501
+ !name.includes('\\');
2502
+
2503
+ let registry = null;
2504
+ try {
2505
+ registry = require('./lib/capability-registry.cjs');
2506
+ } catch {
2507
+ registry = null;
2508
+ }
2509
+
2510
+ const knownNames = new Set([DEFAULT_NAME]);
2511
+ if (registry && registry.runtimes && typeof registry.runtimes === 'object') {
2512
+ for (const desc of Object.values(registry.runtimes)) {
2513
+ const name = desc && desc.runtime && desc.runtime.hostBehaviors &&
2514
+ desc.runtime.hostBehaviors.sharedHooksDirName;
2515
+ if (isSafeSegment(name)) knownNames.add(name);
2516
+ }
2517
+ }
2518
+
2519
+ let runtimeId = null;
2520
+ try {
2521
+ const markerPath = path.join(configDir, 'gsd-core', '.gsd-runtime');
2522
+ const raw = fs.readFileSync(markerPath, 'utf8').trim();
2523
+ runtimeId = raw || null;
2524
+ } catch {
2525
+ runtimeId = null;
2526
+ }
2527
+
2528
+ if (runtimeId && registry && registry.runtimes && registry.runtimes[runtimeId]) {
2529
+ const desc = registry.runtimes[runtimeId];
2530
+ const name = desc && desc.runtime && desc.runtime.hostBehaviors &&
2531
+ desc.runtime.hostBehaviors.sharedHooksDirName;
2532
+ return [isSafeSegment(name) ? name : DEFAULT_NAME];
2533
+ }
2534
+
2535
+ // Runtime undeterminable: scan every known candidate (see asymmetric
2536
+ // fallback comment above).
2537
+ return Array.from(knownNames);
2538
+ }
2539
+
2036
2540
  async function routeDetectCustomFiles({ args, cwd, raw, error }) {
2037
2541
  const configDirIdx = args.indexOf('--config-dir');
2038
2542
  const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
@@ -2073,7 +2577,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2073
2577
  ];
2074
2578
  const GSD_PREFIX_MANAGED_DIRS = [
2075
2579
  'agents',
2076
- 'hooks',
2580
+ ...resolveSharedHooksDirCandidates(resolvedConfigDir),
2077
2581
  'skills',
2078
2582
  ];
2079
2583
 
@@ -2605,6 +3109,133 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2605
3109
  }
2606
3110
  }
2607
3111
 
3112
+ // `gsd_run query context-predicates` — selector surface for the CONTEXT.md
3113
+ // predicate fact-store (ADR-1671, #2928 Phase 1 row S9). Parses the
3114
+ // repo-root CONTEXT.md LIVE via the compiled context-predicates.cjs on
3115
+ // every call — it never reads the committed docs/CONTEXT-INDEX.json (that
3116
+ // artifact is a CI drift-guard byproduct, not a query source, so it can
3117
+ // never go stale relative to the live predicates it answers about).
3118
+ //
3119
+ // Selectors: --class <CLASS>, --prefix <dotted.prefix>, --contains <text>.
3120
+ // At least one is required. When more than one is given they are ANDed
3121
+ // together — the same documented precedence selectPredicates() itself
3122
+ // implements (see context-predicates.cjs doc comment: "Select predicates
3123
+ // by one or more optional criteria (ANDed together)"); no selector is
3124
+ // silently dropped or overridden by another.
3125
+ //
3126
+ // Flag parsing mirrors routePromptBudget's Map-based flagMap: the three
3127
+ // known flags are recognized in both the space-separated `--flag value`
3128
+ // form and the inline-assignment `--flag=value` form (the latter is the
3129
+ // escape hatch for a flag-shaped selector value, e.g. `--contains=--dry-run`
3130
+ // — #2928 review finding C; the space-separated form has no such escape by
3131
+ // design, since a following `--...` token always reads as a missing value).
3132
+ // `--class=` (empty value) and `--class==A` (double-equals typo shape)
3133
+ // are rejected the same way under either form. On a duplicate flag the
3134
+ // FIRST occurrence wins (`Map.set` only fires when the key is absent),
3135
+ // which is deterministic across repeated invocations with identical argv.
3136
+ //
3137
+ // Prototype-pollution safety: selector values are only ever compared via
3138
+ // `===`/`.startsWith()`/`.includes()` against ordinary string fields — this
3139
+ // route never uses a user-supplied string as an object property key
3140
+ // (`obj[userValue] = ...`), so `--class __proto__` / `constructor` /
3141
+ // `prototype` are just non-matching ordinary strings, not property-access
3142
+ // vectors. `flagMap` itself is a `Map`, immune to prototype pollution by
3143
+ // construction.
3144
+ function routeContextPredicates({ args, cwd, raw, error }) {
3145
+ const { parsePredicates, selectPredicates } = require('./lib/context-predicates.cjs');
3146
+
3147
+ const KNOWN_FLAGS = new Set(['--class', '--prefix', '--contains']);
3148
+ const flagMap = new Map();
3149
+ for (let i = 1; i < args.length; i++) {
3150
+ const current = args[i];
3151
+ if (typeof current !== 'string' || !current.startsWith('--')) continue;
3152
+
3153
+ // Inline-assignment escape hatch (`--flag=value`, mirrors the `--config-dir=`/
3154
+ // `--runtime=` convention in routeUpdateContext elsewhere in this file). This is
3155
+ // the ONLY way to pass a flag-shaped selector value (e.g. searching CONTEXT.md
3156
+ // for the literal substring "--dry-run"): the space-separated form below always
3157
+ // treats a following `--...` token as a missing value, by design, so it has no
3158
+ // escape hatch on its own (#2928 review finding C).
3159
+ const eqFlag = [...KNOWN_FLAGS].find((f) => current.startsWith(`${f}=`));
3160
+ if (eqFlag) {
3161
+ const value = current.slice(eqFlag.length + 1);
3162
+ // Reject an empty value (`--class=`) and the `--class==A` double-equals typo
3163
+ // shape (a value starting with `=`) the same way the pre-existing malformed-
3164
+ // assignment behavior did — never silently accept "=A" as a literal value.
3165
+ if (value === '' || value.startsWith('=')) {
3166
+ error(`context-predicates: ${eqFlag} requires a non-empty value`, ERROR_REASON.USAGE);
3167
+ return;
3168
+ }
3169
+ if (!flagMap.has(eqFlag)) flagMap.set(eqFlag, value);
3170
+ continue;
3171
+ }
3172
+
3173
+ if (!KNOWN_FLAGS.has(current)) {
3174
+ error(`Unknown flag for context-predicates: ${current}`, ERROR_REASON.USAGE);
3175
+ return;
3176
+ }
3177
+ const next = args[i + 1];
3178
+ if (next === undefined || next.startsWith('--')) {
3179
+ if (!flagMap.has(current)) flagMap.set(current, null);
3180
+ continue;
3181
+ }
3182
+ if (!flagMap.has(current)) flagMap.set(current, next);
3183
+ i++;
3184
+ }
3185
+
3186
+ const hasClass = flagMap.has('--class');
3187
+ const hasPrefix = flagMap.has('--prefix');
3188
+ const hasContains = flagMap.has('--contains');
3189
+
3190
+ if (!hasClass && !hasPrefix && !hasContains) {
3191
+ error(
3192
+ 'Usage: gsd-tools query context-predicates --class <CLASS> | --prefix <dotted.prefix> | --contains <text> ' +
3193
+ '(selectors are ANDed when combined)',
3194
+ ERROR_REASON.USAGE,
3195
+ );
3196
+ return;
3197
+ }
3198
+
3199
+ const requireNonEmpty = (flagName, rawValue) => {
3200
+ if (rawValue === null || rawValue === undefined || rawValue.trim() === '') {
3201
+ error(`context-predicates: ${flagName} requires a non-empty value`, ERROR_REASON.USAGE);
3202
+ return null;
3203
+ }
3204
+ return rawValue;
3205
+ };
3206
+
3207
+ const opts = {};
3208
+ if (hasClass) {
3209
+ const v = requireNonEmpty('--class', flagMap.get('--class'));
3210
+ if (v === null) return;
3211
+ opts.klass = v;
3212
+ }
3213
+ if (hasPrefix) {
3214
+ const v = requireNonEmpty('--prefix', flagMap.get('--prefix'));
3215
+ if (v === null) return;
3216
+ opts.prefix = v;
3217
+ }
3218
+ if (hasContains) {
3219
+ const v = requireNonEmpty('--contains', flagMap.get('--contains'));
3220
+ if (v === null) return;
3221
+ opts.contains = v;
3222
+ }
3223
+
3224
+ const contextMdPath = path.join(__dirname, '..', '..', 'CONTEXT.md');
3225
+ let markdown;
3226
+ try {
3227
+ markdown = fs.readFileSync(contextMdPath, 'utf8');
3228
+ } catch (err) {
3229
+ error(`context-predicates: cannot read ${contextMdPath}: ${err && err.message}`, ERROR_REASON.USAGE);
3230
+ return;
3231
+ }
3232
+
3233
+ const { predicates } = parsePredicates(markdown);
3234
+ const matches = selectPredicates(predicates, opts);
3235
+
3236
+ output({ matched: matches.length, predicates: matches }, raw);
3237
+ }
3238
+
2608
3239
  function routeUpdateContext({ args, cwd, raw, error }) {
2609
3240
  // #498: resolve the installed GSD version, scope, runtime, and config dir
2610
3241
  // for /gsd:update. Replaces ~280 lines of inline bash in update.md with a
@@ -2838,13 +3469,189 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2838
3469
  return;
2839
3470
  }
2840
3471
 
3472
+ if (subcommand === 'phase-status') {
3473
+ // #1956: deterministic STATE.md-vs-ROADMAP.md phase-status drift.
3474
+ const { planningDir } = require('./lib/planning-workspace.cjs');
3475
+ const { stateExtractField, stateCurrentPositionSlice } = require('./lib/state-document.cjs');
3476
+ const { findRoadmapProgressTable } = require('./lib/roadmap-parser.cjs');
3477
+ const { phaseKeyFromProse } = require('./lib/phase-id.cjs');
3478
+ // STATE.md's YAML frontmatter carries its own lowercase `status:`
3479
+ // scalar ahead of the body's `## Current Position` prose "Status:"
3480
+ // line; stateExtractField's non-scoped regex would otherwise match
3481
+ // that frontmatter line first (it comes first in the file) and
3482
+ // silently report the wrong value. Strip frontmatter so extraction
3483
+ // is scoped to the body.
3484
+ const { stripFrontmatter } = require('./lib/frontmatter.cjs');
3485
+
3486
+ const phaseIdx = args.indexOf('--phase');
3487
+ const phaseArg = (phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--'))
3488
+ ? args[phaseIdx + 1]
3489
+ : undefined;
3490
+
3491
+ const dir = planningDir(cwd);
3492
+ const statePath = path.join(dir, 'STATE.md');
3493
+ const roadmapPath = path.join(dir, 'ROADMAP.md');
3494
+
3495
+ let stateContent = null;
3496
+ try {
3497
+ stateContent = fs.readFileSync(statePath, 'utf-8');
3498
+ } catch {
3499
+ // missing_state below
3500
+ }
3501
+ if (stateContent === null) {
3502
+ const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
3503
+ output({
3504
+ verdict: 'uncheckable',
3505
+ reason: 'missing_state',
3506
+ phase,
3507
+ stateStatus: null,
3508
+ roadmapStatus: null,
3509
+ authority: 'STATE.md',
3510
+ }, raw);
3511
+ return;
3512
+ }
3513
+
3514
+ let roadmapContent = null;
3515
+ try {
3516
+ roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
3517
+ } catch {
3518
+ // missing_roadmap below
3519
+ }
3520
+
3521
+ const stateBody = stripFrontmatter(stateContent);
3522
+ // #1956 fix: scope extraction to `## Current Position` (or `###`
3523
+ // in the bootstrap template) so a historical `Phase:` / `Status:`
3524
+ // line in an archive section (e.g. `## Session Continuity
3525
+ // Archive`) can't shadow the real one — same #2956 scope state.cts
3526
+ // uses for current_phase, via the shared owner in
3527
+ // state-document.cjs.
3528
+ //
3529
+ // Deliberately NO whole-body fallback here. `state.cts`'s WRITE
3530
+ // path falls back to the whole body when no Current Position
3531
+ // heading is found (legacy behavior it must preserve for
3532
+ // backward-compatible writes) — but that fallback is wrong for a
3533
+ // READ that feeds a drift finding: a STATE.md with no Current
3534
+ // Position heading is exactly the shape that let a stray historical
3535
+ // `Status:` line elsewhere in the body shadow the real value and
3536
+ // fabricate a 'drifted' verdict. A guess is worse than an
3537
+ // abstention for a drift detector, so an absent Current Position
3538
+ // section reports 'uncheckable' instead of guessing from the whole
3539
+ // document.
3540
+ const currentPositionBody = stateCurrentPositionSlice(stateBody);
3541
+ if (currentPositionBody === null) {
3542
+ const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
3543
+ output({
3544
+ verdict: 'uncheckable',
3545
+ reason: 'no_current_position',
3546
+ phase,
3547
+ stateStatus: null,
3548
+ roadmapStatus: null,
3549
+ authority: 'STATE.md',
3550
+ }, raw);
3551
+ return;
3552
+ }
3553
+
3554
+ // Resolve the target phase: --phase if given, else whatever
3555
+ // STATE.md's Current Position reports as current.
3556
+ const phase = phaseArg !== undefined
3557
+ ? phaseKeyFromProse(phaseArg)
3558
+ : phaseKeyFromProse(stateExtractField(currentPositionBody, 'Phase'));
3559
+
3560
+ if (roadmapContent === null) {
3561
+ output({
3562
+ verdict: 'uncheckable',
3563
+ reason: 'missing_roadmap',
3564
+ phase,
3565
+ stateStatus: null,
3566
+ roadmapStatus: null,
3567
+ authority: 'STATE.md',
3568
+ }, raw);
3569
+ return;
3570
+ }
3571
+
3572
+ const stateStatus = stateExtractField(currentPositionBody, 'Status');
3573
+
3574
+ // #1956/#2012: scoped to `## Progress` first (decoy-avoidance) —
3575
+ // see findRoadmapProgressTable's doc comment (roadmap-parser.cts).
3576
+ const table = findRoadmapProgressTable(roadmapContent);
3577
+ const matchedRow = table
3578
+ ? table.rows.find((row) => phaseKeyFromProse(row.Phase) === phase && phase !== null)
3579
+ : undefined;
3580
+
3581
+ if (!matchedRow) {
3582
+ const result = comparePhaseStatus({ stateStatus, roadmapStatus: null });
3583
+ output({
3584
+ verdict: 'uncheckable',
3585
+ reason: 'phase_not_in_roadmap',
3586
+ phase,
3587
+ stateStatus,
3588
+ roadmapStatus: null,
3589
+ stateRank: result.stateRank,
3590
+ roadmapRank: result.roadmapRank,
3591
+ authority: 'STATE.md',
3592
+ }, raw);
3593
+ return;
3594
+ }
3595
+
3596
+ const roadmapStatus = matchedRow.Status;
3597
+ const result = comparePhaseStatus({ stateStatus, roadmapStatus });
3598
+ output({
3599
+ verdict: result.verdict,
3600
+ phase,
3601
+ stateStatus,
3602
+ roadmapStatus,
3603
+ stateRank: result.stateRank,
3604
+ roadmapRank: result.roadmapRank,
3605
+ authority: 'STATE.md',
3606
+ }, raw);
3607
+ return;
3608
+ }
3609
+
2841
3610
  error(
2842
- `Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity`,
3611
+ `Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity, phase-status`,
2843
3612
  ERROR_REASON.SDK_UNKNOWN_COMMAND,
2844
3613
  );
2845
3614
  }
2846
3615
 
2847
3616
 
3617
+ /**
3618
+ * #3275: resolve a DECLARED command name to the file a spawn can actually start.
3619
+ *
3620
+ * Lane descriptors (src/review-lane-descriptor.cts) declare BARE, platform-unaware
3621
+ * binary names ('codex', 'gemini', 'kimi', 'agy'), and `review-lane invoke`'s
3622
+ * `deps.spawn` + `deps.hasBinary` both need the on-disk form of that name. Before
3623
+ * this helper existed they disagreed: `hasBinary` scanned PATH WITH PATHEXT (so
3624
+ * probes reported lanes AVAILABLE on Windows) while `spawn` received the bare name
3625
+ * — `CreateProcess` performs no PATHEXT resolution, so every spawn-transport lane
3626
+ * ENOENT'd there, and the #3086 `.cmd`/`.bat` cmd.exe mediation gate never fired
3627
+ * because the declared name never carried an extension. One shared resolver is the
3628
+ * only shape that cannot drift back apart.
3629
+ *
3630
+ * win32: tries PATHEXT entries ONLY — never the bare name. npm global installs
3631
+ * drop an EXTENSIONLESS POSIX sh shim (`...\npm\codex`) next to `codex.CMD`; a
3632
+ * bare-name-first scan resolves to it, the mediation gate sees no `.cmd`, and the
3633
+ * ENOENT returns unchanged (field-reported on Windows 11 — see the #3275 issue
3634
+ * comment pinning exactly this pitfall).
3635
+ *
3636
+ * POSIX: answers EXISTENCE only (the old `hasBinary` contract, preserved
3637
+ * byte-for-byte) by scanning PATH for the bare name. `deps.spawn` does NOT consult
3638
+ * this on POSIX — the bare name goes to spawnSync unchanged and Node's own PATH
3639
+ * search does the work, so macOS/Linux behavior is untouched (#3275 acceptance).
3640
+ *
3641
+ * Path-like names (any '/' or '\') bypass the PATH scan: the name is already an
3642
+ * address, so it passes through when the file exists and is a file.
3643
+ *
3644
+ * #3411: the scan itself now lives in the declared platform seam
3645
+ * (`src/shell-command-projection.cts` → `resolveExecutableBinary`). This function is
3646
+ * the `bin/` entry point onto it and holds no copy of the logic — `CONTEXT.md`
3647
+ * declares that file "All OS-facing I/O; single platform seam", and a private
3648
+ * duplicate here is what made it untrue.
3649
+ */
3650
+ function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
3651
+ const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
3652
+ return resolveExecutableBinary(name, { platform, env });
3653
+ }
3654
+
2848
3655
  const HOST_COMMAND_ROUTERS = {
2849
3656
  // Each entry wraps its `route*Command` router so it receives the module-scope
2850
3657
  // lib the old `case` arm passed, plus the per-dispatch context
@@ -2894,6 +3701,7 @@ const HOST_COMMAND_ROUTERS = {
2894
3701
  'find-phase': routeFindPhase,
2895
3702
  'commit': routeCommit,
2896
3703
  'check-commit': routeCheckCommit,
3704
+ 'commit-docs-guard': routeCommitDocsGuard,
2897
3705
  'commit-to-subrepo': routeCommitToSubrepo,
2898
3706
  'pr-subrepo': routePrSubrepo,
2899
3707
  'verify-summary': routeVerifySummary,
@@ -2912,7 +3720,10 @@ const HOST_COMMAND_ROUTERS = {
2912
3720
  'normalize-test-command': routeNormalizeTestCommand,
2913
3721
  'dispatch-should-flatten': routeDispatchShouldFlatten,
2914
3722
  'dispatch-isolation': routeDispatchIsolation,
3723
+ 'inspect-dispatch-isolation': routeInspectDispatchIsolation,
3724
+ 'record-dispatch-isolation': routeRecordDispatchIsolation,
2915
3725
  'resolve-dispatch-type': routeResolveDispatchType,
3726
+ 'resolve-agent': routeResolveAgent,
2916
3727
  'agent-skills': routeAgentSkills,
2917
3728
  'skill-manifest': routeSkillManifest,
2918
3729
  'history-digest': routeHistoryDigest,
@@ -2940,6 +3751,7 @@ const HOST_COMMAND_ROUTERS = {
2940
3751
  'restore-custom-files': routeRestoreCustomFiles,
2941
3752
  'from-gsd2': routeFromGsd2,
2942
3753
  'prompt-budget': routePromptBudget,
3754
+ 'context-predicates': routeContextPredicates,
2943
3755
  'review-lane': routeReviewLane,
2944
3756
  'update-context': routeUpdateContext,
2945
3757
  'classify-confidence': routeClassifyConfidence,
@@ -2948,6 +3760,7 @@ const HOST_COMMAND_ROUTERS = {
2948
3760
  'user-story': routeUserStory,
2949
3761
  'drift-guard': routeDriftGuard,
2950
3762
  'windows': routeWindows,
3763
+ 'skills-root': routeSkillsRoot,
2951
3764
  };
2952
3765
 
2953
3766
  // Returns true when consumed (suppress "Unknown command"), false to fall
@@ -3143,6 +3956,121 @@ function runWithTimeout(argv) {
3143
3956
 
3144
3957
  // ─── CLI Router ───────────────────────────────────────────────────────────────
3145
3958
 
3959
+ // Top-level usage string — emitted by `gsd-tools` (no args) and by
3960
+ // `gsd-tools --help` / any `--help` request below.
3961
+ // CR feedback: the command list must enumerate every top-level command
3962
+ // supported by the dispatcher so `--help` is actually useful for
3963
+ // discovery; previously it was a partial subset that didn't include
3964
+ // phase / roadmap / milestone / progress / etc.
3965
+ //
3966
+ // Module-scoped (not function-local) so it can be exported and compared
3967
+ // against HOST_COMMAND_ROUTERS in a parity test (DEFECT.GENERATIVE-FIX) —
3968
+ // this string and HOST_COMMAND_ROUTERS/SKIP_ROOT_RESOLUTION are three
3969
+ // independently hand-maintained sites and nothing previously caught them
3970
+ // drifting apart when a query command was added to only one or two.
3971
+ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
3972
+ 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-docs-guard, commit-to-subrepo, pr-subrepo, ' +
3973
+ 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
3974
+ 'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
3975
+ 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
3976
+ 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
3977
+ 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
3978
+ 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
3979
+ 'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, inspect-dispatch-isolation, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-agent, resolve-dispatch-type, ' +
3980
+ 'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
3981
+ 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
3982
+ 'Global flags:\n' +
3983
+ ' --raw Emit raw output without post-processing\n' +
3984
+ ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
3985
+ ' --cwd <path> Override working directory for project-root resolution\n' +
3986
+ ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
3987
+ ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
3988
+ 'For command-specific argument requirements, invoke the command without args ' +
3989
+ '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
3990
+
3991
+ // Multi-repo guard: resolve project root for commands that read/write .planning/.
3992
+ // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
3993
+ // filesystem traversal on every invocation.
3994
+ // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
3995
+ // Both are registry/config queries that resolve activation via
3996
+ // .planning/config.json; they need the project root (cwd) for correct
3997
+ // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
3998
+ // move the other at the same time (keep them consistent).
3999
+ //
4000
+ // Module-scoped for the same reason as TOP_LEVEL_USAGE above — kept
4001
+ // module-private and exposed to the dispatch-table/help-string/skip-list
4002
+ // parity test only through the read-only skipsRootResolution() predicate
4003
+ // below (never as the live Set itself; see that function's doc comment).
4004
+ const SKIP_ROOT_RESOLUTION = new Set([
4005
+ 'generate-slug', 'current-timestamp', 'verify-path-exists',
4006
+ // #2844: verify-summary was previously skipped, leaving relative file-claim
4007
+ // paths resolved against the raw process.cwd() — invoking from a subdirectory
4008
+ // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes
4009
+ // through findProjectRoot so claims resolve against the project root.
4010
+ 'template', 'frontmatter', 'detect-custom-files',
4011
+ // #1854: restore-custom-files operates on a runtime config dir passed
4012
+ // explicitly via --config-dir; it never reads .planning/.
4013
+ 'restore-custom-files',
4014
+ 'worktree', 'prompt-budget',
4015
+ // context-predicates is a pure repo-root CONTEXT.md read (like
4016
+ // prompt-budget); it never touches .planning/, so it needs no project
4017
+ // root resolution and must work from any cwd (including one with no
4018
+ // .planning/ directory at all).
4019
+ 'context-predicates',
4020
+ 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
4021
+ 'user-story', // pure string validation — no .planning/ access needed
4022
+ // #1529: pure runtime→filename projection via getProjectInstructionFile; no
4023
+ // .planning/ access needed, and resolving project root would break workflow
4024
+ // invocations that run before .planning/ exists (new-project Step 1).
4025
+ 'project-instruction-file',
4026
+ // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
4027
+ // needs no .planning/ access, so skip the findProjectRoot traversal.
4028
+ 'eval',
4029
+ ]);
4030
+
4031
+ // Read-only accessor for SKIP_ROOT_RESOLUTION (DEFECT.MUTABLE-EXPORTED-SET,
4032
+ // #2928 review). The Set above stays module-private and mutable internally
4033
+ // (main() only ever calls .has() on it), but exporting the live Set directly
4034
+ // would let any importer call .add()/.delete() on it — Object.freeze() does
4035
+ // not lock Set.prototype.add/delete, so freezing the instance would not have
4036
+ // closed this — and silently change dispatch behavior for every caller in the
4037
+ // process. Export this predicate instead; it exposes membership without
4038
+ // exposing a mutation surface.
4039
+ function skipsRootResolution(command) {
4040
+ return SKIP_ROOT_RESOLUTION.has(command);
4041
+ }
4042
+
4043
+ /**
4044
+ * Resolve the worktree root for a given cwd, warning to stderr when git
4045
+ * could not determine it (reason 'git_timed_out') rather than silently
4046
+ * trusting a best-effort fallback (#3050). Extracted from main() so it can
4047
+ * be driven directly in tests via injected deps.
4048
+ *
4049
+ * @param {string} cwd
4050
+ * @param {{ existsSync?: (p: string) => boolean, resolveWorktreeRoot?: (cwd: string) => { root: string, reason: string }, writeWarning?: (msg: string) => void }} [deps]
4051
+ * @returns {string} resolved cwd
4052
+ */
4053
+ function resolveMainWorktreeCwd(cwd, deps = {}) {
4054
+ const existsSync = deps.existsSync || fs.existsSync;
4055
+ const resolveWorktreeRoot = deps.resolveWorktreeRoot || require('./lib/worktree-safety.cjs').resolveWorktreeRoot;
4056
+ const writeWarning = deps.writeWarning || ((msg) => process.stderr.write(msg));
4057
+
4058
+ if (existsSync(path.join(cwd, '.planning'))) {
4059
+ return cwd;
4060
+ }
4061
+ const { root: worktreeRoot, reason: worktreeRootReason } = resolveWorktreeRoot(cwd);
4062
+ if (worktreeRootReason === 'git_timed_out') {
4063
+ writeWarning(
4064
+ 'WARNING: could not determine the git worktree root (git timed out). ' +
4065
+ 'Planning artifacts (STATE.md, ROADMAP.md, etc.) may be written to the ' +
4066
+ `wrong tree — proceeding with "${worktreeRoot}" as a best-effort fallback. ` +
4067
+ 'Retry the command; if this persists, check for a stalled filesystem mount ' +
4068
+ 'or a stale git index lock (.git/index.lock) in this worktree.\n'
4069
+ );
4070
+ }
4071
+ return worktreeRoot;
4072
+ }
4073
+
3146
4074
  async function main() {
3147
4075
  let args = process.argv.slice(2);
3148
4076
 
@@ -3200,20 +4128,27 @@ async function main() {
3200
4128
  // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
3201
4129
  // However, in monorepo worktrees where the subdirectory itself owns .planning/,
3202
4130
  // skip worktree resolution — the CWD is already the correct project root.
3203
- const { resolveWorktreeRoot } = require('./lib/worktree-safety.cjs');
3204
- if (!fs.existsSync(path.join(cwd, '.planning'))) {
3205
- const worktreeRoot = resolveWorktreeRoot(cwd);
3206
- if (worktreeRoot !== cwd) {
3207
- cwd = worktreeRoot;
3208
- }
3209
- }
4131
+ cwd = resolveMainWorktreeCwd(cwd);
3210
4132
 
3211
4133
  // Optional workstream override for parallel milestone work.
3212
4134
  // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
3213
4135
  let workstreamContext = null;
3214
4136
  try {
4137
+ // #3579 root-cause fix: this bootstrap resolution only decides whether to
4138
+ // populate GSD_WORKSTREAM env for downstream routing — it is a check, not
4139
+ // the consuming read. Using the mutating getActiveWorkstream here
4140
+ // self-healed (cleared) a present-but-unresolvable pointer BEFORE the
4141
+ // dispatched command's own resolution/diagnostic ran, so a second read in
4142
+ // the same process (e.g. a subcommand's own getActiveWorkstream call, or
4143
+ // a fail-safe guard's diagnoseUnresolvedActiveWorkstream) observed
4144
+ // already-cleared state — silently falling through to a fallback marker
4145
+ // it should never have inherited (isolation violation), or losing the
4146
+ // evidence a diagnostic needed to explain why nothing resolved. peek
4147
+ // shares the identical resolution logic and only differs by never
4148
+ // calling adapter.clear(); self-heal still happens, exactly once, at
4149
+ // whichever call site actually consumes the workstream for real.
3215
4150
  workstreamContext = resolveActiveWorkstream(cwd, args, process.env, {
3216
- getStored: getActiveWorkstream,
4151
+ getStored: peekActiveWorkstream,
3217
4152
  });
3218
4153
  args = workstreamContext.args;
3219
4154
  // Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths.
@@ -3276,30 +4211,6 @@ async function main() {
3276
4211
  }
3277
4212
  }
3278
4213
 
3279
- // Top-level usage string — emitted by `gsd-tools` (no args) and by
3280
- // `gsd-tools --help` / any `--help` request below.
3281
- // CR feedback: the command list must enumerate every top-level command
3282
- // supported by the dispatcher so `--help` is actually useful for
3283
- // discovery; previously it was a partial subset that didn't include
3284
- // phase / roadmap / milestone / progress / etc.
3285
- const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
3286
- 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
3287
- 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
3288
- 'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
3289
- 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
3290
- 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
3291
- 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
3292
- 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
3293
- 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
3294
- 'Global flags:\n' +
3295
- ' --raw Emit raw output without post-processing\n' +
3296
- ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
3297
- ' --cwd <path> Override working directory for project-root resolution\n' +
3298
- ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
3299
- ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
3300
- 'For command-specific argument requirements, invoke the command without args ' +
3301
- '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
3302
-
3303
4214
  if (!command) {
3304
4215
  error(TOP_LEVEL_USAGE);
3305
4216
  }
@@ -3326,35 +4237,6 @@ async function main() {
3326
4237
  }
3327
4238
  }
3328
4239
 
3329
- // Multi-repo guard: resolve project root for commands that read/write .planning/.
3330
- // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
3331
- // filesystem traversal on every invocation.
3332
- // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
3333
- // Both are registry/config queries that resolve activation via
3334
- // .planning/config.json; they need the project root (cwd) for correct
3335
- // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
3336
- // move the other at the same time (keep them consistent).
3337
- const SKIP_ROOT_RESOLUTION = new Set([
3338
- 'generate-slug', 'current-timestamp', 'verify-path-exists',
3339
- // #2844: verify-summary was previously skipped, leaving relative file-claim
3340
- // paths resolved against the raw process.cwd() — invoking from a subdirectory
3341
- // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes
3342
- // through findProjectRoot so claims resolve against the project root.
3343
- 'template', 'frontmatter', 'detect-custom-files',
3344
- // #1854: restore-custom-files operates on a runtime config dir passed
3345
- // explicitly via --config-dir; it never reads .planning/.
3346
- 'restore-custom-files',
3347
- 'worktree', 'prompt-budget',
3348
- 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
3349
- 'user-story', // pure string validation — no .planning/ access needed
3350
- // #1529: pure runtime→filename projection via getProjectInstructionFile; no
3351
- // .planning/ access needed, and resolving project root would break workflow
3352
- // invocations that run before .planning/ exists (new-project Step 1).
3353
- 'project-instruction-file',
3354
- // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
3355
- // needs no .planning/ access, so skip the findProjectRoot traversal.
3356
- 'eval',
3357
- ]);
3358
4240
  if (!SKIP_ROOT_RESOLUTION.has(command)) {
3359
4241
  cwd = findProjectRoot(cwd);
3360
4242
  }
@@ -3511,5 +4393,17 @@ if (require.main === module) {
3511
4393
  // synthetic registry + requireModule injections.
3512
4394
  // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
3513
4395
  // the third-party overlay dispatch + install-root confinement tests.
3514
- module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot, dispatchHostCommand, HOST_COMMAND_ROUTERS };
4396
+ module.exports = {
4397
+ dispatchCapabilityCommand,
4398
+ dispatchOverlayCapabilityCommand,
4399
+ defaultRequireFromInstallRoot,
4400
+ dispatchHostCommand,
4401
+ HOST_COMMAND_ROUTERS,
4402
+ TOP_LEVEL_USAGE,
4403
+ skipsRootResolution,
4404
+ resolveMainWorktreeCwd,
4405
+ // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
4406
+ // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
4407
+ resolveSpawnBinary,
4408
+ };
3515
4409