@opengsd/gsd-core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -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
@@ -65,9 +68,22 @@
65
68
  * gaps_found-only, never call on the pass path
66
69
  *
67
70
  * Milestone Operations:
68
- * milestone complete <version> Archive milestone, create MILESTONES.md
71
+ * milestone complete <version> (--confirm | --dry-run)
72
+ * Archive milestone, create MILESTONES.md — one of the two is required
73
+ * --confirm REQUIRED to mutate (#3726): the archive is irreversible (ROADMAP/
74
+ * REQUIREMENTS archived, phase dirs MOVED, STATE.md rewritten), so
75
+ * without this flag the command refuses and mutates nothing
76
+ * --dry-run Preview what would move, mutates nothing (no --confirm needed; #2118)
69
77
  * [--name <name>]
70
78
  * [--no-archive-phases] Skip moving phase dirs to milestones/vX.Y-phases/ (archived by default)
79
+ * [--archive-quick] Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
80
+ * Quick Tasks Completed table (#2142; opt-in, default OFF)
81
+ *
82
+ * milestone archive-quick <version> Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
83
+ * Quick Tasks Completed table, WITHOUT the milestone complete close-out
84
+ * (no ROADMAP/REQUIREMENTS/MILESTONES.md writes, no completion guards);
85
+ * safe against an already-completed milestone (#2142 escalation)
86
+ * [--dry-run] Preview what would move, mutates nothing
71
87
  *
72
88
  * User Story Validation:
73
89
  * user-story validate --story "..." Validate "As a / I want to / so that" format
@@ -80,12 +96,22 @@
80
96
  * drift-guard severity --status <S> Classify a symbol verdict into { severity, hardBlock }
81
97
  * [--authority <A>] Status: VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE
82
98
  * Authority: grep|intel|treesitter|lsp|scip (default: config-resolved)
99
+ * drift-guard phase-status [--phase N] Compare STATE.md vs ROADMAP.md phase status
83
100
  *
84
101
  * Validation:
85
102
  * validate consistency Check phase numbering, disk/roadmap sync
86
103
  * validate health [--repair] Check .planning/ integrity, optionally repair
87
104
  * validate agents Check GSD agent installation status
88
105
  *
106
+ * Planning Snapshot:
107
+ * planning inspect Read-only schema-v1 canonical planning snapshot
108
+ * (milestone identity, active phase, per-phase
109
+ * verification/roadmap-acceptance/UAT evidence kept
110
+ * separate, requirement rows with mapped-phase
111
+ * traceability, plan/task rows with planned+changed
112
+ * file provenance, and independent accepted_phases /
113
+ * completed_plans fractions). Takes no arguments.
114
+ *
89
115
  * Progress:
90
116
  * progress [json|table|bar] Render progress in various formats
91
117
  *
@@ -228,13 +254,22 @@ try {
228
254
  process.stderr.write((bootErr && bootErr.message ? bootErr.message : String(bootErr)) + '\n');
229
255
  // Fatal bootstrap failure before the CLI's ExitError/runMain machinery (which
230
256
  // lives in ./lib) is available to load, so a direct exit is the only option.
231
- // eslint-disable-next-line n/no-process-exit
257
+ // #3910: this call runs BEFORE ./lib/cli-exit.cjs is even required, so the
258
+ // registered-exit seam (runMain/ExitError/terminateNow) does not exist yet
259
+ // at this point in the process's lifetime — there is nothing to route
260
+ // through. This is the second (and only other) sanctioned allowlist entry
261
+ // for local/require-registered-exit, alongside terminateNow's own body.
262
+ // #3914: n/no-process-exit and local/require-registered-exit are
263
+ // complementary, not predecessor/successor (see eslint.config.mjs and
264
+ // docs/adr/3889-process-exit-contract.md) — both remain 'error' on this
265
+ // glob, so both need a disable directive here.
266
+ // eslint-disable-next-line n/no-process-exit, local/require-registered-exit
232
267
  process.exit(1);
233
268
  }
234
269
 
235
- const { ExitError, runMain } = require('./lib/cli-exit.cjs');
270
+ const { ExitError, runMain, resolveContractVersion } = require('./lib/cli-exit.cjs');
236
271
  const io = require('./lib/io.cjs');
237
- const { error, ERROR_REASON, setJsonErrorMode, output } = io;
272
+ const { error, ERROR_REASON, setJsonErrorMode, output, formatDiagnosticToken } = io;
238
273
  const projectRoot = require('./lib/project-root.cjs');
239
274
  // Resolve findProjectRoot lazily at call time rather than binding it at module
240
275
  // load. It is sourced from project-root.cjs; a call-time lookup is robust
@@ -259,8 +294,7 @@ try {
259
294
  }
260
295
  } catch { /* advisory — never block */ }
261
296
 
262
- const { getActiveWorkstream } = require('./lib/planning-workspace.cjs');
263
- const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs');
297
+ const { resolveActiveWorkstream, applyResolvedWorkstreamEnv, peekActiveWorkstream } = require('./lib/active-workstream-store.cjs');
264
298
  const state = require('./lib/state.cjs');
265
299
  const phase = require('./lib/phase.cjs');
266
300
  const roadmap = require('./lib/roadmap.cjs');
@@ -275,6 +309,7 @@ const estimateCli = require('./lib/estimate-cli.cjs');
275
309
  const template = require('./lib/template.cjs');
276
310
  const milestone = require('./lib/milestone.cjs');
277
311
  const commands = require('./lib/commands.cjs');
312
+ const runtimeIdentity = require('./lib/runtime-identity.cjs');
278
313
  const init = require('./lib/init.cjs');
279
314
  const frontmatter = require('./lib/frontmatter.cjs');
280
315
  const workstream = require('./lib/workstream.cjs');
@@ -286,6 +321,7 @@ const { routeVerifyCommand } = require('./lib/verify-command-router.cjs');
286
321
  const { routeEvalCommand } = require('./lib/eval-command-router.cjs');
287
322
  const evalMod = require('./lib/eval.cjs');
288
323
  const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
324
+ const { routePlanningCommand } = require('./lib/planning-command-router.cjs');
289
325
  const verification = require('./lib/verification.cjs');
290
326
  const { routeInitCommand } = require('./lib/init-command-router.cjs');
291
327
  // Stale-bake guard (#1688): warns once when model config changed since agents
@@ -303,9 +339,9 @@ const { routeAgentCommand, AGENT_FAILURE_CLASSES } = require('./lib/agent-comman
303
339
  const smartEntryMod = require('./lib/smart-entry.cjs');
304
340
  const { routeCheckCommand } = require('./lib/check-command-router.cjs');
305
341
  const { routeTaskCommand } = require('./lib/task-command-router.cjs');
306
- const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
342
+ const { parseNamedArgsOrExit, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
307
343
  const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
308
- const { getEffectiveAuthority, classifyDriftSeverity } = require('./lib/plan-drift-guard.cjs');
344
+ const { getEffectiveAuthority, classifyDriftSeverity, comparePhaseStatus } = require('./lib/plan-drift-guard.cjs');
309
345
 
310
346
  // ─── Bridge collapsed (Phase 4) ────────────────────────────────────────────────
311
347
  // Non-family commands now run through their CJS handlers directly. Keep the
@@ -921,6 +957,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
921
957
  commands.cmdCheckCommit(cwd, raw);
922
958
  }
923
959
 
960
+ function routeCommitDocsGuard({ args, cwd, raw, error }) {
961
+ const subcommand = args[1];
962
+ if (subcommand === 'enable') {
963
+ commands.cmdCommitDocsGuardEnable(cwd, raw);
964
+ } else if (subcommand === 'disable') {
965
+ commands.cmdCommitDocsGuardDisable(cwd, raw);
966
+ } else {
967
+ error('Unknown commit-docs-guard subcommand. Available: enable, disable', ERROR_REASON.SDK_UNKNOWN_COMMAND);
968
+ }
969
+ }
970
+
924
971
  function routeCommitToSubrepo({ args, cwd, raw, error }) {
925
972
  const message = args[1];
926
973
  const filesIndex = args.indexOf('--files');
@@ -930,7 +977,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
930
977
 
931
978
  function routePrSubrepo({ args, cwd, raw, error }) {
932
979
  const message = args[1];
933
- const { repo, branch } = parseNamedArgs(args, ['repo', 'branch']);
980
+ // #3884: the commit message is an optional leading positional the
981
+ // caller owns (args[1]) — but when it is OMITTED, args[1] is itself
982
+ // the first flag (e.g. `--repo`), and a static `positionals: 2`
983
+ // treats that flag's own value as an unexpected trailing positional
984
+ // before cmdPrSubrepo's own "commit message required" guard ever
985
+ // runs. Widen the boundary only when args[1] genuinely looks like a
986
+ // message (not flag-shaped), mirroring the same fix applied to
987
+ // `state complete-phase`.
988
+ const messagePresent = message !== undefined && !message.startsWith('--');
989
+ const { repo, branch } = parseNamedArgsOrExit(args, { valueFlags: ['repo', 'branch'], positionals: messagePresent ? 2 : 1 }, error);
934
990
  commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
935
991
  }
936
992
 
@@ -947,7 +1003,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
947
1003
  template.cmdTemplateSelect(cwd, args[2], raw);
948
1004
  } else if (subcommand === 'fill') {
949
1005
  const templateType = args[2];
950
- const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']);
1006
+ const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'plan', 'name', 'type', 'wave', 'fields'], positionals: 3 }, error);
951
1007
  let fields = {};
952
1008
  if (fieldsRaw) {
953
1009
  const { safeJsonParse } = require('./lib/security.cjs');
@@ -996,14 +1052,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
996
1052
  }
997
1053
  // CJS fallback (SDK unavailable or unknown subcommand)
998
1054
  if (subcommand === 'get') {
999
- frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
1055
+ frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['field'], positionals: 3 }, error).field, raw);
1000
1056
  } else if (subcommand === 'set') {
1001
- const { field, value } = parseNamedArgs(args, ['field', 'value']);
1057
+ const { field, value } = parseNamedArgsOrExit(args, { valueFlags: ['field', 'value'], positionals: 3 }, error);
1002
1058
  frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
1003
1059
  } else if (subcommand === 'merge') {
1004
- frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw);
1060
+ frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['data'], positionals: 3 }, error).data, raw);
1005
1061
  } else if (subcommand === 'validate') {
1006
- frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw);
1062
+ frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['schema'], positionals: 3 }, error).schema, raw);
1007
1063
  } else {
1008
1064
  error('Unknown frontmatter subcommand. Available: get, set, merge, validate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1009
1065
  }
@@ -1047,6 +1103,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1047
1103
  commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1048
1104
  }
1049
1105
 
1106
+ function routeRuntimeIdentity({ raw }) {
1107
+ // #3146: report this runtime's package identity so a shipped workflow can
1108
+ // tell whether it reached THIS package's gsd-tools or a colliding one.
1109
+ // Kept on the CJS fast path for the same reason as current-timestamp — the
1110
+ // launcher preamble spawns it once per workflow run, so SDK bridge startup
1111
+ // would be a per-run tax on every workflow.
1112
+ runtimeIdentity.cmdRuntimeIdentity(raw);
1113
+ }
1114
+
1050
1115
  function routeSkillsRoot({ args, raw, error }) {
1051
1116
  // #3024: resolve the global skills base directory for a runtime.
1052
1117
  // The sync-skills workflow previously shelled out to install.js --skills-root,
@@ -1118,10 +1183,38 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1118
1183
  // to the pure appendQuickTaskRow (markdown-table.cjs); this case only
1119
1184
  // handles the I/O (read STATE.md, resolve date/commit, write STATE.md).
1120
1185
  const qtaArgs = args.slice(1);
1121
- const qtaTask = parseNamedArgs(qtaArgs, ['task']).task || args[1];
1186
+ // Ambiguous boundary (ADR-3473 §8.4 Item 2 note): this command accepts
1187
+ // EITHER a positional free-text description (qtaArgs[0]) OR --task
1188
+ // <value> — the same token index is a caller-owned positional in one
1189
+ // input shape and a flag in the other, which a single fixed
1190
+ // `positionals` cursor cannot represent. `positionals: 'rest'`
1191
+ // disables the boundary walk (as with `init quick`) so extraction
1192
+ // (used for the --task form) and the `|| args[1]` fallback (used for
1193
+ // the positional form) both keep working unchanged.
1194
+ // #3356 defect 1: `--quick-id` / `--slug` / `--directory` are
1195
+ // OPTIONAL widenings. A caller with no quick id or task directory
1196
+ // (fast.md, the original #2133 caller) omits them and keeps the
1197
+ // exact prior ordinal-`#`/`'—'`-Directory row. A caller that DOES
1198
+ // have a real quick id + task dir (i.e. can match `workflows/
1199
+ // quick.md`'s own Step 7c row for the same inputs) supplies them
1200
+ // and gets the byte-equivalent canonical row `quick.md:632`
1201
+ // documents — closing the false-equivalence gap `quick.md:627`
1202
+ // claims. `--directory` wins outright when given explicitly;
1203
+ // otherwise a supplied `--quick-id` + `--slug` pair derives the
1204
+ // canonical permalink the same way `workflows/quick.md` renders it.
1205
+ const qtaParsed = parseNamedArgsOrExit(
1206
+ qtaArgs,
1207
+ { valueFlags: ['task', 'quick-id', 'slug', 'directory'], positionals: 'rest' },
1208
+ error,
1209
+ );
1210
+ const qtaTask = qtaParsed.task || args[1];
1122
1211
  if (!qtaTask) {
1123
1212
  error('quick-tasks-append requires --task <description> (or a positional description)', ERROR_REASON.USAGE);
1124
1213
  }
1214
+ const qtaQuickId = qtaParsed['quick-id'] || undefined;
1215
+ const qtaSlug = qtaParsed['slug'] || undefined;
1216
+ const qtaDirectory = qtaParsed['directory']
1217
+ || (qtaQuickId && qtaSlug ? `[${qtaQuickId}-${qtaSlug}](./quick/${qtaQuickId}-${qtaSlug}/)` : undefined);
1125
1218
 
1126
1219
  const statePath = path.join(cwd, '.planning', 'STATE.md');
1127
1220
  if (!fs.existsSync(statePath)) {
@@ -1147,8 +1240,21 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1147
1240
  // still releases the lock before the throw propagates; the transform
1148
1241
  // throws before returning new content, so nothing is ever written).
1149
1242
  let mutation;
1243
+ // #3356 defect 2: this write touches only the Quick Tasks body
1244
+ // table — a single appended row — so it must not trigger the
1245
+ // default full re-derive of the disk-derived `progress.*`
1246
+ // frontmatter block. `{ resync: false }` mirrors every other
1247
+ // body-only STATE.md writer's convention (src/state.cts's own
1248
+ // docstring on `readModifyWriteStateMd` prescribes it); this route
1249
+ // was the lone outlier still passing no options at all.
1150
1250
  state.readModifyWriteStateMd(statePath, (content) => {
1151
- const result = appendQuickTaskRow(content, { description: qtaTask, date, commit });
1251
+ const result = appendQuickTaskRow(content, {
1252
+ description: qtaTask,
1253
+ date,
1254
+ commit,
1255
+ quickId: qtaQuickId,
1256
+ directory: qtaDirectory,
1257
+ });
1152
1258
  if (!result.ok) {
1153
1259
  // Mirrors fast.md's old "skip with a brief log" behaviour (#2133): this
1154
1260
  // is an expected, recoverable condition (no table / unrecognized
@@ -1159,7 +1265,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1159
1265
  }
1160
1266
  mutation = result.value;
1161
1267
  return result.value.content;
1162
- }, cwd);
1268
+ }, cwd, { resync: false });
1163
1269
 
1164
1270
  output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row);
1165
1271
  }
@@ -1285,30 +1391,45 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1285
1391
  return;
1286
1392
  }
1287
1393
 
1288
- // Effort argv is resolved per lane by the host's own execution policy, exactly as the legs did
1289
- // via `resolve-execution … --pick effort_argv_string`. A lane whose slug is not a known host
1290
- // simply gets none.
1291
- // Resolved through the SAME `resolve-execution` surface the bash legs used
1292
- // (`--host <slug> --pick effort_argv_string`), so the host's negotiated effortSurface still
1293
- // decides whether an argument is emitted and the catalog still owns the syntax (ADR-1239 #2481,
1294
- // ADR-443's escalation ladder). `cmdResolveExecution` writes to stdout and exits, so it cannot
1295
- // be called in-process for a value — this spawns the same bounded query the legs did, once per
1296
- // selected lane. A lane whose slug is not a known host resolves to no effort argument at all.
1394
+ // Effort argv is resolved per lane by the host's own execution policy, through the SAME
1395
+ // `resolve-execution` surface the bash legs used (`--host <slug>`), so the host's negotiated
1396
+ // effortSurface still decides whether an argument is emitted and the catalog still owns the
1397
+ // syntax (ADR-1239 #2481, ADR-443's escalation ladder). `cmdResolveExecution` writes to
1398
+ // stdout and exits, so it cannot be called in-process for a value — this spawns the same
1399
+ // bounded query the legs did, once per selected lane. A lane whose slug is not a known host
1400
+ // resolves to no effort argument at all.
1401
+ //
1402
+ // NOT `--raw` and NOT `--pick` (#2295). `--raw` prints only the resolved EFFORT ('low') with
1403
+ // no host-specific rendering at all. `--pick effort_argv_string` used to be the answer — the
1404
+ // rendered array re-joined into a string ('-c model_reasoning_effort=low') — but the caller
1405
+ // then had to `.split(/\s+/)` that string back apart to get an argv array, and re-splitting a
1406
+ // string the callee just joined is a lossy round trip: any argv element that legitimately
1407
+ // contains a space would come back split into two argv elements, corrupting the very argv it
1408
+ // was rendered to preserve. Reading the UNPICKED object instead gives both `effort_argv` (a
1409
+ // real string array, used verbatim, no re-splitting) and `effort_argv_value` (the bare level,
1410
+ // #2295's `plan.effort`) from the one spawn.
1411
+ const EMPTY_EFFORT = { argv: [], value: null };
1297
1412
  const effortFor = (slug) => {
1298
1413
  try {
1299
1414
  const r = cp.spawnSync(
1300
1415
  process.execPath,
1301
- [__filename, 'query', 'resolve-execution', 'gsd-plan-checker',
1302
- // NOT `--raw`: that prints the resolved EFFORT ('low'), ignoring --pick. The picked
1303
- // field is what carries the host-specific syntax ('--effort low' for claude,
1304
- // '-c model_reasoning_effort=low' for codex), which is the whole point of asking.
1305
- '--host', slug, '--pick', 'effort_argv_string'],
1416
+ [__filename, 'query', 'resolve-execution', 'gsd-plan-checker', '--host', slug],
1306
1417
  { cwd, encoding: 'utf8', timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 },
1307
1418
  );
1308
- if (r.status !== 0) return [];
1309
- const s = String(r.stdout || '').trim();
1310
- return s ? s.split(/\s+/).filter(Boolean) : [];
1311
- } catch { return []; }
1419
+ if (r.status !== 0) return EMPTY_EFFORT;
1420
+ let parsed;
1421
+ try {
1422
+ parsed = JSON.parse(String(r.stdout || ''));
1423
+ } catch { return EMPTY_EFFORT; }
1424
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return EMPTY_EFFORT;
1425
+ const argv = Array.isArray(parsed.effort_argv)
1426
+ ? parsed.effort_argv.filter((a) => typeof a === 'string' && a !== '')
1427
+ : [];
1428
+ const value = typeof parsed.effort_argv_value === 'string' && parsed.effort_argv_value
1429
+ ? parsed.effort_argv_value
1430
+ : null;
1431
+ return { argv, value };
1432
+ } catch { return EMPTY_EFFORT; }
1312
1433
  };
1313
1434
 
1314
1435
  /**
@@ -1341,7 +1462,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1341
1462
  // so losing all of them to one bad manifest is strictly worse. Belt and braces on purpose.
1342
1463
  let r;
1343
1464
  try {
1344
- r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effortFor(slug) });
1465
+ const effort = effortFor(slug);
1466
+ r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effort.argv, effortValue: effort.value });
1345
1467
  } catch (e) {
1346
1468
  return { slug, ok: false, reason: 'malformed_lane', detail: `resolver threw: ${e && e.message ? e.message : String(e)}` };
1347
1469
  }
@@ -1385,10 +1507,23 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1385
1507
  // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1386
1508
  // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1387
1509
  // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1388
- const isWin = process.platform === 'win32';
1389
- const winShim = isWin && /\.(cmd|bat)$/i.test(path.basename(binary));
1390
- const spawnBinary = winShim ? (process.env.ComSpec || 'cmd.exe') : binary;
1391
- const spawnArgv = winShim ? ['/d', '/s', '/c', binary, ...argv] : argv;
1510
+ //
1511
+ // #3275: descriptors declare BARE names, so the gate above never saw an
1512
+ // extension — resolve through the shared PATH+PATHEXT resolver FIRST
1513
+ // (the same one `hasBinary` uses, so probe and spawn can never disagree
1514
+ // about what the lane's binary is). POSIX keeps the bare name: Node's own
1515
+ // PATH search already worked there, and the #3275 acceptance contract
1516
+ // holds macOS/Linux behavior unchanged. A name that resolves to nothing
1517
+ // falls back to the declared name so the ENOENT still surfaces (#3086).
1518
+ // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
1519
+ // private copies here; `projectSpawnInvocation` owns them, so a fix to either
1520
+ // reaches every spawn site instead of only this one.
1521
+ //
1522
+ // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
1523
+ // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
1524
+ // from the same resolver, so probe and spawn must agree on the exact file.
1525
+ const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
1526
+ const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
1392
1527
  const r = cp.spawnSync(spawnBinary, spawnArgv, {
1393
1528
  input: opts.input,
1394
1529
  encoding: 'utf8',
@@ -1396,6 +1531,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1396
1531
  killSignal: 'SIGKILL',
1397
1532
  maxBuffer: 64 * 1024 * 1024,
1398
1533
  shell: false, // argv array only — never a shell string (no interpolation of config values)
1534
+ // #2483: a lane's declared env pairs merged OVER this process's environment, for this
1535
+ // child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
1536
+ // into the orchestrating session or into the next lane.
1537
+ ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
1538
+ ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
1399
1539
  });
1400
1540
  return {
1401
1541
  status: r.status,
@@ -1424,24 +1564,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1424
1564
  // all (a probe that costs a process is a probe you avoid running, which is how the original
1425
1565
  // Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
1426
1566
  // Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
1427
- hasBinary: (name) => {
1428
- if (!name || name.includes('/') || name.includes('\\')) {
1429
- try { return fsx.statSync(name).isFile(); } catch { return false; }
1430
- }
1431
- const exts = process.platform === 'win32'
1432
- ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
1433
- : [''];
1434
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
1435
- for (const ext of exts) {
1436
- const candidate = path.join(dir, name + ext);
1437
- try {
1438
- const st = fsx.statSync(candidate);
1439
- if (st.isFile()) return true;
1440
- } catch { /* next candidate */ }
1441
- }
1442
- }
1443
- return false;
1444
- },
1567
+ //
1568
+ // #3275: the scan lives in `resolveSpawnBinary` now, SHARED with `deps.spawn`
1569
+ // above. Two private copies of "what is this declared binary?" is how the
1570
+ // defect hid: the probe resolved WITH PATHEXT while spawn resolved WITHOUT,
1571
+ // so a lane reported available for a spawn that could never start. One
1572
+ // resolver, both seams — if one changes, the other changes with it.
1573
+ hasBinary: (name) => resolveSpawnBinary(name) !== null,
1445
1574
  configGet,
1446
1575
  homeDir: os.homedir(),
1447
1576
  warn: (m) => process.stderr.write(`${m}\n`),
@@ -1493,12 +1622,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1493
1622
  `model override (it declares no modelConfigKey). The review will use the CLI's own default.\n`,
1494
1623
  );
1495
1624
  }
1625
+ const instanceEffort = effortFor(entry.slug);
1496
1626
  const overridden = resolveLanePlan({
1497
1627
  lane,
1498
1628
  configGet: (k) => (key && k === key ? instanceModel : configGet(k)),
1499
1629
  runDir,
1500
1630
  repoRoot,
1501
- effortArgs: effortFor(entry.slug),
1631
+ effortArgs: instanceEffort.argv,
1632
+ effortValue: instanceEffort.value,
1502
1633
  });
1503
1634
  if (overridden.ok) {
1504
1635
  // Preserve any instance retargeting already applied above.
@@ -1575,7 +1706,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1575
1706
  function routeDispatchShouldFlatten({ args, cwd, raw, error }) {
1576
1707
  // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
1577
1708
  //
1578
- // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
1709
+ // Resolves the current runtime (GSD_RUNTIME > config.runtime > per-install
1710
+ // .gsd-runtime marker > 'claude'),
1579
1711
  // looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and
1580
1712
  // calls shouldFlattenDispatch(dispatch) from host-integration.cjs.
1581
1713
  //
@@ -1621,6 +1753,31 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1621
1753
  }
1622
1754
  }
1623
1755
 
1756
+ /**
1757
+ * #3737 — strict read of the project-level worktree opt-out from
1758
+ * `.planning/config.json`. True ONLY when `workflow.use_worktrees` is the
1759
+ * boolean `false`; an absent key, unreadable/malformed config, or any
1760
+ * non-boolean value (including the string "false") degrades to false —
1761
+ * worktrees are ON by default, so the degraded answer is "not opted out".
1762
+ * Direct file read, deliberately NOT loadConfig: this resolver backs
1763
+ * sentinel writes and must never trigger config normalization/rewrites
1764
+ * (same discipline as resolveDispatchIsolationDecision's resolveRuntime
1765
+ * comment above). Never throws.
1766
+ */
1767
+ function projectWorktreesOptedOut(cwd) {
1768
+ // #3972: single owner — planning-workspace's worktreesOptedOut ladder
1769
+ // (scoped own-key, root inheritance under the ws gate, strict === false).
1770
+ // Kept as a local name so routeDispatchIsolation's call sites read the
1771
+ // same as they did in #3938/#3963; the logic itself now lives beside
1772
+ // planningDir/planningRoot where every isolation surface can share it.
1773
+ try {
1774
+ const { worktreesOptedOut } = require('./lib/planning-workspace.cjs');
1775
+ return worktreesOptedOut(cwd);
1776
+ } catch {
1777
+ return false;
1778
+ }
1779
+ }
1780
+
1624
1781
  function routeDispatchIsolation({ args, cwd, raw, error }) {
1625
1782
  // #2584 Phase 3 (#2627): typed query exposing the negotiated
1626
1783
  // `dispatch.isolation` to the execute-phase wave scheduler, so the
@@ -1629,7 +1786,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1629
1786
  // #853) for exactly this reason — it replaced a `RUNTIME === 'codex'`
1630
1787
  // prose rule.
1631
1788
  //
1632
- // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
1789
+ // Resolves the current runtime (GSD_RUNTIME > config.runtime > per-install
1790
+ // .gsd-runtime marker > 'claude'),
1633
1791
  // reads registry.runtimes[id].runtime.hostIntegration.dispatch.isolation,
1634
1792
  // and validates it against the closed vocabulary.
1635
1793
  //
@@ -1664,12 +1822,251 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1664
1822
  // `per-plan-worktree-gate.md`) override the naturally-resolved mode
1665
1823
  // while still going through this single write path. Best-effort: a
1666
1824
  // sentinel write failure here must never fail the wave.
1667
- const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1825
+ const decision = resolveDispatchIsolationDecision({ args, cwd });
1826
+ const runtimeId = decision.runtimeId;
1827
+ let { isolation, exec, harnessFlag } = decision;
1828
+
1829
+ // `--force-isolation <mode>` overrides the naturally-resolved mode with
1830
+ // context this resolver has no way to see on its own (e.g. the #2474
1831
+ // per-plan submodule intersection). Invalid/unrecognized values are
1832
+ // ignored rather than erroring — this is a best-effort recording call,
1833
+ // not a hard usage gate. Forcing to 'none' clears harnessFlag/exec since
1834
+ // neither applies to sequential dispatch.
1835
+ const forceIdx = args.indexOf('--force-isolation');
1836
+ const forcedIsolation = forceIdx !== -1 ? args[forceIdx + 1] : undefined;
1837
+ if (forcedIsolation && DISPATCH_ISOLATION_VOCABULARY.has(forcedIsolation)) {
1838
+ isolation = forcedIsolation;
1839
+ if (isolation === 'none') {
1840
+ harnessFlag = null;
1841
+ exec = null;
1842
+ }
1843
+ }
1844
+
1845
+ const phaseIdx = args.indexOf('--phase');
1846
+ const phaseArg = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
1847
+ ? args[phaseIdx + 1]
1848
+ : null;
1849
+ const planIdx = args.indexOf('--plan');
1850
+ const planArg = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
1851
+ ? args[planIdx + 1]
1852
+ : null;
1853
+
1854
+ // #3737: the project-level opt-out (workflow.use_worktrees === false) is
1855
+ // decided HERE, before the sentinel write — not only in the workflow
1856
+ // shell blocks that run after this resolve. Pre-fix, any plain re-query
1857
+ // (config re-read, wave transition, second plan dispatch) re-persisted
1858
+ // the naturally-resolved host capability over the `--force-isolation
1859
+ // none` record the dispatch-isolation reference mandates, and the guard
1860
+ // then denied the sequential dispatch the config explicitly asked for.
1861
+ // Applied AFTER --force-isolation so the documented rule holds: the
1862
+ // opt-out wins on every host, over both the natural resolution and any
1863
+ // force. Strict `=== false`: the default is worktrees ON, so an absent
1864
+ // key, an unreadable/malformed config, or a non-boolean value degrades
1865
+ // to "not opted out" (mirrors readConfigJsonBoolean's no-coercion
1866
+ // discipline in lib/init.cjs).
1867
+ if (projectWorktreesOptedOut(cwd)) {
1868
+ isolation = 'none';
1869
+ harnessFlag = null;
1870
+ exec = null;
1871
+ }
1872
+
1873
+ // Side-effect write (#3045 CORE REDESIGN) — see the doc comment above.
1874
+ // Never allowed to affect this query's own stdout contract or throw.
1875
+ try {
1876
+ writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase: phaseArg, plan: planArg });
1877
+ } catch {
1878
+ // writeDispatchIsolationSentinel already swallows its own errors into
1879
+ // a { recorded: false } result; this catch is defense in depth only.
1880
+ }
1881
+
1882
+ if (args.indexOf('--json') !== -1) {
1883
+ output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
1884
+ } else {
1885
+ process.stdout.write(isolation);
1886
+ }
1887
+ }
1888
+
1889
+ // #3714 follow-up — the dispatch seam gated only on PRESENCE of an explicit
1890
+ // pin, never on its VALUE, so an Anthropic-flavored global default
1891
+ // (~/.gsd/defaults.json model_overrides["gsd-executor"] = "sonnet"/"opus"/
1892
+ // "claude-*") reached `codex exec --model sonnet`: the documented #2310/
1893
+ // #2311 400 on a passive-posture host (ADR-1239/ADR-2313). It also let a
1894
+ // repo-committed .planning/config.json inject shell-hostile argv (a
1895
+ // `-c approval_policy=never` suffix, `$(...)`/`;` command injection,
1896
+ // embedded control characters) straight onto exec's argv.
1897
+ //
1898
+ // This mirrors — deliberately, not by re-derivation — the same VALUE
1899
+ // policy bin/install.js's generateCodexAgentToml() already applies to the
1900
+ // identical model_overrides["gsd-executor"] config key for the .toml
1901
+ // surface (bin/install.js ~3983-4046): trim; a whitespace-only value drops
1902
+ // silently (#3241, no warning); an Anthropic-flavored value
1903
+ // (isAnthropicFlavoredModel, single-sourced on bin/lib/model-catalog.cjs
1904
+ // per #3241 specifically so it cannot diverge across Codex-posture
1905
+ // surfaces) drops WITH a warning; a real pin survives verbatim. Two
1906
+ // additions beyond the .toml surface, both specific to this seam: the
1907
+ // 'inherit' sentinel (case/whitespace-insensitive) is a no-op here already
1908
+ // and must stay one, and a value that doesn't look like a model id at all
1909
+ // (the injection case above — the .toml surface never had to consider this
1910
+ // because TOML string-quoting isn't a shell argv boundary) is dropped with
1911
+ // a warning rather than ever reaching child_process argv.
1912
+ // Single source of truth for the model-id "allowed characters" notion
1913
+ // (#3714 follow-up — "Generative Fix Divergence"): the accept regex
1914
+ // (MODEL_ID_CHARSET_RE, used to ADMIT a pin) and the sanitizer keep-class
1915
+ // (MODEL_ID_SANITIZE_STRIP_RE, used to RENDER a rejected pin into a
1916
+ // warning) are both derived from this one character-class body so they
1917
+ // cannot drift apart again the way they already did once (the '@' added
1918
+ // for Vertex pins landed in the accept regex but not the sanitizer,
1919
+ // rendering "text-bison@002" as "text-bison?002" in the warning). '@' is
1920
+ // included for Vertex model-version pins ("text-bison@002",
1921
+ // "chat-bison@001"), which are legitimate model ids reachable through a
1922
+ // custom model_provider.
1923
+ // This body is interpolated raw into BOTH a positive character class
1924
+ // (MODEL_ID_CHARSET_RE, `[BODY]`) and a negated one
1925
+ // (MODEL_ID_SANITIZE_STRIP_RE, `[^BODY]`) below — only plain characters
1926
+ // and `x-y` ranges are safe here. A class metacharacter (`^`, `]`, `\`)
1927
+ // would mean different things in the two derived regexes if ever added.
1928
+ const MODEL_ID_CHARSET_BODY = 'A-Za-z0-9._:/@-';
1929
+ // The first character must be alphanumeric (#3714 hardening): a leading
1930
+ // '.', '_', ':', '/', or '@' has no legitimate model-id use case and, for
1931
+ // resolveOrchestratorExec's documented role as a GENERAL descriptor→argv
1932
+ // seam other hosts may adopt, a leading '@' or '/' is exactly the shape an
1933
+ // @-response-file or /-switch parser would key on. The leading-dash shape
1934
+ // is enforced separately by LEADING_DASH_RE below — it is NOT relaxed
1935
+ // here, since a flag-shaped value ("-c", "--config") must still fail the
1936
+ // resolver's own unsafe_leading_dash_model guard path via that dedicated
1937
+ // check, not this charset.
1938
+ const MODEL_ID_CHARSET_RE = new RegExp(`^[A-Za-z0-9][${MODEL_ID_CHARSET_BODY}]*$`);
1939
+ // Keep-class for sanitizing a REJECTED pin before it reaches the warning
1940
+ // (a guaranteed-reachable raw-to-TTY sink — the dispatch step runs with no
1941
+ // `2>` redirect). Built from the same MODEL_ID_CHARSET_BODY as the accept
1942
+ // regex above, so every character the matcher accepts also survives the
1943
+ // sanitizer unchanged, and a widened charset can never diverge from its
1944
+ // rendering again.
1945
+ //
1946
+ // This `g`-flagged instance is for internal `.replace()` use ONLY — a
1947
+ // `/g` regex is stateful (`.lastIndex` persists across calls) and
1948
+ // `.test()` on it alternates true/false/true across repeated calls on the
1949
+ // same string, a false-green trap for any test that reaches for `.test()`
1950
+ // instead of `.replace()`. To make that trap impossible rather than just
1951
+ // documenting it, this `g`-flagged object is never exported; the exported
1952
+ // `MODEL_ID_SANITIZE_STRIP_RE` below is a separate, non-global instance
1953
+ // built from the same body, safe for `.test()`/`.match()` in tests.
1954
+ const MODEL_ID_SANITIZE_STRIP_RE_G = new RegExp(`[^${MODEL_ID_CHARSET_BODY}]`, 'g');
1955
+ // Non-global companion of MODEL_ID_SANITIZE_STRIP_RE_G, exported for
1956
+ // tests. Do not use with `.replace()` on a value containing more than one
1957
+ // disallowed character — it only replaces the first match. Production
1958
+ // code must use the `g`-flagged instance above instead.
1959
+ const MODEL_ID_SANITIZE_STRIP_RE = new RegExp(`[^${MODEL_ID_CHARSET_BODY}]`);
1960
+ // A model id has no legitimate reason to be long; this also keeps a
1961
+ // pathological pin away from the Windows argv ceiling (execFileSync aborts
1962
+ // if argv > 32,767 chars — CLAUDE.md "Windows ARGV Overflow"). A pin over
1963
+ // this length is DROPPED WITH A WARNING like every other rejection, never
1964
+ // truncated into argv — a truncated model id is a different model id.
1965
+ const MODEL_ID_MAX_LENGTH = 200;
1966
+ const _dispatchModelPinDropWarned = new Set();
1967
+ function _warnDispatchModelPinDropped(agentName, rawValue, reason) {
1968
+ const key = `${agentName}::${rawValue}::${reason}`;
1969
+ if (_dispatchModelPinDropWarned.has(key)) return;
1970
+ _dispatchModelPinDropWarned.add(key);
1971
+ // Sanitize BEFORE truncating: every value that reaches this warning
1972
+ // failed the model-id charset test by definition (or, for the
1973
+ // over-length case, still only ever contains charset-legal bytes) —
1974
+ // sanitizing first catches raw control/escape bytes (ESC, BEL, CSI
1975
+ // sequences) using the identity-sanitizing pattern already used for
1976
+ // --as at gsd-tools.cjs:1526. Sanitizing before truncating also ensures
1977
+ // a truncated escape sequence can never survive (e.g. an SGR sequence
1978
+ // cut before its reset, leaving sticky terminal state) — truncation
1979
+ // only ever cuts already-safe characters.
1980
+ const sanitized = String(rawValue).replace(MODEL_ID_SANITIZE_STRIP_RE_G, '?');
1981
+ const safe = sanitized.length > 64 ? `${sanitized.slice(0, 64)}…` : sanitized;
1982
+ process.stderr.write(
1983
+ `gsd: warning — dispatch model pin for agent "${agentName}" (value "${safe}") ${reason}; ` +
1984
+ `dropping it so the spawned executor falls back to the session model.\n`,
1985
+ );
1986
+ }
1987
+ // A value starting with '-' (or '--') is a flag/option shape, not a model
1988
+ // id — `-c`, `--config`, `-`, `--`, `-p` are unsafe to hand to
1989
+ // resolveOrchestratorExec, whose own `unsafe_leading_dash_model` guard
1990
+ // rejects them and fails the WHOLE resolution to `{ ok: false }` ->
1991
+ // exec:null -> a FATAL wave abort (executor-isolation-dispatch.md:299-303),
1992
+ // even on hosts (e.g. kimi-code) that declare no modelFlag at all and
1993
+ // previously ignored the pin entirely. This check MUST run BEFORE
1994
+ // MODEL_ID_CHARSET_RE below: the charset is anchored to `[A-Za-z0-9]` at
1995
+ // the first character, so every dash-leading value already fails the
1996
+ // charset test and would otherwise be swallowed by the generic "unsafe
1997
+ // characters" message, losing the more specific and more actionable
1998
+ // flag/option diagnosis. Reject here, at the VALUE-policy layer, so a
1999
+ // leading-dash value degrades to "no model" like every other rejected
2000
+ // shape, instead of reaching a resolver whose failure mode is fatal
2001
+ // rather than a graceful drop.
2002
+ const LEADING_DASH_RE = /^-/;
2003
+ /**
2004
+ * Resolve the VALUE policy for an explicit dispatch model pin. `rawValue`
2005
+ * is whatever resolveAgentModelOverride(..., null) returned — a string
2006
+ * pin, the 'inherit' sentinel, or null/''/undefined for "no explicit pin".
2007
+ * Returns the trimmed model string to embed, or `undefined` to emit no
2008
+ * --model flag at all. Never throws; never fails closed to an error —
2009
+ * every rejection degrades to "no model" (drop-and-warn), matching the
2010
+ * documented desired behavior of falling back to the session model rather
2011
+ * than aborting the wave.
2012
+ */
2013
+ function resolveDispatchModelPin(agentName, rawValue) {
2014
+ if (typeof rawValue !== 'string') return undefined; // not a string -> no model
2015
+ const trimmed = rawValue.trim();
2016
+ if (trimmed === '') return undefined; // whitespace-only -> no model, no warning (#3241)
2017
+ if (trimmed.toLowerCase() === 'inherit') return undefined; // sentinel -> no model, no warning
2018
+ const { isAnthropicFlavoredModel } = require('./lib/model-catalog.cjs');
2019
+ if (isAnthropicFlavoredModel(trimmed)) {
2020
+ _warnDispatchModelPinDropped(agentName, rawValue, 'is an Anthropic-flavored model/alias, not a valid Codex model');
2021
+ return undefined;
2022
+ }
2023
+ if (LEADING_DASH_RE.test(trimmed)) {
2024
+ _warnDispatchModelPinDropped(agentName, rawValue, 'looks like a flag/option, not a model id (leading "-")');
2025
+ return undefined;
2026
+ }
2027
+ if (!MODEL_ID_CHARSET_RE.test(trimmed)) {
2028
+ _warnDispatchModelPinDropped(agentName, rawValue, 'does not look like a model id (unsafe characters)');
2029
+ return undefined;
2030
+ }
2031
+ if (trimmed.length > MODEL_ID_MAX_LENGTH) {
2032
+ _warnDispatchModelPinDropped(agentName, rawValue, `exceeds the maximum model id length (${MODEL_ID_MAX_LENGTH} characters)`);
2033
+ return undefined;
2034
+ }
2035
+ return trimmed;
2036
+ }
2037
+
2038
+ const DISPATCH_ISOLATION_VOCABULARY = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
2039
+
2040
+ /**
2041
+ * Shared, side-effect-free resolution of the negotiated dispatch isolation:
2042
+ * runtime (GSD_RUNTIME > config.runtime > per-install .gsd-runtime marker >
2043
+ * 'claude') → declared
2044
+ * `dispatch.isolation` → harness-flag / orchestrator-exec degrade rules.
2045
+ * Extracted (#2486) so `routeDispatchIsolation` (the #3045 recording
2046
+ * dispatch path) and `routeInspectDispatchIsolation` (the read-only
2047
+ * inspection path) share exactly one negotiation implementation and cannot
2048
+ * drift apart. Resolution only — the caller decides whether the decision is
2049
+ * recorded to the sentinel.
2050
+ */
2051
+ function resolveDispatchIsolationDecision({ args, cwd }) {
1668
2052
  let isolation = 'none';
1669
2053
  let runtimeId = null;
1670
2054
  let exec = null;
1671
2055
  let harnessFlag = null;
1672
2056
  try {
2057
+ // Deliberately `resolveRuntime`, NOT `resolveActiveRuntime`/`loadConfig`:
2058
+ // loadConfig normalizes and rewrites legacy keys back to disk, and this
2059
+ // resolver backs the sentinel-free `inspect-dispatch-isolation` verb,
2060
+ // which must never write. resolveRuntime reads config.json directly.
2061
+ //
2062
+ // KNOWN LIMITATION, tracked separately: resolveRuntime stops at
2063
+ // GSD_RUNTIME > config.runtime > 'claude' and does not consult the
2064
+ // per-install `.gsd-runtime` marker, so on a non-Claude install whose
2065
+ // project config carries no `runtime` key this resolves 'claude'. That is
2066
+ // open-gsd/gsd-core#2395 — a pre-existing defect in the canonical
2067
+ // resolver, not introduced here, and deliberately NOT fixed in this PR
2068
+ // (its blast radius reaches every consumer of that resolver, so it is
2069
+ // being handled on its own).
1673
2070
  const { resolveRuntime } = require('./lib/runtime-slash.cjs');
1674
2071
  runtimeId = resolveRuntime(cwd);
1675
2072
 
@@ -1678,7 +2075,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1678
2075
  ? registry.runtimes[runtimeId]
1679
2076
  : null;
1680
2077
  const declared = runtimeEntry?.runtime?.hostIntegration?.dispatch?.isolation ?? null;
1681
- if (typeof declared === 'string' && VALID_ISOLATION.has(declared)) {
2078
+ if (typeof declared === 'string' && DISPATCH_ISOLATION_VOCABULARY.has(declared)) {
1682
2079
  isolation = declared;
1683
2080
  }
1684
2081
 
@@ -1701,14 +2098,67 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1701
2098
  const promptIdx = args.indexOf('--prompt');
1702
2099
  const promptArg = promptIdx !== -1 ? args[promptIdx + 1] : undefined;
1703
2100
  const hostIntegration = require('./lib/host-integration.cjs');
2101
+ // #3714: resolve an EXPLICIT, non-sentinel per-agent model pin for the
2102
+ // spawned worktree executor. Passing `null` as the runtime resolver
2103
+ // (3rd arg) is LOAD-BEARING, not an oversight — it is what keeps
2104
+ // profile/tier-derived models out of argv. Codex's `modelMode: passive`
2105
+ // posture (ADR-1239) and ADR-2313 forbid GSD driving model selection on
2106
+ // this host; only an operator's EXPLICIT override may cross this seam.
2107
+ // resolveAgentModelOverride(..., null) returns a value ONLY when the
2108
+ // operator pinned one explicitly (measured: unpinned -> null,
2109
+ // "inherit" -> "inherit" meaning "use the ambient session model, don't
2110
+ // pass a flag", "" -> null, profile-only -> null). Do NOT swap in
2111
+ // resolve-model / a full model-resolver here: that resolver falls back
2112
+ // to a default (e.g. "sonnet") for the unpinned/profile-only cases,
2113
+ // and emitting that on Codex's exec argv is exactly the documented
2114
+ // #2310/#2311 regression (a model unknown to Codex forced into a
2115
+ // passive-posture host).
2116
+ //
2117
+ // Presence of a pin is necessary but not sufficient: resolveDispatchModelPin
2118
+ // applies the same VALUE policy the install-side .toml surface already
2119
+ // applies to this config key (trim / drop-inherit / drop-Anthropic-flavored
2120
+ // with a warning / drop-non-model-id-charset with a warning) so a global
2121
+ // Anthropic-flavored default or an injected config value never reaches argv.
2122
+ //
2123
+ // This whole VALUE policy — including its warning — is gated on the
2124
+ // resolved runtime's descriptor actually declaring a non-empty
2125
+ // `modelFlag`. The policy runs at this host-NEUTRAL site, so a host
2126
+ // with no modelFlag at all (kimi, kimi-code, opencode) was never
2127
+ // going to emit a --model regardless of the pin's value; running the
2128
+ // policy anyway produced a stderr warning claiming "dropping it so
2129
+ // the spawned executor falls back to the session model" on every
2130
+ // dispatch for such a host — misleading today, and actively wrong if
2131
+ // a Claude-capable host ever declares a modelFlag. When the
2132
+ // descriptor declares no modelFlag, skip the policy entirely: no
2133
+ // model, no warning, argv byte-identical to before this pin policy
2134
+ // existed.
2135
+ const declaresModelFlag = typeof runtimeEntry?.runtime?.orchestratorExec?.modelFlag === 'string' &&
2136
+ runtimeEntry.runtime.orchestratorExec.modelFlag.length > 0;
2137
+ let model;
2138
+ if (declaresModelFlag) {
2139
+ const { readGsdEffectiveModelOverrides, resolveAgentModelOverride } =
2140
+ require('./lib/install-model-override-resolver.cjs');
2141
+ const pinned = resolveAgentModelOverride(
2142
+ 'gsd-executor', readGsdEffectiveModelOverrides(cwd), null);
2143
+ model = resolveDispatchModelPin('gsd-executor', pinned);
2144
+ }
1704
2145
  const resolution = hostIntegration.resolveOrchestratorExec(
1705
2146
  runtimeEntry?.runtime?.orchestratorExec,
1706
2147
  cwdTarget,
1707
2148
  promptArg,
2149
+ model,
1708
2150
  );
1709
- // A host declaring orchestrator-worktree whose exec descriptor does
1710
- // not resolve cannot be spawned — degrade to sequential rather than
1711
- // hand the scheduler an unusable command.
2151
+ // A host declaring orchestrator-worktree whose exec descriptor does not
2152
+ // resolve halts THIS wave's dispatch: isolation is forced to 'none' here,
2153
+ // and executor-isolation-dispatch.md:299-303 treats a null exec as FATAL
2154
+ // (exit 1) after the worktree has already been created — it does not
2155
+ // degrade to sequential execution. resolveDispatchModelPin rejects any
2156
+ // leading-dash value (flag/option shape) before it ever reaches this
2157
+ // resolver specifically so it cannot trip the resolver's own
2158
+ // `unsafe_leading_dash_model` guard and turn a bad config value into
2159
+ // this fatal path; every other unresolvable model value likewise
2160
+ // degrades to "no --model" (session model fallback) rather than to
2161
+ // resolution.ok === false.
1712
2162
  if (resolution.ok) {
1713
2163
  exec = { command: resolution.command, args: resolution.args, cwd: resolution.cwd };
1714
2164
  } else {
@@ -1721,41 +2171,52 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1721
2171
  exec = null;
1722
2172
  harnessFlag = null;
1723
2173
  }
2174
+ return { runtimeId, isolation, exec, harnessFlag };
2175
+ }
1724
2176
 
1725
- // `--force-isolation <mode>` overrides the naturally-resolved mode with
1726
- // context this resolver has no way to see on its own (e.g. the #2474
1727
- // per-plan submodule intersection). Invalid/unrecognized values are
1728
- // ignored rather than erroring — this is a best-effort recording call,
1729
- // not a hard usage gate. Forcing to 'none' clears harnessFlag/exec since
1730
- // neither applies to sequential dispatch.
1731
- const forceIdx = args.indexOf('--force-isolation');
1732
- const forcedIsolation = forceIdx !== -1 ? args[forceIdx + 1] : undefined;
1733
- if (forcedIsolation && VALID_ISOLATION.has(forcedIsolation)) {
1734
- isolation = forcedIsolation;
1735
- if (isolation === 'none') {
1736
- harnessFlag = null;
1737
- exec = null;
1738
- }
1739
- }
1740
-
1741
- const phaseIdx = args.indexOf('--phase');
1742
- const phaseArg = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
1743
- ? args[phaseIdx + 1]
1744
- : null;
1745
- const planIdx = args.indexOf('--plan');
1746
- const planArg = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
1747
- ? args[planIdx + 1]
1748
- : null;
1749
-
1750
- // Side-effect write (#3045 CORE REDESIGN) — see the doc comment above.
1751
- // Never allowed to affect this query's own stdout contract or throw.
1752
- try {
1753
- writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase: phaseArg, plan: planArg });
1754
- } catch {
1755
- // writeDispatchIsolationSentinel already swallows its own errors into
1756
- // a { recorded: false } result; this catch is defense in depth only.
2177
+ function routeInspectDispatchIsolation({ args, cwd, raw }) {
2178
+ // #2486: sentinel-free sibling of `dispatch-isolation` for INSPECTION
2179
+ // surfaces — /gsd:health's W025 check and /gsd:settings' Worktrees
2180
+ // branching. The dispatch verb above intentionally records its resolved
2181
+ // decision to the isolation sentinel as an unconditional side effect
2182
+ // (#3045 CORE REDESIGN): correct for executor dispatch, where the record
2183
+ // must be structurally unskippable — but wrong for a read-only
2184
+ // diagnostic. A health check that records a phase:null/plan:null
2185
+ // sentinel can hard-block every executor dispatch for the sentinel's
2186
+ // lifetime, across sessions sharing the main checkout. Inspection
2187
+ // surfaces call this verb instead. Two claims, both narrower than
2188
+ // "side-effect-free", and both exactly true (#2486 review, Majors 2 & 4):
2189
+ //
2190
+ // 1. SENTINEL-FREE, not write-free. This route writes nothing itself, and
2191
+ // in particular never writes .gsd/dispatch-isolation-sentinel.json —
2192
+ // the only write that can hard-block a later executor dispatch. It is
2193
+ // NOT an unconditional claim of total filesystem purity: like every
2194
+ // gsd-tools invocation, it runs the shared bootstrap and
2195
+ // active-workstream resolution first. As of #3579's root-cause fix
2196
+ // that bootstrap resolves via the non-mutating peekActiveWorkstream
2197
+ // (never unlinks); an actual stale/invalid pointer is still
2198
+ // self-healed, but only by whichever verb's own getActiveWorkstream
2199
+ // call later consumes it for real — this inspection route makes no
2200
+ // such call, so it is now also side-effect-free on the pointer file.
2201
+ //
2202
+ // 2. SHARED NEGOTIATION, for the arguments this verb accepts. Both verbs
2203
+ // call resolveDispatchIsolationDecision, so the natural resolution
2204
+ // cannot drift. It is NOT a claim of byte-identical output for every
2205
+ // argv: routeDispatchIsolation applies --force-isolation AFTER the
2206
+ // shared helper returns, so the same argv could otherwise yield
2207
+ // 'none' there and the declared capability here. Rather than let a
2208
+ // caller receive a silently different answer, this verb REJECTS the
2209
+ // recording-only knobs outright — they exist to be recorded, and a
2210
+ // read has nothing to record.
2211
+ const RECORDING_ONLY_ARGS = ['--force-isolation', '--phase', '--plan'];
2212
+ const rejected = RECORDING_ONLY_ARGS.filter((flag) => args.indexOf(flag) !== -1);
2213
+ if (rejected.length > 0) {
2214
+ error(
2215
+ `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.`,
2216
+ ERROR_REASON.USAGE,
2217
+ );
1757
2218
  }
1758
-
2219
+ const { runtimeId, isolation, exec, harnessFlag } = resolveDispatchIsolationDecision({ args, cwd });
1759
2220
  if (args.indexOf('--json') !== -1) {
1760
2221
  output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
1761
2222
  } else {
@@ -1906,6 +2367,50 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1906
2367
  }
1907
2368
  }
1908
2369
 
2370
+ function routeResolveAgent({ args, cwd, raw, error }) {
2371
+ // #1689: resolve a per-plan agent_hint specialist name to the subagent_type
2372
+ // an Agent() call should use. Returns the name unchanged when a
2373
+ // matching agent file exists in the active runtime's agent dir(s);
2374
+ // 'gsd-executor' when the name is absent, blank, or does not resolve.
2375
+ // Fail-closed is the fallback (gsd-executor) — never echo an
2376
+ // unvalidated name, which would make Agent() error and block the wave.
2377
+ //
2378
+ // Output:
2379
+ // --raw (default) -> prints the resolved type (the hint, or 'gsd-executor')
2380
+ // --json -> prints { runtime, requested, resolved, fallback }
2381
+ const FALLBACK = 'gsd-executor';
2382
+ try {
2383
+ const nameIdx = args.indexOf('--name');
2384
+ const requested = nameIdx !== -1 ? args[nameIdx + 1] : '';
2385
+ const { resolveRuntime } = require('./lib/runtime-slash.cjs');
2386
+ const runtimeId = resolveRuntime(cwd);
2387
+ const { resolveAgentHint } = require('./lib/agent-install-check.cjs');
2388
+ let resolved = FALLBACK;
2389
+ let resolvedOk = false; // true only when resolveAgentHint returned a hit
2390
+ if (requested && !requested.startsWith('-')) {
2391
+ const hit = resolveAgentHint(requested, runtimeId, cwd);
2392
+ if (hit !== null) {
2393
+ resolved = hit;
2394
+ resolvedOk = true;
2395
+ }
2396
+ }
2397
+ // `fallback` = we did NOT honor a resolvable hint (absent/flag-shaped name,
2398
+ // the named agent did not resolve, or resolution errored). Requesting
2399
+ // gsd-executor explicitly and resolving to it is NOT a fallback.
2400
+ const fellBack = !resolvedOk;
2401
+ const jsonIdx = args.indexOf('--json');
2402
+ if (jsonIdx !== -1) {
2403
+ output({ runtime: runtimeId, requested: requested || null, resolved, fallback: fellBack }, raw);
2404
+ } else {
2405
+ process.stdout.write(String(resolved));
2406
+ }
2407
+ } catch {
2408
+ // Fail-closed: degrade to the legacy executor on any error so dispatch
2409
+ // never blocks on resolution.
2410
+ process.stdout.write(FALLBACK);
2411
+ }
2412
+ }
2413
+
1909
2414
  function routeAgentSkills({ args, cwd, raw, error }) {
1910
2415
  // --json emits typed IR { agent_type, block, skills_count } for test assertions
1911
2416
  // (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
@@ -1962,8 +2467,19 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1962
2467
  .filter((t) => t.length > 0);
1963
2468
  termsOverride = list.length > 0 ? { pluralization: list } : undefined;
1964
2469
  }
2470
+ // An unresolvable phase section is not a negative verdict. Feeding
2471
+ // `''` to the detector reported "examined, found nothing" for a
2472
+ // probe that never had input — no ROADMAP.md, or a phase number
2473
+ // absent from it, both read as a confident `detected:false`
2474
+ // (ADR-3889 failure class (c), #3909). Exit stays 0: this is an
2475
+ // ADR-2980 degraded result carried in the payload, and ADR-3889 P8
2476
+ // pins the gsd-tools exit projection at v1.
1965
2477
  const section = roadmap.getRoadmapPhaseWithFallback(cwd, phaseNum);
1966
- const result = detectAssumptionDelta(section ?? '', termsOverride);
2478
+ if (typeof section !== 'string' || section.trim() === '') {
2479
+ output({ skipped: true, reason: 'phase_unresolved' }, raw);
2480
+ return;
2481
+ }
2482
+ const result = detectAssumptionDelta(section, termsOverride);
1967
2483
  output(result, raw);
1968
2484
  return;
1969
2485
  }
@@ -2006,9 +2522,24 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2006
2522
  const force = args.includes('--force');
2007
2523
  // #2118: --dry-run prints a preview plan without mutating.
2008
2524
  const dryRun = args.includes('--dry-run');
2009
- milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun }, raw);
2525
+ // #2142: quick-task archival is opt-in (default OFF) — unlike
2526
+ // --no-archive-phases' inverted shape, absence of this flag means
2527
+ // "do nothing" rather than "skip a default-on behavior".
2528
+ const archiveQuick = args.includes('--archive-quick');
2529
+ // #3726: explicit mutation opt-in — without --confirm (and without
2530
+ // --dry-run) the command refuses before touching anything. Distinct
2531
+ // from --force, which bypasses the narrow scope guards only.
2532
+ const confirm = args.includes('--confirm');
2533
+ milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick, confirm }, raw);
2534
+ } else if (subcommand === 'archive-quick') {
2535
+ // #2142 escalation: narrow archival-only entry point (does NOT
2536
+ // touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion
2537
+ // guards) — safe to call against an already-completed milestone,
2538
+ // unlike `milestone complete --archive-quick`.
2539
+ const dryRun = args.includes('--dry-run');
2540
+ milestone.cmdQuickArchive(cwd, args[2], { dryRun }, raw);
2010
2541
  } else {
2011
- error('Unknown milestone subcommand. Available: complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2542
+ error('Unknown milestone subcommand. Available: complete, archive-quick', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2012
2543
  }
2013
2544
  }
2014
2545
 
@@ -2021,11 +2552,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2021
2552
  const subcommand = args[1];
2022
2553
  if (subcommand === 'render-checkpoint') {
2023
2554
  const uat = require('./lib/uat.cjs');
2024
- const options = parseNamedArgs(args, ['file']);
2555
+ const options = parseNamedArgsOrExit(args, { valueFlags: ['file'], positionals: 2 }, error);
2025
2556
  uat.cmdRenderCheckpoint(cwd, options, raw);
2026
2557
  } else if (subcommand === 'classify-coverage') {
2027
2558
  const coverage = require('./lib/coverage.cjs');
2028
- const options = parseNamedArgs(args, ['summary', 'file']);
2559
+ const options = parseNamedArgsOrExit(args, { valueFlags: ['summary', 'file'], positionals: 2 }, error);
2029
2560
  coverage.cmdClassify(cwd, options, raw);
2030
2561
  } else {
2031
2562
  error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -2050,8 +2581,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2050
2581
 
2051
2582
  function routeScaffold({ args, cwd, raw, error }) {
2052
2583
  const scaffoldType = args[1];
2584
+ // `--name` is multi-word (consumed separately by parseMultiwordArg,
2585
+ // below) — a token count the single-token-per-flag boundary walk
2586
+ // cannot represent. `positionals: 'rest'` disables that walk for
2587
+ // this call, matching the existing (unchanged) permissive behavior
2588
+ // for --name; --phase extraction is unaffected either way.
2053
2589
  const scaffoldOptions = {
2054
- phase: parseNamedArgs(args, ['phase']).phase,
2590
+ phase: parseNamedArgsOrExit(args, { valueFlags: ['phase'], positionals: 'rest' }, error).phase,
2055
2591
  name: parseMultiwordArg(args, 'name'),
2056
2592
  };
2057
2593
  commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
@@ -2255,9 +2791,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2255
2791
  );
2256
2792
  }
2257
2793
  } catch (e) {
2794
+ // ADR-3889: error() now throws ExitError instead of calling
2795
+ // process.exit(1) directly, so an ExitError raised by error() INSIDE
2796
+ // this try (e.g. the "Unknown windows subcommand" call above, or one
2797
+ // inside cmdWindowsStatus/Append/Waive/MarkFixed) lands HERE instead of
2798
+ // terminating uncatchably. It must be re-thrown unconditionally, before
2799
+ // the WindowsError name check below, or it falls through to the
2800
+ // generic branch and gets re-wrapped with a wrong message/reason,
2801
+ // discarding the original exit code.
2802
+ if (e instanceof ExitError) throw e;
2258
2803
  // WindowsError carries a REASON code; surface it through the structured
2259
- // error path so tests can assert on the typed reason. `error()` calls
2260
- // process.exit(1) internally so we never reach the fall-through.
2804
+ // error path so tests can assert on the typed reason.
2261
2805
  if (e && e.name === 'WindowsError' && typeof e.reason === 'string') {
2262
2806
  error(e.message || 'broken-windows error', e.reason);
2263
2807
  }
@@ -3283,13 +3827,189 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3283
3827
  return;
3284
3828
  }
3285
3829
 
3830
+ if (subcommand === 'phase-status') {
3831
+ // #1956: deterministic STATE.md-vs-ROADMAP.md phase-status drift.
3832
+ const { planningDir } = require('./lib/planning-workspace.cjs');
3833
+ const { stateExtractField, stateCurrentPositionSlice } = require('./lib/state-document.cjs');
3834
+ const { findRoadmapProgressTable } = require('./lib/roadmap-parser.cjs');
3835
+ const { phaseKeyFromProse } = require('./lib/phase-id.cjs');
3836
+ // STATE.md's YAML frontmatter carries its own lowercase `status:`
3837
+ // scalar ahead of the body's `## Current Position` prose "Status:"
3838
+ // line; stateExtractField's non-scoped regex would otherwise match
3839
+ // that frontmatter line first (it comes first in the file) and
3840
+ // silently report the wrong value. Strip frontmatter so extraction
3841
+ // is scoped to the body.
3842
+ const { stripFrontmatter } = require('./lib/frontmatter.cjs');
3843
+
3844
+ const phaseIdx = args.indexOf('--phase');
3845
+ const phaseArg = (phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--'))
3846
+ ? args[phaseIdx + 1]
3847
+ : undefined;
3848
+
3849
+ const dir = planningDir(cwd);
3850
+ const statePath = path.join(dir, 'STATE.md');
3851
+ const roadmapPath = path.join(dir, 'ROADMAP.md');
3852
+
3853
+ let stateContent = null;
3854
+ try {
3855
+ stateContent = fs.readFileSync(statePath, 'utf-8');
3856
+ } catch {
3857
+ // missing_state below
3858
+ }
3859
+ if (stateContent === null) {
3860
+ const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
3861
+ output({
3862
+ verdict: 'uncheckable',
3863
+ reason: 'missing_state',
3864
+ phase,
3865
+ stateStatus: null,
3866
+ roadmapStatus: null,
3867
+ authority: 'STATE.md',
3868
+ }, raw);
3869
+ return;
3870
+ }
3871
+
3872
+ let roadmapContent = null;
3873
+ try {
3874
+ roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
3875
+ } catch {
3876
+ // missing_roadmap below
3877
+ }
3878
+
3879
+ const stateBody = stripFrontmatter(stateContent);
3880
+ // #1956 fix: scope extraction to `## Current Position` (or `###`
3881
+ // in the bootstrap template) so a historical `Phase:` / `Status:`
3882
+ // line in an archive section (e.g. `## Session Continuity
3883
+ // Archive`) can't shadow the real one — same #2956 scope state.cts
3884
+ // uses for current_phase, via the shared owner in
3885
+ // state-document.cjs.
3886
+ //
3887
+ // Deliberately NO whole-body fallback here. `state.cts`'s WRITE
3888
+ // path falls back to the whole body when no Current Position
3889
+ // heading is found (legacy behavior it must preserve for
3890
+ // backward-compatible writes) — but that fallback is wrong for a
3891
+ // READ that feeds a drift finding: a STATE.md with no Current
3892
+ // Position heading is exactly the shape that let a stray historical
3893
+ // `Status:` line elsewhere in the body shadow the real value and
3894
+ // fabricate a 'drifted' verdict. A guess is worse than an
3895
+ // abstention for a drift detector, so an absent Current Position
3896
+ // section reports 'uncheckable' instead of guessing from the whole
3897
+ // document.
3898
+ const currentPositionBody = stateCurrentPositionSlice(stateBody);
3899
+ if (currentPositionBody === null) {
3900
+ const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
3901
+ output({
3902
+ verdict: 'uncheckable',
3903
+ reason: 'no_current_position',
3904
+ phase,
3905
+ stateStatus: null,
3906
+ roadmapStatus: null,
3907
+ authority: 'STATE.md',
3908
+ }, raw);
3909
+ return;
3910
+ }
3911
+
3912
+ // Resolve the target phase: --phase if given, else whatever
3913
+ // STATE.md's Current Position reports as current.
3914
+ const phase = phaseArg !== undefined
3915
+ ? phaseKeyFromProse(phaseArg)
3916
+ : phaseKeyFromProse(stateExtractField(currentPositionBody, 'Phase'));
3917
+
3918
+ if (roadmapContent === null) {
3919
+ output({
3920
+ verdict: 'uncheckable',
3921
+ reason: 'missing_roadmap',
3922
+ phase,
3923
+ stateStatus: null,
3924
+ roadmapStatus: null,
3925
+ authority: 'STATE.md',
3926
+ }, raw);
3927
+ return;
3928
+ }
3929
+
3930
+ const stateStatus = stateExtractField(currentPositionBody, 'Status');
3931
+
3932
+ // #1956/#2012: scoped to `## Progress` first (decoy-avoidance) —
3933
+ // see findRoadmapProgressTable's doc comment (roadmap-parser.cts).
3934
+ const table = findRoadmapProgressTable(roadmapContent);
3935
+ const matchedRow = table
3936
+ ? table.rows.find((row) => phaseKeyFromProse(row.Phase) === phase && phase !== null)
3937
+ : undefined;
3938
+
3939
+ if (!matchedRow) {
3940
+ const result = comparePhaseStatus({ stateStatus, roadmapStatus: null });
3941
+ output({
3942
+ verdict: 'uncheckable',
3943
+ reason: 'phase_not_in_roadmap',
3944
+ phase,
3945
+ stateStatus,
3946
+ roadmapStatus: null,
3947
+ stateRank: result.stateRank,
3948
+ roadmapRank: result.roadmapRank,
3949
+ authority: 'STATE.md',
3950
+ }, raw);
3951
+ return;
3952
+ }
3953
+
3954
+ const roadmapStatus = matchedRow.Status;
3955
+ const result = comparePhaseStatus({ stateStatus, roadmapStatus });
3956
+ output({
3957
+ verdict: result.verdict,
3958
+ phase,
3959
+ stateStatus,
3960
+ roadmapStatus,
3961
+ stateRank: result.stateRank,
3962
+ roadmapRank: result.roadmapRank,
3963
+ authority: 'STATE.md',
3964
+ }, raw);
3965
+ return;
3966
+ }
3967
+
3286
3968
  error(
3287
- `Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity`,
3969
+ `Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity, phase-status`,
3288
3970
  ERROR_REASON.SDK_UNKNOWN_COMMAND,
3289
3971
  );
3290
3972
  }
3291
3973
 
3292
3974
 
3975
+ /**
3976
+ * #3275: resolve a DECLARED command name to the file a spawn can actually start.
3977
+ *
3978
+ * Lane descriptors (src/review-lane-descriptor.cts) declare BARE, platform-unaware
3979
+ * binary names ('codex', 'gemini', 'kimi', 'agy'), and `review-lane invoke`'s
3980
+ * `deps.spawn` + `deps.hasBinary` both need the on-disk form of that name. Before
3981
+ * this helper existed they disagreed: `hasBinary` scanned PATH WITH PATHEXT (so
3982
+ * probes reported lanes AVAILABLE on Windows) while `spawn` received the bare name
3983
+ * — `CreateProcess` performs no PATHEXT resolution, so every spawn-transport lane
3984
+ * ENOENT'd there, and the #3086 `.cmd`/`.bat` cmd.exe mediation gate never fired
3985
+ * because the declared name never carried an extension. One shared resolver is the
3986
+ * only shape that cannot drift back apart.
3987
+ *
3988
+ * win32: tries PATHEXT entries ONLY — never the bare name. npm global installs
3989
+ * drop an EXTENSIONLESS POSIX sh shim (`...\npm\codex`) next to `codex.CMD`; a
3990
+ * bare-name-first scan resolves to it, the mediation gate sees no `.cmd`, and the
3991
+ * ENOENT returns unchanged (field-reported on Windows 11 — see the #3275 issue
3992
+ * comment pinning exactly this pitfall).
3993
+ *
3994
+ * POSIX: answers EXISTENCE only (the old `hasBinary` contract, preserved
3995
+ * byte-for-byte) by scanning PATH for the bare name. `deps.spawn` does NOT consult
3996
+ * this on POSIX — the bare name goes to spawnSync unchanged and Node's own PATH
3997
+ * search does the work, so macOS/Linux behavior is untouched (#3275 acceptance).
3998
+ *
3999
+ * Path-like names (any '/' or '\') bypass the PATH scan: the name is already an
4000
+ * address, so it passes through when the file exists and is a file.
4001
+ *
4002
+ * #3411: the scan itself now lives in the declared platform seam
4003
+ * (`src/shell-command-projection.cts` → `resolveExecutableBinary`). This function is
4004
+ * the `bin/` entry point onto it and holds no copy of the logic — `CONTEXT.md`
4005
+ * declares that file "All OS-facing I/O; single platform seam", and a private
4006
+ * duplicate here is what made it untrue.
4007
+ */
4008
+ function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
4009
+ const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
4010
+ return resolveExecutableBinary(name, { platform, env });
4011
+ }
4012
+
3293
4013
  const HOST_COMMAND_ROUTERS = {
3294
4014
  // Each entry wraps its `route*Command` router so it receives the module-scope
3295
4015
  // lib the old `case` arm passed, plus the per-dispatch context
@@ -3339,6 +4059,7 @@ const HOST_COMMAND_ROUTERS = {
3339
4059
  'find-phase': routeFindPhase,
3340
4060
  'commit': routeCommit,
3341
4061
  'check-commit': routeCheckCommit,
4062
+ 'commit-docs-guard': routeCommitDocsGuard,
3342
4063
  'commit-to-subrepo': routeCommitToSubrepo,
3343
4064
  'pr-subrepo': routePrSubrepo,
3344
4065
  'verify-summary': routeVerifySummary,
@@ -3349,6 +4070,7 @@ const HOST_COMMAND_ROUTERS = {
3349
4070
  'verification': routeVerification,
3350
4071
  'generate-slug': routeGenerateSlug,
3351
4072
  'current-timestamp': routeCurrentTimestamp,
4073
+ 'runtime-identity': routeRuntimeIdentity,
3352
4074
  'project-instruction-file': routeProjectInstructionFile,
3353
4075
  'list-todos': routeListTodos,
3354
4076
  'list-seeds': routeListSeeds,
@@ -3357,12 +4079,18 @@ const HOST_COMMAND_ROUTERS = {
3357
4079
  'normalize-test-command': routeNormalizeTestCommand,
3358
4080
  'dispatch-should-flatten': routeDispatchShouldFlatten,
3359
4081
  'dispatch-isolation': routeDispatchIsolation,
4082
+ 'inspect-dispatch-isolation': routeInspectDispatchIsolation,
3360
4083
  'record-dispatch-isolation': routeRecordDispatchIsolation,
3361
4084
  'resolve-dispatch-type': routeResolveDispatchType,
4085
+ 'resolve-agent': routeResolveAgent,
3362
4086
  'agent-skills': routeAgentSkills,
3363
4087
  'skill-manifest': routeSkillManifest,
3364
4088
  'history-digest': routeHistoryDigest,
3365
4089
  'phases': routePhases,
4090
+ // #2790: read-only schema-v1 planning snapshot. The router imports its own
4091
+ // io/planning-inspect deps, so it needs no module injection — it receives
4092
+ // { args, cwd, raw, error } and ignores the rest of the dispatch context.
4093
+ 'planning': routePlanningCommand,
3366
4094
  'assumption-delta': routeAssumptionDelta,
3367
4095
  'requirements': routeRequirements,
3368
4096
  'gap-analysis': routeGapAnalysis,
@@ -3603,23 +4331,25 @@ function runWithTimeout(argv) {
3603
4331
  // this string and HOST_COMMAND_ROUTERS/SKIP_ROOT_RESOLUTION are three
3604
4332
  // independently hand-maintained sites and nothing previously caught them
3605
4333
  // drifting apart when a query command was added to only one or two.
3606
- const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
3607
- 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
4334
+ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--project-dir <path>] [--ws <name>] [--json-errors] [--exit-contract=<v>]\n' +
4335
+ 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-docs-guard, commit-to-subrepo, pr-subrepo, ' +
3608
4336
  'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
3609
4337
  'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
3610
4338
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
3611
4339
  'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
3612
- 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
3613
- '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, ' +
3614
- 'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-dispatch-type, ' +
4340
+ 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, planning, profile-questionnaire, ' +
4341
+ 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, runtime-identity, scaffold, smart-entry, state, ' +
4342
+ '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, ' +
3615
4343
  'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
3616
4344
  'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
3617
4345
  'Global flags:\n' +
3618
4346
  ' --raw Emit raw output without post-processing\n' +
3619
4347
  ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
3620
4348
  ' --cwd <path> Override working directory for project-root resolution\n' +
4349
+ ' --project-dir <path> Explicit project root; skips the ancestor walk-up entirely (must already contain .planning/)\n' +
3621
4350
  ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
3622
- ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
4351
+ ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n' +
4352
+ ' --exit-contract=<v> Exit-code contract version: v1 (default) or v2 (or set GSD_EXIT_CONTRACT)\n\n' +
3623
4353
  'For command-specific argument requirements, invoke the command without args ' +
3624
4354
  '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
3625
4355
 
@@ -3638,6 +4368,10 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <fiel
3638
4368
  // below (never as the live Set itself; see that function's doc comment).
3639
4369
  const SKIP_ROOT_RESOLUTION = new Set([
3640
4370
  'generate-slug', 'current-timestamp', 'verify-path-exists',
4371
+ // #3146: runtime-identity is a pure local read of baked package coordinates.
4372
+ // It is probed from whatever cwd a workflow happens to be in — including
4373
+ // outside any project — so it must never require a resolvable project root.
4374
+ 'runtime-identity',
3641
4375
  // #2844: verify-summary was previously skipped, leaving relative file-claim
3642
4376
  // paths resolved against the raw process.cwd() — invoking from a subdirectory
3643
4377
  // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes
@@ -3709,18 +4443,19 @@ function resolveMainWorktreeCwd(cwd, deps = {}) {
3709
4443
  async function main() {
3710
4444
  let args = process.argv.slice(2);
3711
4445
 
3712
- // #2351: run-with-timeout bounds a spawned command's wall clock portably
3713
- // (coreutils-independent). It MUST intercept HERE, before the global-flag
3714
- // parsing below — the wrapped command's argv is opaque and may itself contain
3715
- // --raw / --cwd / --pick that this dispatcher would otherwise consume.
3716
- {
3717
- let rwt = args;
3718
- if (rwt[0] === 'query') rwt = rwt.slice(1);
3719
- if (rwt[0] === 'run-with-timeout') {
3720
- // Return the child's exit code; runMain() maps it to process.exitCode.
3721
- return runWithTimeout(rwt.slice(1));
3722
- }
3723
- }
4446
+ // These two global-flag blocks (--json-errors, --exit-contract) MUST run
4447
+ // BEFORE the run-with-timeout interception below. run-with-timeout treats
4448
+ // args[0] (post `query` stripping) as the sentinel and otherwise passes the
4449
+ // remaining argv straight to the wrapped child — it never reaches the
4450
+ // dispatcher's "Unknown command" fallback, but a global flag left in LEADING
4451
+ // position (e.g. `--exit-contract=v2 run-with-timeout ...`) would be spliced
4452
+ // out too late if these ran after, since neither block currently exists
4453
+ // below this point to consume it. Splicing here, before run-with-timeout's
4454
+ // own argv slicing, is what keeps both flags position-independent for every
4455
+ // command, run-with-timeout included. Do not move these back below the
4456
+ // run-with-timeout block (#confirmed regression: leading --exit-contract=v2
4457
+ // and leading --json-errors both broke run-with-timeout when these blocks
4458
+ // sat after it).
3724
4459
 
3725
4460
  // --json-errors / GSD_JSON_ERRORS=1: when active, error() emits structured
3726
4461
  // JSON ({ ok: false, reason: <ERROR_REASON code>, message }) to stderr
@@ -3740,6 +4475,41 @@ async function main() {
3740
4475
  setJsonErrorMode(true);
3741
4476
  }
3742
4477
 
4478
+ // --exit-contract=<v> / GSD_EXIT_CONTRACT: resolve FIRST, before the splice
4479
+ // below, so an invalid value (e.g. `v3`, or an empty `--exit-contract=`)
4480
+ // throws EARLY — matching the --json-errors block's own "detect early,
4481
+ // before any flag parsing that can fire error()" rationale above. This also
4482
+ // memoizes the resolved version into the shared contract-version cell so a
4483
+ // later terminateNow()/runMain() call projects against it correctly.
4484
+ //
4485
+ // The argv splice must happen here too, otherwise the dispatcher below sees
4486
+ // "--exit-contract=<v>" as an unknown command when the flag is given in
4487
+ // LEADING position (argv[0] is what the dispatcher treats as the command
4488
+ // name). Splice EVERY occurrence, not just the first — findExitContractFlag
4489
+ // only consults the first match, so a stray second token would otherwise
4490
+ // survive into the dispatcher and reproduce the same "Unknown command".
4491
+ resolveContractVersion({ argv: process.argv, env: process.env });
4492
+ for (let i = args.length - 1; i >= 0; i--) {
4493
+ if (typeof args[i] === 'string' && args[i].startsWith('--exit-contract=')) {
4494
+ args.splice(i, 1);
4495
+ }
4496
+ }
4497
+
4498
+ // #2351: run-with-timeout bounds a spawned command's wall clock portably
4499
+ // (coreutils-independent). It MUST intercept HERE, before the remaining
4500
+ // flag parsing below — the wrapped command's argv is opaque and may itself
4501
+ // contain --raw / --cwd / --pick that this dispatcher would otherwise
4502
+ // consume. (--json-errors / --exit-contract are handled above this block,
4503
+ // not below, precisely so they keep working with run-with-timeout.)
4504
+ {
4505
+ let rwt = args;
4506
+ if (rwt[0] === 'query') rwt = rwt.slice(1);
4507
+ if (rwt[0] === 'run-with-timeout') {
4508
+ // Return the child's exit code; runMain() maps it to process.exitCode.
4509
+ return runWithTimeout(rwt.slice(1));
4510
+ }
4511
+ }
4512
+
3743
4513
  // Optional cwd override for sandboxed subagents running outside project root.
3744
4514
  let cwd = process.cwd();
3745
4515
  const cwdEqArg = args.find(arg => arg.startsWith('--cwd='));
@@ -3760,6 +4530,39 @@ async function main() {
3760
4530
  error(`Invalid --cwd: ${cwd}`, ERROR_REASON.USAGE);
3761
4531
  }
3762
4532
 
4533
+ // #3881: --project-dir <path> is a documented (docs/CONFIGURATION.md,
4534
+ // "Project-Root Resolution in Multi-Repo Workspaces") explicit override of
4535
+ // the project root. It is idempotent under findProjectRoot's ancestor
4536
+ // walk-up — i.e. it short-circuits the walk-up rather than seeding it —
4537
+ // so it MUST be validated and applied here, before findProjectRoot ever
4538
+ // runs, and its result must skip that call entirely below. A relative
4539
+ // value resolves against process.cwd(), matching --cwd's own resolution.
4540
+ let projectDirExplicit = false;
4541
+ const projectDirEqArg = args.find(arg => arg.startsWith('--project-dir='));
4542
+ const projectDirIdx = args.indexOf('--project-dir');
4543
+ let projectDirValue;
4544
+ if (projectDirEqArg) {
4545
+ projectDirValue = projectDirEqArg.slice('--project-dir='.length).trim();
4546
+ if (!projectDirValue) error('Missing value for --project-dir', ERROR_REASON.USAGE);
4547
+ args.splice(args.indexOf(projectDirEqArg), 1);
4548
+ } else if (projectDirIdx !== -1) {
4549
+ projectDirValue = args[projectDirIdx + 1];
4550
+ if (!projectDirValue || projectDirValue.startsWith('--')) error('Missing value for --project-dir', ERROR_REASON.USAGE);
4551
+ args.splice(projectDirIdx, 2);
4552
+ }
4553
+ if (projectDirValue !== undefined) {
4554
+ const resolvedProjectDir = path.resolve(projectDirValue);
4555
+ if (!fs.existsSync(resolvedProjectDir) || !fs.statSync(resolvedProjectDir).isDirectory()) {
4556
+ error(`Invalid --project-dir: ${resolvedProjectDir} (path does not exist or is not a directory)`, ERROR_REASON.USAGE);
4557
+ }
4558
+ const resolvedProjectDirPlanning = path.join(resolvedProjectDir, '.planning');
4559
+ if (!fs.existsSync(resolvedProjectDirPlanning) || !fs.statSync(resolvedProjectDirPlanning).isDirectory()) {
4560
+ error(`Invalid --project-dir: ${resolvedProjectDir} (no .planning/ directory found — --project-dir must name the project root itself, not an ancestor to walk up from)`, ERROR_REASON.USAGE);
4561
+ }
4562
+ cwd = resolvedProjectDir;
4563
+ projectDirExplicit = true;
4564
+ }
4565
+
3763
4566
  // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
3764
4567
  // However, in monorepo worktrees where the subdirectory itself owns .planning/,
3765
4568
  // skip worktree resolution — the CWD is already the correct project root.
@@ -3769,8 +4572,21 @@ async function main() {
3769
4572
  // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
3770
4573
  let workstreamContext = null;
3771
4574
  try {
4575
+ // #3579 root-cause fix: this bootstrap resolution only decides whether to
4576
+ // populate GSD_WORKSTREAM env for downstream routing — it is a check, not
4577
+ // the consuming read. Using the mutating getActiveWorkstream here
4578
+ // self-healed (cleared) a present-but-unresolvable pointer BEFORE the
4579
+ // dispatched command's own resolution/diagnostic ran, so a second read in
4580
+ // the same process (e.g. a subcommand's own getActiveWorkstream call, or
4581
+ // a fail-safe guard's diagnoseUnresolvedActiveWorkstream) observed
4582
+ // already-cleared state — silently falling through to a fallback marker
4583
+ // it should never have inherited (isolation violation), or losing the
4584
+ // evidence a diagnostic needed to explain why nothing resolved. peek
4585
+ // shares the identical resolution logic and only differs by never
4586
+ // calling adapter.clear(); self-heal still happens, exactly once, at
4587
+ // whichever call site actually consumes the workstream for real.
3772
4588
  workstreamContext = resolveActiveWorkstream(cwd, args, process.env, {
3773
- getStored: getActiveWorkstream,
4589
+ getStored: peekActiveWorkstream,
3774
4590
  });
3775
4591
  args = workstreamContext.args;
3776
4592
  // Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths.
@@ -3859,24 +4675,45 @@ async function main() {
3859
4675
  }
3860
4676
  }
3861
4677
 
3862
- if (!SKIP_ROOT_RESOLUTION.has(command)) {
4678
+ // #3881: an explicit --project-dir already IS the resolved project root
4679
+ // (validated above) — findProjectRoot's ancestor walk-up must not run
4680
+ // over it, per docs/CONFIGURATION.md's documented idempotence.
4681
+ if (!projectDirExplicit && !SKIP_ROOT_RESOLUTION.has(command)) {
3863
4682
  cwd = findProjectRoot(cwd);
3864
4683
  }
3865
4684
 
3866
4685
  // When --pick is active, capture stdout and extract the requested field.
4686
+ // ADR-3473 §8.4 (#3365, #3358): an absent field or non-JSON command output
4687
+ // is a failure ("I could not answer"), never a demotion to an empty answer
4688
+ // at exit 0. `resolveAtFileOutput` MUST run before JSON.parse — @file:
4689
+ // payloads (io.cjs output() writes these for JSON > 50KB) are not
4690
+ // themselves JSON text, so resolving late would make every large result a
4691
+ // false "output was not JSON" (negative space N8).
3867
4692
  if (pickField) {
3868
4693
  const captured = await captureStdoutSyncWrites(async () => {
3869
4694
  await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
3870
4695
  });
3871
4696
  const resolved = resolveAtFileOutput(captured);
4697
+ let obj;
3872
4698
  try {
3873
- const obj = JSON.parse(resolved);
3874
- const value = extractField(obj, pickField);
3875
- const result = value === null || value === undefined ? '' : String(value);
3876
- fs.writeSync(1, result);
4699
+ obj = JSON.parse(resolved);
3877
4700
  } catch {
3878
- fs.writeSync(1, captured);
4701
+ error(`--pick ${formatDiagnosticToken(pickField)}: command output was not JSON`, ERROR_REASON.PICK_OUTPUT_NOT_JSON);
4702
+ return;
4703
+ }
4704
+ const { found, value } = extractField(obj, pickField);
4705
+ if (!found) {
4706
+ const rootDescription = isPlainRecord(obj)
4707
+ ? `available top-level keys: ${Object.keys(obj).map(formatKeyForDiagnosticList).join(', ') || '(none)'}`
4708
+ : `the command's output is a JSON ${describeJsonRootType(obj)}, not an object with that field`;
4709
+ error(`--pick ${formatDiagnosticToken(pickField)}: field not found; ${rootDescription}`, ERROR_REASON.PICK_FIELD_ABSENT);
4710
+ return;
3879
4711
  }
4712
+ // N1/N2: `null` and `''` are answers, not failures — an absent field
4713
+ // above already exited non-zero, so reaching here means the field EXISTS
4714
+ // and this is its real value (including `0` and `false`, #3365).
4715
+ const result = value === null || value === undefined ? '' : String(value);
4716
+ fs.writeSync(1, result);
3880
4717
  return;
3881
4718
  }
3882
4719
 
@@ -3938,27 +4775,68 @@ function resolveAtFileOutput(captured) {
3938
4775
  return fs.readFileSync(captured.slice(6), 'utf-8');
3939
4776
  }
3940
4777
 
4778
+ // A plain object root/intermediate value — everything else (null, an array,
4779
+ // a number, a string, a boolean) is treated as non-object for NAMED-key
4780
+ // lookup purposes (#3365 / #3358, ADR-3473 §8.4): only bracket notation may
4781
+ // reach into an array.
4782
+ function isPlainRecord(v) {
4783
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
4784
+ }
4785
+
4786
+ // Describes the JSON root's shape for a --pick "field not found" message
4787
+ // when the root is NOT a plain object (so listing "top-level keys" would be
4788
+ // meaningless).
4789
+ function describeJsonRootType(v) {
4790
+ if (Array.isArray(v)) return 'array';
4791
+ if (v === null) return 'null';
4792
+ return typeof v;
4793
+ }
4794
+
4795
+ // A command's JSON output can be a USER-authored document (e.g. `frontmatter
4796
+ // get`), so its top-level keys are untrusted the same way an argv token is.
4797
+ // `formatDiagnosticToken` (io.cjs) is the shared escape (see its JSDoc for
4798
+ // why `error()` cannot do this itself); this thin wrapper reuses that exact
4799
+ // escaping but strips the surrounding quotes JSON.stringify adds, so a key
4800
+ // list reads as "a, b, c" rather than the noisier "\"a\", \"b\", \"c\"" while
4801
+ // a key containing \n/\r/\t/other C0 bytes still cannot forge a second
4802
+ // stderr "Error:" line or span more than one line.
4803
+ function formatKeyForDiagnosticList(key) {
4804
+ return formatDiagnosticToken(key).slice(1, -1);
4805
+ }
4806
+
3941
4807
  /**
3942
4808
  * Extract a field from an object using dot-notation and bracket syntax.
3943
4809
  * Supports: 'field', 'parent.child', 'arr[-1]', 'arr[0]'
4810
+ *
4811
+ * Returns a discriminated `{ found, value }` rather than a bare value so a
4812
+ * caller can distinguish "the field exists and is null/''/0/false" (an
4813
+ * ANSWER, exit 0) from "no such field" (an absence, exit non-zero) — #3365.
4814
+ * Reports NOT-FOUND for: a missing key; a dotted path that dies partway; an
4815
+ * array index out of range (after negative-index normalization); a bracket
4816
+ * applied to a non-array; and any key lookup against a non-object (null, a
4817
+ * number, a string, a boolean, or an array root).
3944
4818
  */
3945
4819
  function extractField(obj, fieldPath) {
3946
4820
  const parts = fieldPath.split('.');
3947
4821
  let current = obj;
3948
4822
  for (const part of parts) {
3949
- if (current === null || current === undefined) return undefined;
3950
4823
  const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/);
3951
4824
  if (bracketMatch) {
3952
4825
  const key = bracketMatch[1];
3953
4826
  const index = parseInt(bracketMatch[2], 10);
3954
- current = current[key];
3955
- if (!Array.isArray(current)) return undefined;
3956
- current = index < 0 ? current[current.length + index] : current[index];
4827
+ if (!isPlainRecord(current)) return { found: false, value: undefined };
4828
+ const arr = current[key];
4829
+ if (!Array.isArray(arr)) return { found: false, value: undefined };
4830
+ const resolvedIndex = index < 0 ? arr.length + index : index;
4831
+ if (resolvedIndex < 0 || resolvedIndex >= arr.length) return { found: false, value: undefined };
4832
+ current = arr[resolvedIndex];
3957
4833
  } else {
4834
+ if (!isPlainRecord(current)) return { found: false, value: undefined };
4835
+ if (!Object.prototype.hasOwnProperty.call(current, part)) return { found: false, value: undefined };
3958
4836
  current = current[part];
3959
4837
  }
3960
4838
  }
3961
- return current;
4839
+ return { found: true, value: current };
3962
4840
  }
3963
4841
 
3964
4842
  async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
@@ -4024,5 +4902,24 @@ module.exports = {
4024
4902
  TOP_LEVEL_USAGE,
4025
4903
  skipsRootResolution,
4026
4904
  resolveMainWorktreeCwd,
4905
+ // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
4906
+ // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
4907
+ resolveSpawnBinary,
4908
+ // #3714 follow-up: exported for tests — the dispatch model-pin VALUE
4909
+ // policy (charset accept/render parity, max-length boundary, leading-char
4910
+ // anchor) is otherwise unreachable from outside the dispatchOverlayCapabilityCommand closure.
4911
+ resolveDispatchModelPin,
4912
+ MODEL_ID_CHARSET_RE,
4913
+ // The shared character-class body both MODEL_ID_CHARSET_RE and
4914
+ // MODEL_ID_SANITIZE_STRIP_RE are derived from — exported so a test can
4915
+ // assert its own expected charset literal EQUALS this value, making a
4916
+ // silent widening of the production body fail the test instead of only
4917
+ // the (unexported) regexes built from it.
4918
+ MODEL_ID_CHARSET_BODY,
4919
+ // Non-global companion of the internal g-flagged sanitize regex — see the
4920
+ // comment at its definition for why the g-flagged instance is never
4921
+ // exported.
4922
+ MODEL_ID_SANITIZE_STRIP_RE,
4923
+ MODEL_ID_MAX_LENGTH,
4027
4924
  };
4028
4925