@opengsd/gsd-core 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -68,7 +68,12 @@
68
68
  * gaps_found-only, never call on the pass path
69
69
  *
70
70
  * Milestone Operations:
71
- * 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)
72
77
  * [--name <name>]
73
78
  * [--no-archive-phases] Skip moving phase dirs to milestones/vX.Y-phases/ (archived by default)
74
79
  * [--archive-quick] Move .planning/quick/* dirs to milestones/vX.Y-quick/ + reset the
@@ -98,6 +103,15 @@
98
103
  * validate health [--repair] Check .planning/ integrity, optionally repair
99
104
  * validate agents Check GSD agent installation status
100
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
+ *
101
115
  * Progress:
102
116
  * progress [json|table|bar] Render progress in various formats
103
117
  *
@@ -240,13 +254,22 @@ try {
240
254
  process.stderr.write((bootErr && bootErr.message ? bootErr.message : String(bootErr)) + '\n');
241
255
  // Fatal bootstrap failure before the CLI's ExitError/runMain machinery (which
242
256
  // lives in ./lib) is available to load, so a direct exit is the only option.
243
- // 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
244
267
  process.exit(1);
245
268
  }
246
269
 
247
- const { ExitError, runMain } = require('./lib/cli-exit.cjs');
270
+ const { ExitError, runMain, resolveContractVersion } = require('./lib/cli-exit.cjs');
248
271
  const io = require('./lib/io.cjs');
249
- const { error, ERROR_REASON, setJsonErrorMode, output } = io;
272
+ const { error, ERROR_REASON, setJsonErrorMode, output, formatDiagnosticToken } = io;
250
273
  const projectRoot = require('./lib/project-root.cjs');
251
274
  // Resolve findProjectRoot lazily at call time rather than binding it at module
252
275
  // load. It is sourced from project-root.cjs; a call-time lookup is robust
@@ -286,6 +309,7 @@ const estimateCli = require('./lib/estimate-cli.cjs');
286
309
  const template = require('./lib/template.cjs');
287
310
  const milestone = require('./lib/milestone.cjs');
288
311
  const commands = require('./lib/commands.cjs');
312
+ const runtimeIdentity = require('./lib/runtime-identity.cjs');
289
313
  const init = require('./lib/init.cjs');
290
314
  const frontmatter = require('./lib/frontmatter.cjs');
291
315
  const workstream = require('./lib/workstream.cjs');
@@ -297,6 +321,7 @@ const { routeVerifyCommand } = require('./lib/verify-command-router.cjs');
297
321
  const { routeEvalCommand } = require('./lib/eval-command-router.cjs');
298
322
  const evalMod = require('./lib/eval.cjs');
299
323
  const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
324
+ const { routePlanningCommand } = require('./lib/planning-command-router.cjs');
300
325
  const verification = require('./lib/verification.cjs');
301
326
  const { routeInitCommand } = require('./lib/init-command-router.cjs');
302
327
  // Stale-bake guard (#1688): warns once when model config changed since agents
@@ -314,7 +339,7 @@ const { routeAgentCommand, AGENT_FAILURE_CLASSES } = require('./lib/agent-comman
314
339
  const smartEntryMod = require('./lib/smart-entry.cjs');
315
340
  const { routeCheckCommand } = require('./lib/check-command-router.cjs');
316
341
  const { routeTaskCommand } = require('./lib/task-command-router.cjs');
317
- const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
342
+ const { parseNamedArgsOrExit, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
318
343
  const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
319
344
  const { getEffectiveAuthority, classifyDriftSeverity, comparePhaseStatus } = require('./lib/plan-drift-guard.cjs');
320
345
 
@@ -952,7 +977,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
952
977
 
953
978
  function routePrSubrepo({ args, cwd, raw, error }) {
954
979
  const message = args[1];
955
- 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);
956
990
  commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
957
991
  }
958
992
 
@@ -969,7 +1003,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
969
1003
  template.cmdTemplateSelect(cwd, args[2], raw);
970
1004
  } else if (subcommand === 'fill') {
971
1005
  const templateType = args[2];
972
- 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);
973
1007
  let fields = {};
974
1008
  if (fieldsRaw) {
975
1009
  const { safeJsonParse } = require('./lib/security.cjs');
@@ -1018,14 +1052,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1018
1052
  }
1019
1053
  // CJS fallback (SDK unavailable or unknown subcommand)
1020
1054
  if (subcommand === 'get') {
1021
- frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
1055
+ frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['field'], positionals: 3 }, error).field, raw);
1022
1056
  } else if (subcommand === 'set') {
1023
- const { field, value } = parseNamedArgs(args, ['field', 'value']);
1057
+ const { field, value } = parseNamedArgsOrExit(args, { valueFlags: ['field', 'value'], positionals: 3 }, error);
1024
1058
  frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
1025
1059
  } else if (subcommand === 'merge') {
1026
- frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw);
1060
+ frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['data'], positionals: 3 }, error).data, raw);
1027
1061
  } else if (subcommand === 'validate') {
1028
- frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw);
1062
+ frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['schema'], positionals: 3 }, error).schema, raw);
1029
1063
  } else {
1030
1064
  error('Unknown frontmatter subcommand. Available: get, set, merge, validate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1031
1065
  }
@@ -1069,6 +1103,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1069
1103
  commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1070
1104
  }
1071
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
+
1072
1115
  function routeSkillsRoot({ args, raw, error }) {
1073
1116
  // #3024: resolve the global skills base directory for a runtime.
1074
1117
  // The sync-skills workflow previously shelled out to install.js --skills-root,
@@ -1140,10 +1183,38 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1140
1183
  // to the pure appendQuickTaskRow (markdown-table.cjs); this case only
1141
1184
  // handles the I/O (read STATE.md, resolve date/commit, write STATE.md).
1142
1185
  const qtaArgs = args.slice(1);
1143
- 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];
1144
1211
  if (!qtaTask) {
1145
1212
  error('quick-tasks-append requires --task <description> (or a positional description)', ERROR_REASON.USAGE);
1146
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);
1147
1218
 
1148
1219
  const statePath = path.join(cwd, '.planning', 'STATE.md');
1149
1220
  if (!fs.existsSync(statePath)) {
@@ -1169,8 +1240,21 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1169
1240
  // still releases the lock before the throw propagates; the transform
1170
1241
  // throws before returning new content, so nothing is ever written).
1171
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.
1172
1250
  state.readModifyWriteStateMd(statePath, (content) => {
1173
- 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
+ });
1174
1258
  if (!result.ok) {
1175
1259
  // Mirrors fast.md's old "skip with a brief log" behaviour (#2133): this
1176
1260
  // is an expected, recoverable condition (no table / unrecognized
@@ -1181,7 +1265,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1181
1265
  }
1182
1266
  mutation = result.value;
1183
1267
  return result.value.content;
1184
- }, cwd);
1268
+ }, cwd, { resync: false });
1185
1269
 
1186
1270
  output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row);
1187
1271
  }
@@ -1669,6 +1753,31 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1669
1753
  }
1670
1754
  }
1671
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
+
1672
1781
  function routeDispatchIsolation({ args, cwd, raw, error }) {
1673
1782
  // #2584 Phase 3 (#2627): typed query exposing the negotiated
1674
1783
  // `dispatch.isolation` to the execute-phase wave scheduler, so the
@@ -1742,6 +1851,25 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1742
1851
  ? args[planIdx + 1]
1743
1852
  : null;
1744
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
+
1745
1873
  // Side-effect write (#3045 CORE REDESIGN) — see the doc comment above.
1746
1874
  // Never allowed to affect this query's own stdout contract or throw.
1747
1875
  try {
@@ -1758,6 +1886,155 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1758
1886
  }
1759
1887
  }
1760
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
+
1761
2038
  const DISPATCH_ISOLATION_VOCABULARY = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1762
2039
 
1763
2040
  /**
@@ -1821,14 +2098,67 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1821
2098
  const promptIdx = args.indexOf('--prompt');
1822
2099
  const promptArg = promptIdx !== -1 ? args[promptIdx + 1] : undefined;
1823
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
+ }
1824
2145
  const resolution = hostIntegration.resolveOrchestratorExec(
1825
2146
  runtimeEntry?.runtime?.orchestratorExec,
1826
2147
  cwdTarget,
1827
2148
  promptArg,
2149
+ model,
1828
2150
  );
1829
- // A host declaring orchestrator-worktree whose exec descriptor does
1830
- // not resolve cannot be spawned — degrade to sequential rather than
1831
- // 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.
1832
2162
  if (resolution.ok) {
1833
2163
  exec = { command: resolution.command, args: resolution.args, cwd: resolution.cwd };
1834
2164
  } else {
@@ -2137,8 +2467,19 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2137
2467
  .filter((t) => t.length > 0);
2138
2468
  termsOverride = list.length > 0 ? { pluralization: list } : undefined;
2139
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.
2140
2477
  const section = roadmap.getRoadmapPhaseWithFallback(cwd, phaseNum);
2141
- 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);
2142
2483
  output(result, raw);
2143
2484
  return;
2144
2485
  }
@@ -2185,7 +2526,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2185
2526
  // --no-archive-phases' inverted shape, absence of this flag means
2186
2527
  // "do nothing" rather than "skip a default-on behavior".
2187
2528
  const archiveQuick = args.includes('--archive-quick');
2188
- milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick }, raw);
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);
2189
2534
  } else if (subcommand === 'archive-quick') {
2190
2535
  // #2142 escalation: narrow archival-only entry point (does NOT
2191
2536
  // touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion
@@ -2207,11 +2552,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2207
2552
  const subcommand = args[1];
2208
2553
  if (subcommand === 'render-checkpoint') {
2209
2554
  const uat = require('./lib/uat.cjs');
2210
- const options = parseNamedArgs(args, ['file']);
2555
+ const options = parseNamedArgsOrExit(args, { valueFlags: ['file'], positionals: 2 }, error);
2211
2556
  uat.cmdRenderCheckpoint(cwd, options, raw);
2212
2557
  } else if (subcommand === 'classify-coverage') {
2213
2558
  const coverage = require('./lib/coverage.cjs');
2214
- const options = parseNamedArgs(args, ['summary', 'file']);
2559
+ const options = parseNamedArgsOrExit(args, { valueFlags: ['summary', 'file'], positionals: 2 }, error);
2215
2560
  coverage.cmdClassify(cwd, options, raw);
2216
2561
  } else {
2217
2562
  error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -2236,8 +2581,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2236
2581
 
2237
2582
  function routeScaffold({ args, cwd, raw, error }) {
2238
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.
2239
2589
  const scaffoldOptions = {
2240
- phase: parseNamedArgs(args, ['phase']).phase,
2590
+ phase: parseNamedArgsOrExit(args, { valueFlags: ['phase'], positionals: 'rest' }, error).phase,
2241
2591
  name: parseMultiwordArg(args, 'name'),
2242
2592
  };
2243
2593
  commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
@@ -2441,9 +2791,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2441
2791
  );
2442
2792
  }
2443
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;
2444
2803
  // WindowsError carries a REASON code; surface it through the structured
2445
- // error path so tests can assert on the typed reason. `error()` calls
2446
- // process.exit(1) internally so we never reach the fall-through.
2804
+ // error path so tests can assert on the typed reason.
2447
2805
  if (e && e.name === 'WindowsError' && typeof e.reason === 'string') {
2448
2806
  error(e.message || 'broken-windows error', e.reason);
2449
2807
  }
@@ -3712,6 +4070,7 @@ const HOST_COMMAND_ROUTERS = {
3712
4070
  'verification': routeVerification,
3713
4071
  'generate-slug': routeGenerateSlug,
3714
4072
  'current-timestamp': routeCurrentTimestamp,
4073
+ 'runtime-identity': routeRuntimeIdentity,
3715
4074
  'project-instruction-file': routeProjectInstructionFile,
3716
4075
  'list-todos': routeListTodos,
3717
4076
  'list-seeds': routeListSeeds,
@@ -3728,6 +4087,10 @@ const HOST_COMMAND_ROUTERS = {
3728
4087
  'skill-manifest': routeSkillManifest,
3729
4088
  'history-digest': routeHistoryDigest,
3730
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,
3731
4094
  'assumption-delta': routeAssumptionDelta,
3732
4095
  'requirements': routeRequirements,
3733
4096
  'gap-analysis': routeGapAnalysis,
@@ -3968,14 +4331,14 @@ function runWithTimeout(argv) {
3968
4331
  // this string and HOST_COMMAND_ROUTERS/SKIP_ROOT_RESOLUTION are three
3969
4332
  // independently hand-maintained sites and nothing previously caught them
3970
4333
  // drifting apart when a query command was added to only one or two.
3971
- const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
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' +
3972
4335
  'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-docs-guard, commit-to-subrepo, pr-subrepo, ' +
3973
4336
  'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
3974
4337
  'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
3975
4338
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
3976
4339
  'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
3977
- 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
3978
- 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
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, ' +
3979
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, ' +
3980
4343
  'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
3981
4344
  'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
@@ -3983,8 +4346,10 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <fiel
3983
4346
  ' --raw Emit raw output without post-processing\n' +
3984
4347
  ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
3985
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' +
3986
4350
  ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
3987
- ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
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' +
3988
4353
  'For command-specific argument requirements, invoke the command without args ' +
3989
4354
  '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
3990
4355
 
@@ -4003,6 +4368,10 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <fiel
4003
4368
  // below (never as the live Set itself; see that function's doc comment).
4004
4369
  const SKIP_ROOT_RESOLUTION = new Set([
4005
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',
4006
4375
  // #2844: verify-summary was previously skipped, leaving relative file-claim
4007
4376
  // paths resolved against the raw process.cwd() — invoking from a subdirectory
4008
4377
  // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes
@@ -4074,18 +4443,19 @@ function resolveMainWorktreeCwd(cwd, deps = {}) {
4074
4443
  async function main() {
4075
4444
  let args = process.argv.slice(2);
4076
4445
 
4077
- // #2351: run-with-timeout bounds a spawned command's wall clock portably
4078
- // (coreutils-independent). It MUST intercept HERE, before the global-flag
4079
- // parsing below — the wrapped command's argv is opaque and may itself contain
4080
- // --raw / --cwd / --pick that this dispatcher would otherwise consume.
4081
- {
4082
- let rwt = args;
4083
- if (rwt[0] === 'query') rwt = rwt.slice(1);
4084
- if (rwt[0] === 'run-with-timeout') {
4085
- // Return the child's exit code; runMain() maps it to process.exitCode.
4086
- return runWithTimeout(rwt.slice(1));
4087
- }
4088
- }
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).
4089
4459
 
4090
4460
  // --json-errors / GSD_JSON_ERRORS=1: when active, error() emits structured
4091
4461
  // JSON ({ ok: false, reason: <ERROR_REASON code>, message }) to stderr
@@ -4105,6 +4475,41 @@ async function main() {
4105
4475
  setJsonErrorMode(true);
4106
4476
  }
4107
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
+
4108
4513
  // Optional cwd override for sandboxed subagents running outside project root.
4109
4514
  let cwd = process.cwd();
4110
4515
  const cwdEqArg = args.find(arg => arg.startsWith('--cwd='));
@@ -4125,6 +4530,39 @@ async function main() {
4125
4530
  error(`Invalid --cwd: ${cwd}`, ERROR_REASON.USAGE);
4126
4531
  }
4127
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
+
4128
4566
  // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
4129
4567
  // However, in monorepo worktrees where the subdirectory itself owns .planning/,
4130
4568
  // skip worktree resolution — the CWD is already the correct project root.
@@ -4237,24 +4675,45 @@ async function main() {
4237
4675
  }
4238
4676
  }
4239
4677
 
4240
- 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)) {
4241
4682
  cwd = findProjectRoot(cwd);
4242
4683
  }
4243
4684
 
4244
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).
4245
4692
  if (pickField) {
4246
4693
  const captured = await captureStdoutSyncWrites(async () => {
4247
4694
  await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
4248
4695
  });
4249
4696
  const resolved = resolveAtFileOutput(captured);
4697
+ let obj;
4250
4698
  try {
4251
- const obj = JSON.parse(resolved);
4252
- const value = extractField(obj, pickField);
4253
- const result = value === null || value === undefined ? '' : String(value);
4254
- fs.writeSync(1, result);
4699
+ obj = JSON.parse(resolved);
4255
4700
  } catch {
4256
- fs.writeSync(1, captured);
4701
+ error(`--pick ${formatDiagnosticToken(pickField)}: command output was not JSON`, ERROR_REASON.PICK_OUTPUT_NOT_JSON);
4702
+ return;
4257
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;
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);
4258
4717
  return;
4259
4718
  }
4260
4719
 
@@ -4316,27 +4775,68 @@ function resolveAtFileOutput(captured) {
4316
4775
  return fs.readFileSync(captured.slice(6), 'utf-8');
4317
4776
  }
4318
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
+
4319
4807
  /**
4320
4808
  * Extract a field from an object using dot-notation and bracket syntax.
4321
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).
4322
4818
  */
4323
4819
  function extractField(obj, fieldPath) {
4324
4820
  const parts = fieldPath.split('.');
4325
4821
  let current = obj;
4326
4822
  for (const part of parts) {
4327
- if (current === null || current === undefined) return undefined;
4328
4823
  const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/);
4329
4824
  if (bracketMatch) {
4330
4825
  const key = bracketMatch[1];
4331
4826
  const index = parseInt(bracketMatch[2], 10);
4332
- current = current[key];
4333
- if (!Array.isArray(current)) return undefined;
4334
- 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];
4335
4833
  } else {
4834
+ if (!isPlainRecord(current)) return { found: false, value: undefined };
4835
+ if (!Object.prototype.hasOwnProperty.call(current, part)) return { found: false, value: undefined };
4336
4836
  current = current[part];
4337
4837
  }
4338
4838
  }
4339
- return current;
4839
+ return { found: true, value: current };
4340
4840
  }
4341
4841
 
4342
4842
  async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
@@ -4405,5 +4905,21 @@ module.exports = {
4405
4905
  // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
4406
4906
  // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
4407
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,
4408
4924
  };
4409
4925