@opengsd/gsd-core 1.13.0 → 1.15.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 (441) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-advisor-researcher.compact.md +85 -0
  8. package/agents/gsd-ai-researcher.compact.md +96 -0
  9. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  10. package/agents/gsd-code-fixer.compact.md +459 -0
  11. package/agents/gsd-code-fixer.md +9 -8
  12. package/agents/gsd-code-reviewer.compact.md +269 -0
  13. package/agents/gsd-code-reviewer.md +15 -3
  14. package/agents/gsd-codebase-mapper.compact.md +760 -0
  15. package/agents/gsd-debug-session-manager.compact.md +360 -0
  16. package/agents/gsd-debug-session-manager.md +17 -2
  17. package/agents/gsd-debugger.md +2 -2
  18. package/agents/gsd-doc-classifier.compact.md +192 -0
  19. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  20. package/agents/gsd-doc-verifier.compact.md +143 -0
  21. package/agents/gsd-doc-writer.compact.md +440 -0
  22. package/agents/gsd-dom-verifier.compact.md +138 -0
  23. package/agents/gsd-domain-researcher.compact.md +141 -0
  24. package/agents/gsd-eval-auditor.compact.md +160 -0
  25. package/agents/gsd-eval-auditor.md +1 -1
  26. package/agents/gsd-eval-planner.compact.md +137 -0
  27. package/agents/gsd-executor.md +13 -8
  28. package/agents/gsd-framework-selector.compact.md +82 -0
  29. package/agents/gsd-integration-checker.compact.md +245 -0
  30. package/agents/gsd-intel-updater.compact.md +226 -0
  31. package/agents/gsd-intel-updater.md +1 -1
  32. package/agents/gsd-mempalace-curator.compact.md +45 -0
  33. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  34. package/agents/gsd-pattern-mapper.compact.md +275 -0
  35. package/agents/gsd-phase-researcher.md +19 -11
  36. package/agents/gsd-plan-checker.md +8 -7
  37. package/agents/gsd-planner.md +12 -8
  38. package/agents/gsd-project-researcher.compact.md +587 -0
  39. package/agents/gsd-project-researcher.md +1 -1
  40. package/agents/gsd-research-synthesizer.compact.md +212 -0
  41. package/agents/gsd-research-synthesizer.md +1 -1
  42. package/agents/gsd-roadmapper.compact.md +454 -0
  43. package/agents/gsd-roadmapper.md +13 -0
  44. package/agents/gsd-security-auditor.compact.md +162 -0
  45. package/agents/gsd-ui-auditor.compact.md +404 -0
  46. package/agents/gsd-ui-auditor.md +155 -17
  47. package/agents/gsd-ui-checker.compact.md +277 -0
  48. package/agents/gsd-ui-researcher.compact.md +282 -0
  49. package/agents/gsd-ui-researcher.md +1 -1
  50. package/agents/gsd-user-profiler.compact.md +108 -0
  51. package/agents/gsd-verifier.md +10 -9
  52. package/bin/install.js +848 -163
  53. package/commands/gsd/autonomous.md +2 -2
  54. package/commands/gsd/capture.md +1 -1
  55. package/commands/gsd/cleanup.md +1 -0
  56. package/commands/gsd/code-review.md +2 -1
  57. package/commands/gsd/complete-milestone.md +1 -0
  58. package/commands/gsd/config.md +1 -0
  59. package/commands/gsd/debug.md +1 -0
  60. package/commands/gsd/graphify.md +1 -0
  61. package/commands/gsd/health.md +1 -0
  62. package/commands/gsd/mempalace-capture.md +8 -3
  63. package/commands/gsd/mempalace-recall.md +1 -0
  64. package/commands/gsd/new-milestone.md +1 -0
  65. package/commands/gsd/new-project.md +1 -0
  66. package/commands/gsd/next.md +1 -0
  67. package/commands/gsd/pause-work.md +1 -0
  68. package/commands/gsd/phase.md +1 -0
  69. package/commands/gsd/plan-review-convergence.md +6 -6
  70. package/commands/gsd/pr-branch.md +1 -0
  71. package/commands/gsd/progress.md +1 -1
  72. package/commands/gsd/quick-batch.md +1 -1
  73. package/commands/gsd/resume-work.md +1 -0
  74. package/commands/gsd/review-backlog.md +1 -0
  75. package/commands/gsd/review.md +2 -3
  76. package/commands/gsd/settings.md +2 -1
  77. package/commands/gsd/stats.md +1 -0
  78. package/commands/gsd/thread.md +1 -0
  79. package/commands/gsd/workspace.md +1 -0
  80. package/commands/gsd/workstreams.md +1 -0
  81. package/gsd-core/bin/check-latest-version.cjs +8 -3
  82. package/gsd-core/bin/gsd-tools.cjs +672 -146
  83. package/gsd-core/bin/lib/adr-parser.cjs +4 -2
  84. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  85. package/gsd-core/bin/lib/audit.cjs +119 -34
  86. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  87. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  88. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  89. package/gsd-core/bin/lib/capability-registry.cjs +96 -189
  90. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  91. package/gsd-core/bin/lib/capability-validator.cjs +14 -2
  92. package/gsd-core/bin/lib/check-command-router.cjs +213 -49
  93. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  94. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  95. package/gsd-core/bin/lib/commands.cjs +823 -112
  96. package/gsd-core/bin/lib/config-loader.cjs +66 -4
  97. package/gsd-core/bin/lib/config.cjs +186 -45
  98. package/gsd-core/bin/lib/coverage.cjs +1 -1
  99. package/gsd-core/bin/lib/decisions.cjs +164 -45
  100. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  101. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  102. package/gsd-core/bin/lib/graphify.cjs +10 -2
  103. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  104. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  105. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  106. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  107. package/gsd-core/bin/lib/init.cjs +614 -86
  108. package/gsd-core/bin/lib/install-engine.cjs +29 -3
  109. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  110. package/gsd-core/bin/lib/installer-migrations.cjs +41 -5
  111. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  112. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  113. package/gsd-core/bin/lib/milestone.cjs +37 -13
  114. package/gsd-core/bin/lib/model-resolver.cjs +253 -53
  115. package/gsd-core/bin/lib/phase-command-router.cjs +16 -2
  116. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  117. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  118. package/gsd-core/bin/lib/phase-id.cjs +268 -27
  119. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  120. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  121. package/gsd-core/bin/lib/phase.cjs +393 -88
  122. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  123. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  124. package/gsd-core/bin/lib/planning-inspect.cjs +52 -19
  125. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  126. package/gsd-core/bin/lib/planning-workspace.cjs +57 -3
  127. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  128. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  129. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  130. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  131. package/gsd-core/bin/lib/project-root.cjs +41 -2
  132. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  133. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  134. package/gsd-core/bin/lib/research-store.cjs +11 -12
  135. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  136. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  137. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  138. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  139. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  140. package/gsd-core/bin/lib/roadmap-parser.cjs +219 -18
  141. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  142. package/gsd-core/bin/lib/roadmap.cjs +356 -42
  143. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -41
  144. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +15 -4
  145. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  146. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  147. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -37
  148. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  149. package/gsd-core/bin/lib/security.cjs +126 -7
  150. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  151. package/gsd-core/bin/lib/state-document.cjs +130 -28
  152. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  153. package/gsd-core/bin/lib/state-transition.cjs +181 -30
  154. package/gsd-core/bin/lib/state.cjs +265 -27
  155. package/gsd-core/bin/lib/surface.cjs +77 -3
  156. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  157. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  158. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  159. package/gsd-core/bin/lib/uat.cjs +9 -1
  160. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  161. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  162. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  163. package/gsd-core/bin/lib/update-context.cjs +30 -24
  164. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  165. package/gsd-core/bin/lib/verification.cjs +315 -30
  166. package/gsd-core/bin/lib/verify-command-grounding.cjs +47 -3
  167. package/gsd-core/bin/lib/verify.cjs +320 -48
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  169. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  170. package/gsd-core/bin/lib/worktree-safety.cjs +797 -58
  171. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  172. package/gsd-core/bin/shared/config-schema.manifest.json +6 -0
  173. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  174. package/gsd-core/references/checkpoints.md +5 -3
  175. package/gsd-core/references/compact-content-gate.md +66 -0
  176. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  177. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  178. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  179. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  180. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  181. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  182. package/gsd-core/references/edge-probe.md +195 -21
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  184. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  185. package/gsd-core/references/gsd-run-resolver.md +1 -1
  186. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  187. package/gsd-core/references/model-profiles.md +13 -4
  188. package/gsd-core/references/phase-argument-parsing.md +9 -7
  189. package/gsd-core/references/phase-id-convention.md +28 -0
  190. package/gsd-core/references/planner-gap-closure.md +2 -0
  191. package/gsd-core/references/planner-load-graph-context.md +24 -13
  192. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  193. package/gsd-core/references/planning-config.md +14 -2
  194. package/gsd-core/references/tdd.md +30 -4
  195. package/gsd-core/references/thinking-models-planning.md +18 -2
  196. package/gsd-core/references/ui-consideration-probe.md +10 -5
  197. package/gsd-core/references/verification-patterns.md +17 -4
  198. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  199. package/gsd-core/references/worktree-path-safety.md +433 -2
  200. package/gsd-core/templates/README.md +7 -1
  201. package/gsd-core/templates/state.md +6 -3
  202. package/gsd-core/templates/summary.compact.md +212 -0
  203. package/gsd-core/templates/user-setup.compact.md +199 -0
  204. package/gsd-core/templates/user-setup.md +0 -9
  205. package/gsd-core/templates/verification-report.md +1 -1
  206. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  207. package/gsd-core/workflows/add-backlog.md +1 -1
  208. package/gsd-core/workflows/add-phase.md +1 -1
  209. package/gsd-core/workflows/add-tests.md +2 -2
  210. package/gsd-core/workflows/add-todo.md +6 -5
  211. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  212. package/gsd-core/workflows/audit-fix.md +1 -1
  213. package/gsd-core/workflows/audit-milestone.md +1 -1
  214. package/gsd-core/workflows/audit-uat.md +1 -1
  215. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  216. package/gsd-core/workflows/autonomous.md +29 -16
  217. package/gsd-core/workflows/check-todos.md +6 -4
  218. package/gsd-core/workflows/cleanup.md +5 -3
  219. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  220. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +8 -1
  221. package/gsd-core/workflows/code-review-fix.md +108 -22
  222. package/gsd-core/workflows/code-review.md +216 -73
  223. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  224. package/gsd-core/workflows/complete-milestone.md +41 -264
  225. package/gsd-core/workflows/debug.md +3 -3
  226. package/gsd-core/workflows/diagnose-issues.md +1 -1
  227. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  228. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  229. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  230. package/gsd-core/workflows/discuss-phase.md +1 -1
  231. package/gsd-core/workflows/do.md +2 -2
  232. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  233. package/gsd-core/workflows/docs-update.md +17 -158
  234. package/gsd-core/workflows/edit-phase.md +1 -1
  235. package/gsd-core/workflows/eval-review.md +10 -3
  236. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  237. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  238. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +19 -4
  239. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  240. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +43 -4
  241. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  242. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  243. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  244. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  245. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  246. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  247. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  248. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  249. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  250. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  251. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  252. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  253. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  254. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  255. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  256. package/gsd-core/workflows/execute-phase.md +83 -172
  257. package/gsd-core/workflows/execute-plan.md +24 -10
  258. package/gsd-core/workflows/explore.md +3 -3
  259. package/gsd-core/workflows/extract-learnings.md +2 -1
  260. package/gsd-core/workflows/fast.md +1 -1
  261. package/gsd-core/workflows/forensics.md +1 -1
  262. package/gsd-core/workflows/graduation.md +1 -1
  263. package/gsd-core/workflows/health.md +2 -2
  264. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  265. package/gsd-core/workflows/help/modes/full.md +5 -5
  266. package/gsd-core/workflows/help/modes/topic.md +15 -5
  267. package/gsd-core/workflows/help.md +1 -1
  268. package/gsd-core/workflows/import.md +2 -2
  269. package/gsd-core/workflows/inbox.md +2 -2
  270. package/gsd-core/workflows/ingest-docs.md +3 -3
  271. package/gsd-core/workflows/insert-phase.md +1 -1
  272. package/gsd-core/workflows/list-seeds.md +1 -1
  273. package/gsd-core/workflows/list-workspaces.md +1 -1
  274. package/gsd-core/workflows/manager.md +2 -2
  275. package/gsd-core/workflows/map-codebase.md +52 -5
  276. package/gsd-core/workflows/milestone-summary.md +1 -1
  277. package/gsd-core/workflows/mvp-phase.md +1 -1
  278. package/gsd-core/workflows/new-milestone.md +56 -14
  279. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  280. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  281. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  282. package/gsd-core/workflows/new-project.md +39 -209
  283. package/gsd-core/workflows/new-workspace.md +2 -2
  284. package/gsd-core/workflows/next.md +1 -1
  285. package/gsd-core/workflows/note.md +1 -1
  286. package/gsd-core/workflows/onboard.md +1 -1
  287. package/gsd-core/workflows/pause-work.md +1 -1
  288. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  289. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  290. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  291. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  292. package/gsd-core/workflows/plan-phase.md +45 -187
  293. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  294. package/gsd-core/workflows/plant-seed.md +62 -20
  295. package/gsd-core/workflows/pr-branch.md +132 -20
  296. package/gsd-core/workflows/profile-user.md +2 -2
  297. package/gsd-core/workflows/progress.md +1 -1
  298. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  299. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  300. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  301. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  302. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  303. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  304. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  305. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  306. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  307. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  308. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  309. package/gsd-core/workflows/quick-batch.md +1 -1
  310. package/gsd-core/workflows/quick.md +29 -10
  311. package/gsd-core/workflows/reapply-patches.md +86 -6
  312. package/gsd-core/workflows/remove-phase.md +1 -1
  313. package/gsd-core/workflows/remove-workspace.md +2 -2
  314. package/gsd-core/workflows/resume-project.md +1 -1
  315. package/gsd-core/workflows/review.md +31 -16
  316. package/gsd-core/workflows/scan.md +1 -1
  317. package/gsd-core/workflows/secure-phase.md +3 -2
  318. package/gsd-core/workflows/settings-advanced.md +30 -10
  319. package/gsd-core/workflows/settings-integrations.md +2 -3
  320. package/gsd-core/workflows/settings.md +22 -9
  321. package/gsd-core/workflows/ship.md +3 -2
  322. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  323. package/gsd-core/workflows/sketch.md +1 -1
  324. package/gsd-core/workflows/smart-entry.md +2 -2
  325. package/gsd-core/workflows/spec-phase.md +15 -5
  326. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  327. package/gsd-core/workflows/spike.md +1 -1
  328. package/gsd-core/workflows/stats.md +1 -1
  329. package/gsd-core/workflows/sync-skills.md +5 -5
  330. package/gsd-core/workflows/thread.md +1 -1
  331. package/gsd-core/workflows/transition.md +1 -1
  332. package/gsd-core/workflows/ui-phase.md +44 -8
  333. package/gsd-core/workflows/ui-review.md +18 -4
  334. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  335. package/gsd-core/workflows/undo.md +339 -20
  336. package/gsd-core/workflows/update.md +14 -12
  337. package/gsd-core/workflows/validate-phase.md +3 -2
  338. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  339. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  340. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  341. package/gsd-core/workflows/verify-work.md +101 -196
  342. package/hooks/dist/gsd-agent-isolation-guard.js +66 -16
  343. package/hooks/dist/gsd-context-monitor.js +88 -15
  344. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  345. package/hooks/dist/gsd-secret-read-guard.js +71 -19
  346. package/hooks/dist/gsd-statusline.js +81 -20
  347. package/hooks/dist/gsd-validate-commit.sh +97 -8
  348. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  349. package/hooks/dist/gsd-write-guard.js +46 -1
  350. package/hooks/dist/lib/dispatch-identity.js +187 -0
  351. package/hooks/dist/lib/filename-classification.js +64 -0
  352. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  353. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  354. package/hooks/gsd-agent-isolation-guard.js +66 -16
  355. package/hooks/gsd-context-monitor.js +88 -15
  356. package/hooks/gsd-cursor-subagent-start.js +34 -14
  357. package/hooks/gsd-secret-read-guard.js +71 -19
  358. package/hooks/gsd-statusline.js +81 -20
  359. package/hooks/gsd-validate-commit.sh +97 -8
  360. package/hooks/gsd-worktree-path-guard.js +25 -14
  361. package/hooks/gsd-write-guard.js +46 -1
  362. package/hooks/lib/dispatch-identity.js +187 -0
  363. package/hooks/lib/filename-classification.js +64 -0
  364. package/hooks/lib/isolation-deny-reason.js +53 -1
  365. package/hooks/lib/isolation-sentinel.js +58 -19
  366. package/package.json +11 -6
  367. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  368. package/scripts/benchmark-compact-content.cjs +368 -0
  369. package/scripts/build-hooks.js +15 -6
  370. package/scripts/check-contract-drift.cjs +131 -12
  371. package/scripts/check-env.cjs +36 -8
  372. package/scripts/check-glossary-refs.cjs +25 -21
  373. package/scripts/ci-next-health.cjs +271 -0
  374. package/scripts/ci-prepare-test-scope.cjs +7 -7
  375. package/scripts/ci-test-scope.cjs +126 -20
  376. package/scripts/ci-timeout-report.cjs +1 -1
  377. package/scripts/command-contract-helpers.cjs +3 -0
  378. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  379. package/scripts/docs-guard-registry.cjs +35 -2
  380. package/scripts/gen-adr-index.cjs +8 -2
  381. package/scripts/gen-inventory-manifest.cjs +12 -0
  382. package/scripts/gen-loop-host-contract.cjs +69 -0
  383. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  384. package/scripts/lib/drift-scan.cjs +1 -1
  385. package/scripts/lib/macos-conformance-tier.generated.cjs +224 -0
  386. package/scripts/lib/ndjson-reporter.cjs +3 -2
  387. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  388. package/scripts/lib/platform-conformance-tier.generated.cjs +287 -0
  389. package/scripts/lib/suite-detection.cjs +32 -0
  390. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  391. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +47 -3
  392. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  393. package/scripts/lint-phase-id-drift.cjs +623 -13
  394. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  395. package/scripts/lint-response-language-coverage.cjs +9 -3
  396. package/scripts/lint-retired-runtime-name.cjs +619 -0
  397. package/scripts/lint-source-test-name-collision.cjs +1 -1
  398. package/scripts/lint-state-write-path-drift.cjs +93 -0
  399. package/scripts/lint-test-file-count.allowlist.json +29 -9
  400. package/scripts/lint-vendored-deps.cjs +128 -17
  401. package/scripts/lint-workflow-shellcheck-baseline.json +100 -0
  402. package/scripts/prompt-injection-scan.sh +18 -0
  403. package/scripts/release-tarball-smoke.cjs +194 -1
  404. package/scripts/workflow-size.cjs +139 -0
  405. package/skills/gsd-autonomous/SKILL.md +2 -2
  406. package/skills/gsd-capture/SKILL.md +1 -1
  407. package/skills/gsd-cleanup/SKILL.md +1 -0
  408. package/skills/gsd-code-review/SKILL.md +2 -1
  409. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  410. package/skills/gsd-config/SKILL.md +1 -0
  411. package/skills/gsd-debug/SKILL.md +1 -0
  412. package/skills/gsd-graphify/SKILL.md +1 -0
  413. package/skills/gsd-health/SKILL.md +1 -0
  414. package/skills/gsd-mempalace-capture/SKILL.md +8 -3
  415. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  416. package/skills/gsd-new-milestone/SKILL.md +1 -0
  417. package/skills/gsd-new-project/SKILL.md +1 -0
  418. package/skills/gsd-next/SKILL.md +1 -0
  419. package/skills/gsd-pause-work/SKILL.md +1 -0
  420. package/skills/gsd-phase/SKILL.md +1 -0
  421. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  422. package/skills/gsd-pr-branch/SKILL.md +1 -0
  423. package/skills/gsd-progress/SKILL.md +1 -1
  424. package/skills/gsd-quick-batch/SKILL.md +1 -1
  425. package/skills/gsd-resume-work/SKILL.md +1 -0
  426. package/skills/gsd-review/SKILL.md +2 -3
  427. package/skills/gsd-review-backlog/SKILL.md +1 -0
  428. package/skills/gsd-settings/SKILL.md +2 -1
  429. package/skills/gsd-stats/SKILL.md +1 -0
  430. package/skills/gsd-thread/SKILL.md +1 -0
  431. package/skills/gsd-workspace/SKILL.md +1 -0
  432. package/skills/gsd-workstreams/SKILL.md +1 -0
  433. package/vscode/package.json +1 -1
  434. package/gsd-core/templates/claude-md.md +0 -145
  435. package/gsd-core/templates/codebase/concerns.md +0 -310
  436. package/gsd-core/templates/codebase/conventions.md +0 -307
  437. package/gsd-core/templates/codebase/integrations.md +0 -280
  438. package/gsd-core/templates/codebase/structure.md +0 -285
  439. package/gsd-core/templates/codebase/testing.md +0 -480
  440. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  441. package/gsd-core/templates/discovery.md +0 -146
@@ -19,6 +19,10 @@
19
19
  * state signal-resume Remove WAITING.json signal
20
20
  * resolve-model <agent-type> Get model for agent based on profile
21
21
  * find-phase <phase> Find phase directory by number
22
+ * select-revert-commits (--phase P | --plan NN-MM) --range R
23
+ * List commits within R whose DECLARED conventional-commit
24
+ * scope identifies P or NN-MM (exact match; phase mode also
25
+ * accepts a NN-MM-scoped commit). Used by /gsd:undo (#4661).
22
26
  * commit <message> [--files f1 f2] [--no-verify] Commit planning docs
23
27
  * commit-docs-guard enable|disable Opt-in .git/hooks/pre-commit guard
24
28
  * that refuses a commit staging
@@ -60,7 +64,7 @@
60
64
  * roadmap update-plan-progress <N> Update progress table row from disk (PLAN vs SUMMARY counts)
61
65
  * roadmap annotate-dependencies <N> Add wave dependency notes + cross-cutting constraints to ROADMAP.md
62
66
  * roadmap validate Validate phase ID convention compliance
63
- * roadmap upgrade [--apply] --convention milestone-prefixed Migrate phase IDs to M-NN convention
67
+ * roadmap upgrade [--apply] --convention <milestone-prefixed|bracket> Migrate phase IDs (dry-run by default)
64
68
  *
65
69
  * Requirements Operations:
66
70
  * requirements mark-complete <ids> Mark requirement IDs as complete in REQUIREMENTS.md
@@ -438,8 +442,9 @@ function dispatchCapabilityCommand({ command, args, cwd, raw, error, registry, r
438
442
  // Step 2: confinement check — belt-and-suspenders even after the basename
439
443
  // validation above. Resolved path must be inside libDir (not equal to it,
440
444
  // and must start with libDir + sep so "libDir-suffix" can't sneak through).
441
- const resolved = path.resolve(libDir, m);
442
- if (resolved === libDir || !resolved.startsWith(libDir + path.sep)) {
445
+ const { tryWithinRootLexical } = require('./lib/security.cjs');
446
+ const resolved = tryWithinRootLexical(m, libDir);
447
+ if (resolved === null || resolved === path.resolve(libDir)) {
443
448
  throw new Error('capability module path escapes bin/lib/: ' + JSON.stringify(m));
444
449
  }
445
450
  // Step 3: require the resolved absolute path — the SAME representation that
@@ -515,18 +520,19 @@ function defaultRequireFromInstallRoot(installRoot, m) {
515
520
  if (typeof m !== 'string' || !/^[A-Za-z0-9._-]+\.cjs$/.test(m)) {
516
521
  throw new Error('capability module must be a bare .cjs basename: ' + JSON.stringify(m));
517
522
  }
518
- // Realpath the root so a symlinked ancestor can't widen confinement.
519
- const realRoot = fs.realpathSync(installRoot);
520
- const resolved = path.resolve(realRoot, m);
521
- if (resolved === realRoot || !resolved.startsWith(realRoot + path.sep)) {
523
+ const { tryWithinRoot, tryWithinRootLexical, PathAcceptance } = require('./lib/security.cjs');
524
+ // Lexical containment check: a symlinked ancestor can't widen confinement.
525
+ const lexical = tryWithinRootLexical(m, installRoot);
526
+ if (lexical === null || lexical === path.resolve(installRoot)) {
522
527
  throw new Error('capability module path escapes its install root: ' + JSON.stringify(m));
523
528
  }
524
- // The module file itself must not be a symlink pointing outside the root.
525
- const realResolved = fs.realpathSync(resolved);
526
- if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) {
529
+ // Realpath/symlink check: the module file itself must not be a symlink
530
+ // pointing outside the root.
531
+ const real = tryWithinRoot(m, installRoot, PathAcceptance.RelativeOnly);
532
+ if (real === null) {
527
533
  throw new Error('capability module resolves outside its install root (symlink): ' + JSON.stringify(m));
528
534
  }
529
- return require(realResolved);
535
+ return require(real);
530
536
  }
531
537
 
532
538
  /**
@@ -784,7 +790,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
784
790
  error,
785
791
  output: output,
786
792
  });
787
- if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw);
793
+ if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw, { dryRun: args.includes('--dry-run') });
788
794
  }
789
795
 
790
796
  function routeConfigSetModelProfile({ args, cwd, raw }) {
@@ -947,18 +953,243 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
947
953
  if (!handled) phase.cmdFindPhase(cwd, args[1], raw);
948
954
  }
949
955
 
956
+ /**
957
+ * #4906 Phase 5 (issue #4661, absorbed from the original bug report):
958
+ * `gsd-tools.cjs query select-revert-commits --phase <id>|--plan <id>
959
+ * --range <git-revision-range>`.
960
+ *
961
+ * Replaces the two `git log --oneline | grep -E ...` pipelines
962
+ * `gsd-core/workflows/undo.md` used to run for `/gsd:undo --phase`/`--plan`
963
+ * selection. `--range` is the ALREADY-COMPUTED `$UNDO_RANGE` the workflow's
964
+ * own phase-directory anchor logic derives — this subcommand changes only
965
+ * the subject-matching step, never the ranging behavior.
966
+ *
967
+ * Validates `--phase`/`--range <id>` via `validatePhaseNumber`
968
+ * (src/security.cts) BEFORE running any git command — an invalid id is
969
+ * refused here, never reaching git or the selection regex (closes bug
970
+ * class 1 of #4661 by construction). A `--plan NN-MM` id is two
971
+ * phase-number-shaped segments joined by the FIRST `-`: `validatePhaseNumber`'s
972
+ * bracket-style alternative requires a leading letter, so a plain numeric
973
+ * plan id never matches it whole — each segment is validated on its own
974
+ * instead of forking a second grammar.
975
+ *
976
+ * Selection itself is delegated to `selectCommitsByDeclaredScope`
977
+ * (src/undo-commit-selection.cts), which parses each commit subject
978
+ * through the SAME anchored conventional-commit header regex the
979
+ * changelog/PR-title gate uses and compares the declared scope to the
980
+ * validated id by exact string equality — no regex is ever built from
981
+ * user input.
982
+ *
983
+ * Output: JSON `{selected, classified}` by default; with `--raw`, one
984
+ * `<full-sha> <subject>` line per selected commit (preserves the visual
985
+ * shape `git log --oneline` produced, so the workflow's downstream
986
+ * confirm-screen display, >50 truncation count, and `git revert
987
+ * --no-commit` loop over COMMITS are unchanged).
988
+ */
989
+ function routeSelectRevertCommits({ args, cwd, raw, error, invokingCwd }) {
990
+ // #4906/#4465 review fix: `cwd` here has already been remapped to the
991
+ // MAIN worktree root by `resolveMainWorktreeCwd` (main(), applied
992
+ // BLANKET to every command before dispatch — correct for a router that
993
+ // reads `.planning/`, e.g. `find-phase`, but wrong for this one). This
994
+ // command reads NO `.planning/` content — it only runs `git log` — and
995
+ // that git operation must see the CALLER's own worktree: its HEAD, its
996
+ // branch, its reachable commit range. Using the remapped `cwd` instead
997
+ // silently resolves `--range` against a DIFFERENT repository checkout
998
+ // (reproduced: from a linked worktree with no local `.planning/`, a
999
+ // `--range HEAD~3..HEAD` that succeeds when run directly in that
1000
+ // worktree fails with "ambiguous argument" through this command,
1001
+ // because it silently ran against main's shorter HEAD instead —
1002
+ // tests/undo-commit-selection-4465.test.cjs, "linked worktree: the main
1003
+ // worktree's planning is the same repository, and is not refused").
1004
+ // `invokingCwd` (threaded from main() through dispatchHostCommand) is
1005
+ // the pre-remap value; falling back to `cwd` keeps direct callers that
1006
+ // omit it (e.g. `dispatchHostCommand` invoked without the new field)
1007
+ // working exactly as before rather than crashing on `undefined`.
1008
+ const gitCwd = invokingCwd || cwd;
1009
+ const { validatePhaseNumber } = require('./lib/security.cjs');
1010
+ const { normalizePhaseName } = require('./lib/phase-id.cjs');
1011
+ const { selectCommitsByDeclaredScope } = require('./lib/undo-commit-selection.cjs');
1012
+ const { execGit } = require('./lib/shell-command-projection.cjs');
1013
+
1014
+ // #4906 Phase 5 review fix: the RETIRED phase-mode grep's `0*` prefix
1015
+ // (`\(0*${TARGET_PHASE}(-[0-9]+)?\):`) tolerated an UNPADDED user-supplied
1016
+ // phase number against a zero-padded commit scope — `--phase 3` matched
1017
+ // `feat(03-01): ...` because `0*3` matches the literal text `03`.
1018
+ // `selectCommitsByDeclaredScope` compares by exact string equality, and
1019
+ // `gsd-core/workflows/undo.md`'s parse_arguments step passes `TARGET_PHASE`
1020
+ // through with NO normalization, so without this, `--phase 3` would
1021
+ // silently select nothing against real (zero-padded) commit scopes — a
1022
+ // behavior regression, not just a documented known-limit. Zero-pad via
1023
+ // `normalizePhaseName` (src/phase-id.cts), the canonical owner of this
1024
+ // exact operation (confirmed: `normalizePhaseName('3') === '03'`).
1025
+ //
1026
+ // GUARDED to digit-first ids only: `normalizePhaseName` also runs
1027
+ // `stripProjectCodePrefix`, which strips a leading `LETTERS-`-shaped
1028
+ // prefix UNCONDITIONALLY — `normalizePhaseName('PROJ-42') === '42'`,
1029
+ // discarding the "PROJ-" entirely (confirmed by direct call). That is
1030
+ // exactly the shape `validatePhaseNumber`'s bracket-style alternative
1031
+ // legitimately accepts as a phase id in ITS OWN right (`PROJ-42`,
1032
+ // `AUTH-101` — see tests/security.test.cjs). Running a letter-first id
1033
+ // through it would silently widen the match (a wildcard-style
1034
+ // over-match reintroducing bug classes 1/2). The old grep's `0*` was
1035
+ // never meaningful for a letter-first target anyway — `0*` before a
1036
+ // non-'0' character matches zero repetitions, a no-op — so a
1037
+ // letter-first id needs neither padding nor this normalization.
1038
+ // `PROJECT_CODE_PREFIX_STRIP_RE_I` (`src/phase-id.cts:33`) itself
1039
+ // requires `^[A-Z]`, so it can never fire on a digit-first id — the
1040
+ // guard below is therefore exactly the safe/unsafe boundary.
1041
+ const normalizePaddedPhase = (id) => (/^\d/.test(id) ? normalizePhaseName(id) : id);
1042
+
1043
+ const parsed = parseNamedArgsOrExit(
1044
+ args,
1045
+ { valueFlags: ['phase', 'plan', 'range'], positionals: 1 },
1046
+ error,
1047
+ );
1048
+ const rawPhase = parsed.phase;
1049
+ const rawPlan = parsed.plan;
1050
+ const range = parsed.range;
1051
+
1052
+ if ((rawPhase && rawPlan) || (!rawPhase && !rawPlan)) {
1053
+ error('select-revert-commits requires exactly one of --phase <id> or --plan <id>', ERROR_REASON.USAGE);
1054
+ return;
1055
+ }
1056
+ if (!range) {
1057
+ error('select-revert-commits requires --range <git-revision-range>', ERROR_REASON.USAGE);
1058
+ return;
1059
+ }
1060
+ // Belt-and-suspenders alongside the --end-of-options guard below (mirrors
1061
+ // src/git-base-branch.cts's isSafeRevisionRef posture for the same class
1062
+ // of argument): refuse an option-shaped range before it ever reaches git.
1063
+ if (typeof range !== 'string' || range.startsWith('-')) {
1064
+ error(`Invalid --range: ${JSON.stringify(range)}`, ERROR_REASON.USAGE);
1065
+ return;
1066
+ }
1067
+
1068
+ let mode;
1069
+ let normalizedId;
1070
+ if (rawPhase) {
1071
+ mode = 'phase';
1072
+ const check = validatePhaseNumber(rawPhase);
1073
+ if (!check.valid) {
1074
+ error(`Invalid --phase: ${check.error}`, ERROR_REASON.USAGE);
1075
+ return;
1076
+ }
1077
+ normalizedId = normalizePaddedPhase(check.normalized);
1078
+ } else {
1079
+ mode = 'plan';
1080
+ // Split on the FIRST `-` only, giving exactly two segments (phase,
1081
+ // plan). This is deliberately NOT a general N-segment composite-id
1082
+ // parser: checked against docs/reference/plan-md.md (frontmatter
1083
+ // `plan` field + `.planning/phases/<NN>-<slug>/<NN>-<PP>-PLAN.md`
1084
+ // layout) and every `--plan` usage in gsd-core/workflows/*.md, the only
1085
+ // plan-id shape this repo documents or emits is the plain two-segment
1086
+ // `NN-MM`. A THREE-segment purely-numeric shape (`NN-MM-PP`) does exist
1087
+ // elsewhere in the codebase, but as a MILESTONE-phase-plan composite
1088
+ // under the (already-deprecated-forward) `milestone-prefixed`
1089
+ // `phase_id_convention` — a different id space than phase-plan, which
1090
+ // `docs/adr/612-bracket-phase-id-convention.md:15` itself documents as
1091
+ // having "no deterministic parse" once a token carries both a
1092
+ // milestone-joined phase AND a plan (the exact ambiguity that
1093
+ // motivated bracket's own `[PROJECT.MM] PP-PP` grammar). No function in
1094
+ // `src/phase-id.cts` validates the phase-PLAN grammar end-to-end
1095
+ // either: `parsePhaseId` explicitly REJECTS a bare `NN-MM` token by
1096
+ // design (its own doc comment lists `02-04` as a rejected "ambiguous /
1097
+ // bare token"), and `getPhaseDirFromPhaseId`'s N-segment dash grammar
1098
+ // is a MILESTONE-phase(-subphase) directory-name constructor — a
1099
+ // structurally similar but semantically different id space (first
1100
+ // segment = milestone, not phase) whose return value doesn't map back
1101
+ // to (phase, plan) anyway. Given no suitable whole-string validator
1102
+ // exists and no real usage needs more than two segments, a `NN-MM-PP`
1103
+ // id is refused today (see
1104
+ // tests/undo-commit-selection.test.cjs "refuses a 3-segment plan id"),
1105
+ // not silently mis-parsed — extending this to N segments is a product
1106
+ // decision for whichever future issue actually needs it. The same
1107
+ // "only the plain two-segment shape is supported" standard applies to
1108
+ // a bracket-style PHASE segment here too: `--plan PROJ-42-01` splits
1109
+ // on the first `-` into phase segment `PROJ` (no internal dash) and
1110
+ // plan segment `42-01`, and `validatePhaseNumber`'s bracket branch
1111
+ // requires the internal dash on ITS side (`PROJ-42`, not bare `PROJ`),
1112
+ // so the phase segment fails and the whole id is refused — bracket-style
1113
+ // ids are supported for `--phase` only, never as a `--plan` segment (no
1114
+ // real usage found; see tests/undo-commit-selection.test.cjs "bracket-
1115
+ // style phase segment in --plan is refused").
1116
+ const dashIdx = rawPlan.indexOf('-');
1117
+ if (dashIdx === -1) {
1118
+ error(`Invalid --plan: "${rawPlan}" (expected NN-MM)`, ERROR_REASON.USAGE);
1119
+ return;
1120
+ }
1121
+ const phasePart = rawPlan.slice(0, dashIdx);
1122
+ const planPart = rawPlan.slice(dashIdx + 1);
1123
+ const phaseCheck = validatePhaseNumber(phasePart);
1124
+ if (!phaseCheck.valid) {
1125
+ error(`Invalid --plan: "${rawPlan}" (phase segment: ${phaseCheck.error})`, ERROR_REASON.USAGE);
1126
+ return;
1127
+ }
1128
+ const planCheck = validatePhaseNumber(planPart);
1129
+ if (!planCheck.valid) {
1130
+ error(`Invalid --plan: "${rawPlan}" (plan segment: ${planCheck.error})`, ERROR_REASON.USAGE);
1131
+ return;
1132
+ }
1133
+ normalizedId = `${normalizePaddedPhase(phaseCheck.normalized)}-${normalizePaddedPhase(planCheck.normalized)}`;
1134
+ }
1135
+
1136
+ const GIT_LOG_TIMEOUT_MS = 15000;
1137
+ const gitResult = execGit(
1138
+ ['log', '--format=%H%x00%s', '--no-merges', '--no-decorate', '--end-of-options', range],
1139
+ { cwd: gitCwd, timeout: GIT_LOG_TIMEOUT_MS },
1140
+ );
1141
+ if (gitResult.exitCode !== 0) {
1142
+ // `execGit` (src/shell-command-projection.cts) exposes `timedOut` on
1143
+ // its SpawnResultOutput — thread it through so a hung `git log` reads
1144
+ // distinctly from an ordinary non-zero exit with no stderr, which
1145
+ // otherwise both surfaced as the same unhelpful "(no stderr)" message.
1146
+ const gitFailureDetail = gitResult.timedOut
1147
+ ? `git log timed out after ${GIT_LOG_TIMEOUT_MS}ms`
1148
+ : `git log failed: ${gitResult.stderr || '(no stderr)'}`;
1149
+ error(
1150
+ `select-revert-commits: ${gitFailureDetail} for range "${range}"`,
1151
+ ERROR_REASON.USAGE,
1152
+ );
1153
+ return;
1154
+ }
1155
+
1156
+ const commits = (gitResult.stdout ? gitResult.stdout.split('\n') : [])
1157
+ .filter((line) => line.length > 0)
1158
+ .map((line) => {
1159
+ const nulIdx = line.indexOf('\0');
1160
+ return nulIdx === -1 ? null : { sha: line.slice(0, nulIdx), subject: line.slice(nulIdx + 1) };
1161
+ })
1162
+ .filter((c) => c !== null);
1163
+
1164
+ const result = selectCommitsByDeclaredScope(commits, normalizedId, mode);
1165
+ const rawLines = result.selected.map((c) => `${c.sha} ${c.subject}`).join('\n');
1166
+ output(result, raw, rawLines);
1167
+ }
1168
+
950
1169
  function routeCommit({ args, cwd, raw, error }) {
951
1170
  const amend = args.includes('--amend');
952
1171
  const noVerify = args.includes('--no-verify');
953
- const filesIndex = args.indexOf('--files');
1172
+ // #4208: `--files` and `--files-removed` are two path lists, each
1173
+ // running from its flag to the NEXT LIST FLAG. A boolean flag
1174
+ // inside a list (`--files a --amend b`) is skipped, not a
1175
+ // terminator: that is what the previous slice-to-end collection
1176
+ // did (it filtered `--` tokens and kept everything else), and a
1177
+ // list that stopped at any `--` token silently dropped `b`
1178
+ // (review of #4253). The previous form could not carry a second
1179
+ // list flag at all, which is the only thing that changed.
1180
+ // A REPEATED list flag (`--files a --files b`) merges, as the old
1181
+ // slice-to-end parse merged it: every occurrence contributes its
1182
+ // run, and none of them ends another's silently.
1183
+ const firstListFlag = args.findIndex((a, i) => i > 0 && COMMIT_LIST_FLAGS.has(a));
954
1184
  // Collect all positional args between command name and first flag,
955
1185
  // then join them — handles both quoted ("multi word msg") and
956
1186
  // unquoted (multi word msg) invocations from different shells
957
- const endIndex = filesIndex !== -1 ? filesIndex : args.length;
1187
+ const endIndex = firstListFlag !== -1 ? firstListFlag : args.length;
958
1188
  const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--'));
959
1189
  const message = messageArgs.join(' ') || undefined;
960
- const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
961
- commands.cmdCommit(cwd, message, files, raw, amend, noVerify);
1190
+ const files = collectListFlagValues(args, '--files');
1191
+ const filesRemoved = collectListFlagValues(args, '--files-removed');
1192
+ commands.cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved);
962
1193
  }
963
1194
 
964
1195
  function routeCheckCommit({ args, cwd, raw, error }) {
@@ -1169,7 +1400,18 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1169
1400
  // First positional that isn't a flag also works (lenient); otherwise ignore unknown flags.
1170
1401
  if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; }
1171
1402
  }
1172
- const filename = getProjectInstructionFile(pifRuntime);
1403
+ // A retired runtime id now THROWS rather than resolving (#4709 AC#1).
1404
+ // Map it to the same clean single-line error routeSkillsRoot emits for
1405
+ // an unknown runtime — a CLI must not answer a bad flag value with a
1406
+ // stack trace. The thrown message already names the successor and the
1407
+ // retiring issue, so it is surfaced verbatim.
1408
+ let filename;
1409
+ try {
1410
+ filename = getProjectInstructionFile(pifRuntime);
1411
+ } catch (err) {
1412
+ if (err && err.code === 'GSD_RETIRED_RUNTIME') error(err.message);
1413
+ throw err;
1414
+ }
1173
1415
  process.stdout.write(filename + '\n');
1174
1416
  }
1175
1417
 
@@ -1238,9 +1480,21 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1238
1480
  // claims. `--directory` wins outright when given explicitly;
1239
1481
  // otherwise a supplied `--quick-id` + `--slug` pair derives the
1240
1482
  // canonical permalink the same way `workflows/quick.md` renders it.
1483
+ // #4906 Phase 3 (#4958, evidence #4736): `--status` is the OPTIONAL
1484
+ // widening that closes the last gap between this CLI and
1485
+ // `workflows/quick.md`'s own Step 7c row shapes. `appendQuickTaskRow`
1486
+ // (markdown-table.cjs) has always accepted `status` — the schema's
1487
+ // `Status` column exists precisely for `$VALIDATE_MODE` runs — but
1488
+ // nothing on this CLI surface could set it, so quick.md's
1489
+ // VALIDATE_MODE branch had no escaping-safe path and kept
1490
+ // interpolating `${DESCRIPTION}` into raw markdown (#4736 D1: an
1491
+ // unescaped `|` in a task description permanently rags the table).
1492
+ // Omitted, `status` stays undefined and `appendQuickTaskRow` falls
1493
+ // back to its own `'—'` default — unchanged for every existing
1494
+ // caller (fast.md, quick.md's non-VALIDATE_MODE branch).
1241
1495
  const qtaParsed = parseNamedArgsOrExit(
1242
1496
  qtaArgs,
1243
- { valueFlags: ['task', 'quick-id', 'slug', 'directory'], positionals: 'rest' },
1497
+ { valueFlags: ['task', 'quick-id', 'slug', 'directory', 'status'], positionals: 'rest' },
1244
1498
  error,
1245
1499
  );
1246
1500
  const qtaTask = qtaParsed.task || args[1];
@@ -1251,6 +1505,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1251
1505
  const qtaSlug = qtaParsed['slug'] || undefined;
1252
1506
  const qtaDirectory = qtaParsed['directory']
1253
1507
  || (qtaQuickId && qtaSlug ? `[${qtaQuickId}-${qtaSlug}](./quick/${qtaQuickId}-${qtaSlug}/)` : undefined);
1508
+ const qtaStatus = qtaParsed['status'] || undefined;
1254
1509
 
1255
1510
  const statePath = path.join(cwd, '.planning', 'STATE.md');
1256
1511
  if (!fs.existsSync(statePath)) {
@@ -1288,6 +1543,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1288
1543
  description: qtaTask,
1289
1544
  date,
1290
1545
  commit,
1546
+ status: qtaStatus,
1291
1547
  quickId: qtaQuickId,
1292
1548
  directory: qtaDirectory,
1293
1549
  });
@@ -1322,7 +1578,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1322
1578
  const fsx = require('node:fs');
1323
1579
  const os = require('node:os');
1324
1580
  const { REVIEWER_LANES, mergeReviewerLanes } = require('./lib/review-lane-descriptor.cjs');
1325
- const { resolveLanePlan, resolveLaneEffort } = require('./lib/review-lane-invocation.cjs');
1581
+ const { resolveLanePlan, resolveLaneEffort, resolveLaneBudget } = require('./lib/review-lane-invocation.cjs');
1326
1582
  const modelCatalog = require('./lib/model-catalog.cjs');
1327
1583
  const runner = require('./lib/review-lane-runner.cjs');
1328
1584
  const cfgLoader = require('./lib/config-loader.cjs');
@@ -1347,8 +1603,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1347
1603
  // `plan`/`invoke` are the only subs that need the expensive plan-building path
1348
1604
  // below; `sections`/`flags` return earlier still. Anything else errors here, before
1349
1605
  // any of that work starts.
1350
- if (!['plan', 'invoke', 'sections', 'flags'].includes(sub)) {
1351
- error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
1606
+ if (!['plan', 'invoke', 'sections', 'flags', 'dispatch-step', 'explicit-from-argv'].includes(sub)) {
1607
+ error("Usage: review-lane <plan|invoke|sections|flags|dispatch-step|explicit-from-argv> [--selected a,b] [--run-dir D] [--repo-root R]");
1352
1608
  return;
1353
1609
  }
1354
1610
  const runDir = flag('--run-dir') || '.';
@@ -1370,6 +1626,86 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1370
1626
  return cur;
1371
1627
  };
1372
1628
 
1629
+ // Shared by `invoke` and `dispatch-step` (#4209) — both need the SAME bounded
1630
+ // spawn/http/fs seam `runLane` requires (RunnerDeps). Factored out so the two
1631
+ // callers can never disagree about how a lane's binary is resolved or how its
1632
+ // process is bounded; a fix to either reaches both.
1633
+ const buildLaneRunnerDeps = () => ({
1634
+ spawn: (binary, argv, opts) => {
1635
+ // #3086: on Windows, reviewer CLIs (gemini, codex, etc.) are installed
1636
+ // as .cmd shims. spawnSync with a bare name + shell:false fails with
1637
+ // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1638
+ // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1639
+ // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1640
+ //
1641
+ // #3275: descriptors declare BARE names, so the gate above never saw an
1642
+ // extension — resolve through the shared PATH+PATHEXT resolver FIRST
1643
+ // (the same one `hasBinary` uses, so probe and spawn can never disagree
1644
+ // about what the lane's binary is). POSIX keeps the bare name: Node's own
1645
+ // PATH search already worked there, and the #3275 acceptance contract
1646
+ // holds macOS/Linux behavior unchanged. A name that resolves to nothing
1647
+ // falls back to the declared name so the ENOENT still surfaces (#3086).
1648
+ // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
1649
+ // private copies here; `projectSpawnInvocation` owns them, so a fix to either
1650
+ // reaches every spawn site instead of only this one.
1651
+ //
1652
+ // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
1653
+ // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
1654
+ // from the same resolver, so probe and spawn must agree on the exact file.
1655
+ const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
1656
+ const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
1657
+ const r = cp.spawnSync(spawnBinary, spawnArgv, {
1658
+ input: opts.input,
1659
+ encoding: 'utf8',
1660
+ timeout: opts.timeoutMs,
1661
+ killSignal: 'SIGKILL',
1662
+ maxBuffer: 64 * 1024 * 1024,
1663
+ shell: false, // argv array only — never a shell string (no interpolation of config values)
1664
+ // #2483: a lane's declared env pairs merged OVER this process's environment, for this
1665
+ // child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
1666
+ // into the orchestrating session or into the next lane.
1667
+ ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
1668
+ ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
1669
+ });
1670
+ return {
1671
+ status: r.status,
1672
+ stdout: r.stdout || '',
1673
+ stderr: r.stderr || '',
1674
+ errorCode: r.error && r.error.code ? r.error.code : undefined,
1675
+ };
1676
+ },
1677
+ httpJson: async (url, opts) => {
1678
+ try {
1679
+ const res = await fetch(url, {
1680
+ method: opts.method,
1681
+ headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
1682
+ body: opts.body,
1683
+ signal: AbortSignal.timeout(opts.timeoutMs),
1684
+ });
1685
+ return { ok: res.ok, status: res.status, body: await res.text() };
1686
+ } catch (e) {
1687
+ return { ok: false, status: 0, body: '', error: e && e.message ? e.message : String(e) };
1688
+ }
1689
+ },
1690
+ readFile: (p) => fsx.readFileSync(p, 'utf8'),
1691
+ writeFile: (p, c) => fsx.writeFileSync(p, c, 'utf8'),
1692
+ exists: (p) => fsx.existsSync(p),
1693
+ // PATH scan rather than spawning `command -v` / `where`. Two reasons: it spawns nothing at
1694
+ // all (a probe that costs a process is a probe you avoid running, which is how the original
1695
+ // Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
1696
+ // Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
1697
+ //
1698
+ // #3275: the scan lives in `resolveSpawnBinary` now, SHARED with `deps.spawn`
1699
+ // above. Two private copies of "what is this declared binary?" is how the
1700
+ // defect hid: the probe resolved WITH PATHEXT while spawn resolved WITHOUT,
1701
+ // so a lane reported available for a spawn that could never start. One
1702
+ // resolver, both seams — if one changes, the other changes with it.
1703
+ hasBinary: (name) => resolveSpawnBinary(name) !== null,
1704
+ configGet,
1705
+ homeDir: os.homedir(),
1706
+ warn: (m) => process.stderr.write(`${m}\n`),
1707
+ });
1708
+
1373
1709
  const selected = (flag('--selected') || '')
1374
1710
  .split(',').map((s) => s.trim()).filter(Boolean);
1375
1711
  // ADR-2782 D8 (#2927): the lane map is first-party ∪ INSTALLED overlay
@@ -1397,6 +1733,25 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1397
1733
  const laneBySlug = new Map(mergedLanes.map((l) => [l.slug, l]));
1398
1734
  const chosen = selected.length ? selected : mergedLanes.map((l) => l.slug);
1399
1735
 
1736
+ // #4209 RQ-02: match a workflow's raw CLI argv (e.g. `--codex`, `--agy`) against the SAME
1737
+ // merged first-party+installed-overlay roster this whole function already built above,
1738
+ // instead of a workflow re-deriving its own copy of `loadRegistry`/`mergeReviewerLanes` via
1739
+ // an inline `node -e` (a rename-only duplicate of the block starting at `mergedLanes =
1740
+ // REVIEWER_LANES` above — `code-review-flags.cjs`'s own header states "this is the canonical
1741
+ // flag-parsing surface — do not replicate inline bash parsing" for exactly this reason).
1742
+ // Everything after `--` is a candidate flag; matched lane slugs print sorted and comma-joined.
1743
+ if (sub === 'explicit-from-argv') {
1744
+ const sepIdx = args.indexOf('--');
1745
+ const candidateArgs = new Set(sepIdx === -1 ? [] : args.slice(sepIdx + 1));
1746
+ const slugs = [];
1747
+ for (const lane of mergedLanes) {
1748
+ const flags = Array.isArray(lane.flags) ? lane.flags : [];
1749
+ if (flags.some((f) => candidateArgs.has(f))) slugs.push(lane.slug);
1750
+ }
1751
+ process.stdout.write([...new Set(slugs)].sort().join(','));
1752
+ return;
1753
+ }
1754
+
1400
1755
  if (sub === 'sections') {
1401
1756
  const rows = chosen
1402
1757
  .map((s) => laneBySlug.get(s))
@@ -1454,26 +1809,135 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1454
1809
  };
1455
1810
  const effortFor = (lane) => resolveLaneEffort(lane, configGet, renderLaneEffort);
1456
1811
 
1457
- /**
1458
- * Per-lane prompt budget (#2797 semantics, preserved exactly).
1459
- *
1460
- * `-1` is the UNSET sentinel and falls back to the central `review.max_prompt_tokens`, because
1461
- * `0` is a legitimate value meaning "do not trim this lane". Treating 0 as unset would silently
1462
- * switch a user who deliberately disabled trimming onto the global budget.
1463
- *
1464
- * Only the budget VALUE is resolved here. Assembly and trimming stay in `prompt-budget`, which
1465
- * already owns that machinery and is already tested; the workflow calls it and hands the
1466
- * trimmed file back via `--prompt-file`. Re-implementing it inside the runner would fork a
1467
- * tested surface for no gain.
1468
- */
1469
- const budgetFor = (lane) => {
1470
- if (!lane.promptBudgetKey) return null;
1471
- const per = configGet(lane.promptBudgetKey);
1472
- const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
1473
- if (isNum(per) && per !== -1) return per;
1474
- const global = configGet('review.max_prompt_tokens');
1475
- return isNum(global) ? global : null;
1476
- };
1812
+ // Per-lane prompt budget: `resolveLaneBudget` (review-lane-invocation.cjs) owns the #2797
1813
+ // resolution semantics (shared with src/reviewer-step-dispatch.cts, #4209 R3 — was two
1814
+ // verbatim copies). Only the budget VALUE is resolved here; assembly/trimming stay in
1815
+ // `prompt-budget`, which the workflow calls, handing the trimmed file back via `--prompt-file`.
1816
+ const budgetFor = (lane) => resolveLaneBudget(lane, configGet);
1817
+
1818
+ // #4209 (ADR-2782 seam) — the ONE interpreter route for a step that declared
1819
+ // `supportsReviewerLanes: true`. Wires `dispatchReviewerLanes` (src/reviewer-step-dispatch.cts)
1820
+ // to the SAME plan/invoke machinery `plan`/`invoke` above use, so a step opting in gets exact
1821
+ // parity with hand-driven `review-lane plan|invoke` rather than a second implementation.
1822
+ // Canonical file paths travel on stdin, never argv (see gsd-core/workflows/code-review.md's
1823
+ // "Files travel on stdin" note) — a 50+-file scope with long paths approaches the Windows
1824
+ // execFileSync argv ceiling, and stdin has no such bound.
1825
+ //
1826
+ // Returns EARLY, like `sections`/`flags` above, rather than falling into the `plans` builder
1827
+ // below: that builder spawns one `effortFor` child process PER LANE IN THE ROSTER (chosen
1828
+ // defaults to every merged lane when nothing is selected), which would burn ~12 wasted spawns
1829
+ // on every dispatch-step call whether or not anything was actually selected. `dispatchReviewerLanes`
1830
+ // builds its own per-SELECTED-lane plan below instead, bounded by the (typically 0-3) explicitly
1831
+ // requested slugs, not the whole roster.
1832
+ if (sub === 'dispatch-step') {
1833
+ const { dispatchReviewerLanes } = require('./lib/reviewer-step-dispatch.cjs');
1834
+ const explicitFlags = (flag('--explicit') || '').split(',').map((s) => s.trim()).filter(Boolean);
1835
+ const depth = flag('--depth') || '';
1836
+ const baseSha = flag('--base-sha') || '';
1837
+ // #4209: this command IS the reusable capability/step-dispatch trait check — see
1838
+ // gsd-core/references/loop-hook-dispatch.md for what supportsReviewerLanes means and why
1839
+ // this is the one place it's resolved. Calls the SAME resolver `loop render-hooks` uses,
1840
+ // `resolveActiveHooksForPoint`, directly in-process — no subprocess, no JSON re-parse, and
1841
+ // no exposure to `io.cjs`'s `@file:` overflow protocol (which only applies to the
1842
+ // rendered-string envelope this path never touches).
1843
+ const capId = flag('--cap-id') || '';
1844
+ const point = flag('--point') || '';
1845
+ let trait = false;
1846
+ if (capId && point) {
1847
+ try {
1848
+ const { resolveActiveHooksForPoint } = loopResolver;
1849
+ const { activeHooks } = resolveActiveHooksForPoint(cwd, point);
1850
+ trait = activeHooks.some((h) => h && h.capId === capId && h.supportsReviewerLanes === true);
1851
+ } catch (e) {
1852
+ process.stderr.write(`Warning: reviewer-lane trait resolution failed for --cap-id ${capId} --point ${point}: ${e && e.message ? e.message : String(e)} — treating as not enabled.\n`);
1853
+ trait = false;
1854
+ }
1855
+ } else if (capId || point) {
1856
+ // #4209 RQ-03: exactly one of the two was passed — a caller with NO capability-step
1857
+ // context at all (neither flag) is the legitimate, silent no-op documented above, but a
1858
+ // caller that named a capability without its point (or vice versa) is misconfigured, not
1859
+ // opted out, and that must not look identical to a correct opt-out on the wire.
1860
+ process.stderr.write(`Warning: --cap-id and --point must both be given to resolve the reviewer-lane trait (got --cap-id=${JSON.stringify(capId)} --point=${JSON.stringify(point)}) — treating as not enabled.\n`);
1861
+ }
1862
+ // No piped stdin (interactive TTY): fail closed to empty paths instead of blocking
1863
+ // indefinitely on a TTY EOF the caller never sends.
1864
+ let stdinPaths = '';
1865
+ if (!process.stdin.isTTY) {
1866
+ try { stdinPaths = fsx.readFileSync(0, 'utf8'); } catch { stdinPaths = ''; }
1867
+ }
1868
+ const paths = stdinPaths.split('\n').map((s) => s.trim()).filter(Boolean);
1869
+
1870
+ // Reuse the exact effort-aware, per-lane plan `plan` builds above (DISP-03: "planned
1871
+ // through the existing `review-lane plan` interface") rather than the interpreter's
1872
+ // simpler default plan callback, which does not resolve per-host effort.
1873
+ const planFn = (lane, ctx) => {
1874
+ const effort = effortFor(lane.slug);
1875
+ return resolveLanePlan({
1876
+ lane, configGet: ctx.configGet, runDir: ctx.runDir, repoRoot: ctx.repoRoot,
1877
+ effortArgs: effort.argv, effortValue: effort.value,
1878
+ });
1879
+ };
1880
+
1881
+ const runnerDeps = buildLaneRunnerDeps();
1882
+ const invokeFn = async (lane, plan) => {
1883
+ let consentedHost;
1884
+ if (plan.transport === 'openai-http') {
1885
+ try {
1886
+ const consent = require('./lib/capability-consent.cjs');
1887
+ const projectRoot = require('./lib/project-root.cjs').consentProjectRoot(cwd);
1888
+ const capId = String(lane.slug).replace(/_/g, '-');
1889
+ consentedHost = consent.readConsentedReviewerHost({ projectRoot, id: capId });
1890
+ } catch { consentedHost = undefined; }
1891
+ }
1892
+ // DISP-04/05: every selected lane is invoked through this SAME `runner.runLane` seam
1893
+ // `invoke` uses, exactly once (the interpreter's own for-loop over `selection.selected`
1894
+ // never revisits a slug).
1895
+ return runner.runLane(plan, runnerDeps, { consentedHost, explicitlyRequested: true, repoRoot });
1896
+ };
1897
+
1898
+ // `resolveReviewerSelection` only selects an explicit flag present in `detected`
1899
+ // (ADR-2782 D4: absent-safe governs discovery, never explicit selection — a slug the
1900
+ // roster does not declare is rejected here as an explicit-selection error). REAL
1901
+ // host availability (is the CLI actually installed?) is a separate, already-owned
1902
+ // check inside `runner.runLane`'s `probeLane` at invoke time below — duplicating a
1903
+ // second `command -v` probe here would let the two disagree about what "available"
1904
+ // means, which is the exact defect class `resolveSpawnBinary` was consolidated to
1905
+ // prevent (#3275).
1906
+ //
1907
+ // GUARDED ON explicitFlags.length, not unconditional: `resolveReviewerSelection`'s
1908
+ // precedence chain (explicit > --all > review.default_reviewers > all detected) ends,
1909
+ // when none of the first three apply, in `selected = [...detected]` — the SAME
1910
+ // "no flags means every detected reviewer" default `/gsd:review` intentionally uses.
1911
+ // Source review's COMP-01 contract is the opposite: no reviewer-lane flag means inert,
1912
+ // unchanged from before #4209. Passing a non-empty
1913
+ // `detected` unconditionally would silently opt every dispatch-step call with no
1914
+ // `--explicit` into planning+invoking the WHOLE roster via that fallback branch. An
1915
+ // empty `detected` when nothing was asked for makes that fallback resolve to
1916
+ // `[...[]]` = `[]`, so `dispatchReviewerLanes` hits its own `NO_LANES_SELECTED`
1917
+ // early-return before any plan/invoke call — the same fast, inert no-op the caller
1918
+ // gets from an absent `supportsReviewerLanes` trait.
1919
+ const rosterSlugs = explicitFlags.length > 0 ? [...laneBySlug.keys()] : [];
1920
+
1921
+ const dispatchResult = await dispatchReviewerLanes(
1922
+ {
1923
+ trait,
1924
+ selection: { explicitFlags, detected: rosterSlugs },
1925
+ repoRoot,
1926
+ paths,
1927
+ depth,
1928
+ baseSha,
1929
+ runDir,
1930
+ },
1931
+ {
1932
+ getLane: (slug) => laneBySlug.get(slug),
1933
+ configGet,
1934
+ plan: planFn,
1935
+ invoke: invokeFn,
1936
+ },
1937
+ );
1938
+ output(dispatchResult, raw);
1939
+ return;
1940
+ }
1477
1941
 
1478
1942
  const plans = chosen.map((slug) => {
1479
1943
  const lane = laneBySlug.get(slug);
@@ -1508,7 +1972,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1508
1972
  }
1509
1973
 
1510
1974
  if (sub !== 'invoke') {
1511
- error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
1975
+ error("Usage: review-lane <plan|invoke|sections|flags|dispatch-step|explicit-from-argv> [--selected a,b] [--run-dir D] [--repo-root R]");
1512
1976
  return;
1513
1977
  }
1514
1978
 
@@ -1522,81 +1986,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1522
1986
 
1523
1987
  // EVERY spawn bounded — `DEFECT.UNBOUNDED-SUBPROCESS` (CONTEXT.md:772). A frozen sync spawn
1524
1988
  // cannot be interrupted by --test-force-exit and hangs a whole CI chunk to its 10-minute kill.
1525
- const deps = {
1526
- spawn: (binary, argv, opts) => {
1527
- // #3086: on Windows, reviewer CLIs (gemini, codex, etc.) are installed
1528
- // as .cmd shims. spawnSync with a bare name + shell:false fails with
1529
- // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1530
- // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1531
- // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1532
- //
1533
- // #3275: descriptors declare BARE names, so the gate above never saw an
1534
- // extension — resolve through the shared PATH+PATHEXT resolver FIRST
1535
- // (the same one `hasBinary` uses, so probe and spawn can never disagree
1536
- // about what the lane's binary is). POSIX keeps the bare name: Node's own
1537
- // PATH search already worked there, and the #3275 acceptance contract
1538
- // holds macOS/Linux behavior unchanged. A name that resolves to nothing
1539
- // falls back to the declared name so the ENOENT still surfaces (#3086).
1540
- // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
1541
- // private copies here; `projectSpawnInvocation` owns them, so a fix to either
1542
- // reaches every spawn site instead of only this one.
1543
- //
1544
- // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
1545
- // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
1546
- // from the same resolver, so probe and spawn must agree on the exact file.
1547
- const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
1548
- const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
1549
- const r = cp.spawnSync(spawnBinary, spawnArgv, {
1550
- input: opts.input,
1551
- encoding: 'utf8',
1552
- timeout: opts.timeoutMs,
1553
- killSignal: 'SIGKILL',
1554
- maxBuffer: 64 * 1024 * 1024,
1555
- shell: false, // argv array only — never a shell string (no interpolation of config values)
1556
- // #2483: a lane's declared env pairs merged OVER this process's environment, for this
1557
- // child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
1558
- // into the orchestrating session or into the next lane.
1559
- ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
1560
- ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
1561
- });
1562
- return {
1563
- status: r.status,
1564
- stdout: r.stdout || '',
1565
- stderr: r.stderr || '',
1566
- errorCode: r.error && r.error.code ? r.error.code : undefined,
1567
- };
1568
- },
1569
- httpJson: async (url, opts) => {
1570
- try {
1571
- const res = await fetch(url, {
1572
- method: opts.method,
1573
- headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
1574
- body: opts.body,
1575
- signal: AbortSignal.timeout(opts.timeoutMs),
1576
- });
1577
- return { ok: res.ok, status: res.status, body: await res.text() };
1578
- } catch (e) {
1579
- return { ok: false, status: 0, body: '', error: e && e.message ? e.message : String(e) };
1580
- }
1581
- },
1582
- readFile: (p) => fsx.readFileSync(p, 'utf8'),
1583
- writeFile: (p, c) => fsx.writeFileSync(p, c, 'utf8'),
1584
- exists: (p) => fsx.existsSync(p),
1585
- // PATH scan rather than spawning `command -v` / `where`. Two reasons: it spawns nothing at
1586
- // all (a probe that costs a process is a probe you avoid running, which is how the original
1587
- // Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
1588
- // Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
1589
- //
1590
- // #3275: the scan lives in `resolveSpawnBinary` now, SHARED with `deps.spawn`
1591
- // above. Two private copies of "what is this declared binary?" is how the
1592
- // defect hid: the probe resolved WITH PATHEXT while spawn resolved WITHOUT,
1593
- // so a lane reported available for a spawn that could never start. One
1594
- // resolver, both seams — if one changes, the other changes with it.
1595
- hasBinary: (name) => resolveSpawnBinary(name) !== null,
1596
- configGet,
1597
- homeDir: os.homedir(),
1598
- warn: (m) => process.stderr.write(`${m}\n`),
1599
- };
1989
+ const deps = buildLaneRunnerDeps();
1600
1990
 
1601
1991
  // ADR-1517 reviewer instances resolve THROUGH a lane rather than being lanes themselves
1602
1992
  // (ADR-2782 D8), so they reuse this seam with three substitutions instead of duplicating the
@@ -2858,6 +3248,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2858
3248
  worktreeSafety.cmdWorktreeRecordAgent(cwd, args.slice(2));
2859
3249
  } else if (subcommand === 'reap-orphans') {
2860
3250
  worktreeSafety.cmdWorktreeReapOrphans(cwd);
3251
+ } else if (subcommand === 'worker-record') {
3252
+ worktreeSafety.cmdWorktreeWorkerRecord(cwd, args.slice(2));
3253
+ } else if (subcommand === 'worker-status') {
3254
+ worktreeSafety.cmdWorktreeWorkerStatus(cwd, args.slice(2));
3255
+ } else if (subcommand === 'worker-complete') {
3256
+ worktreeSafety.cmdWorktreeWorkerComplete(cwd, args.slice(2));
2861
3257
  } else if (subcommand === 'base-check') {
2862
3258
  require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
2863
3259
  } else if (subcommand === 'set-baseref') {
@@ -2865,7 +3261,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2865
3261
  } else if (subcommand === 'create') {
2866
3262
  worktreeSafety.cmdWorktreeCreate(cwd, args.slice(2));
2867
3263
  } else {
2868
- error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref, create', ERROR_REASON.SDK_UNKNOWN_COMMAND);
3264
+ error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref, create, worker-record, worker-status, worker-complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2869
3265
  }
2870
3266
  }
2871
3267
 
@@ -3147,6 +3543,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3147
3543
  const RESTORE_OUTCOME = Object.freeze({
3148
3544
  ELIGIBLE: 'eligible',
3149
3545
  RESTORED: 'restored',
3546
+ ALREADY_PRESENT: 'already_present',
3150
3547
  SKIPPED_DESTINATION_MANAGED: 'skipped_destination_managed',
3151
3548
  SKIPPED_DESTINATION_EXISTS: 'skipped_destination_exists',
3152
3549
  SKIPPED_COPY_FAILED: 'skipped_copy_failed',
@@ -3205,18 +3602,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3205
3602
  return out;
3206
3603
  }
3207
3604
 
3208
- // Why these three checks rather than security.cjs's `validatePath`: that seam
3209
- // resolves symlinks with realpathSync and then tests containment, so a link
3210
- // whose target sits inside the config dir passes. For a restore that is still
3211
- // wrong — writing through any link overwrites whatever it points at instead
3212
- // of materializing a regular file at the backed-up path. These checks reject
3213
- // links outright, which is strictly stricter than validatePath, not a
3214
- // reimplementation of it. Do not "simplify" this to validatePath.
3605
+ // Why these three checks rather than security.cjs's `assertWithinRoot` /
3606
+ // `tryWithinRoot`: that seam resolves symlinks with realpathSync and then
3607
+ // tests containment, so a link whose target sits inside the config dir
3608
+ // passes. For a restore that is still wrong — writing through any link
3609
+ // overwrites whatever it points at instead of materializing a regular file
3610
+ // at the backed-up path. These checks reject links outright, which is
3611
+ // strictly stricter than assertWithinRoot/tryWithinRoot, not a
3612
+ // reimplementation of them. Do not "simplify" this to assertWithinRoot or
3613
+ // tryWithinRoot. Reviewed under epic #4636 Phase 3: the containment
3614
+ // DECISION now routes through the canonical lexical predicate
3615
+ // (`tryWithinRootLexical`, ADR-4650 decision 6); isInsideDir below still
3616
+ // treats target === root as NOT contained via its own extra `!==` check
3617
+ // (unlike every other containment implementation in this repo, which
3618
+ // treats target === root as contained) — that condition is this gate's
3619
+ // own and is layered on top of the shared predicate, not folded into it.
3215
3620
 
3216
3621
  /** True when `target` resolves strictly inside `root`. */
3217
3622
  function isInsideDir(root, target) {
3218
- const rel = path.relative(path.resolve(root), path.resolve(target));
3219
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
3623
+ // Containment decision: canonical lexical predicate (ADR-4650 decision 6).
3624
+ // The extra `!==` condition is this gate's own: a restore must never
3625
+ // target the config directory itself, only something strictly inside it.
3626
+ if (path.resolve(target) === path.resolve(root)) return false;
3627
+ const { tryWithinRootLexical } = require('./lib/security.cjs');
3628
+ return tryWithinRootLexical(target, root) !== null;
3220
3629
  }
3221
3630
 
3222
3631
  /**
@@ -3414,9 +3823,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3414
3823
  }
3415
3824
 
3416
3825
  // An identical destination is a no-op restore, not a conflict: re-running
3417
- // the restore after a successful one must stay quiet and idempotent.
3826
+ // the restore after a successful one must stay quiet and idempotent. It
3827
+ // gets its own outcome so it is excluded from eligible_count — the update
3828
+ // workflow drives its restore question off that count (#4558).
3829
+ let destExists = false;
3418
3830
  let destDiffers = false;
3419
3831
  if (fs.existsSync(destPath)) {
3832
+ destExists = true;
3420
3833
  try {
3421
3834
  destDiffers = !fs.readFileSync(destPath).equals(fs.readFileSync(srcPath));
3422
3835
  } catch {
@@ -3431,6 +3844,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3431
3844
  entries.push({ path: relPath, outcome: RESTORE_OUTCOME.SKIPPED_DESTINATION_EXISTS, warnings });
3432
3845
  continue;
3433
3846
  }
3847
+ if (destExists) {
3848
+ entries.push({ path: relPath, outcome: RESTORE_OUTCOME.ALREADY_PRESENT, warnings });
3849
+ continue;
3850
+ }
3434
3851
 
3435
3852
  if (!apply) {
3436
3853
  entries.push({ path: relPath, outcome: RESTORE_OUTCOME.ELIGIBLE, warnings });
@@ -4146,6 +4563,31 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4146
4563
  * declares that file "All OS-facing I/O; single platform seam", and a private
4147
4564
  * duplicate here is what made it untrue.
4148
4565
  */
4566
+ const COMMIT_LIST_FLAGS = new Set(['--files', '--files-removed']);
4567
+
4568
+ // #4208 review: hoisted out of routeCommit's closure so the parser is reachable
4569
+ // from a test. It is the whole of the two-list argument contract, and its edge
4570
+ // cases (a boolean flag inside a run, a repeated list flag, either order) were
4571
+ // already the subject of a review round -- a parser that only the CLI can reach
4572
+ // can only be tested by example, one spawn at a time.
4573
+ //
4574
+ // Every occurrence of `flag` contributes a run; a run ends at the next LIST
4575
+ // flag and skips boolean flags on the way, so no token strictly between one
4576
+ // list flag and the next is ever dropped. Repeated runs of the same flag merge,
4577
+ // as the pre-#4208 slice-to-end parse merged them.
4578
+ function collectListFlagValues(args, flag) {
4579
+ const values = [];
4580
+ args.forEach((a, i) => {
4581
+ if (a !== flag) return;
4582
+ for (const b of args.slice(i + 1)) {
4583
+ if (COMMIT_LIST_FLAGS.has(b)) break;
4584
+ if (b.startsWith('--')) continue;
4585
+ values.push(b);
4586
+ }
4587
+ });
4588
+ return values;
4589
+ }
4590
+
4149
4591
  function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
4150
4592
  const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
4151
4593
  return resolveExecutableBinary(name, { platform, env });
@@ -4187,6 +4629,21 @@ const HOST_COMMAND_ROUTERS = {
4187
4629
  // rather than a family — ADR-2346 promotes to a family only at >=3.
4188
4630
  'estimate-check': ({ args, cwd, raw }) => estimateCli.cmdEstimateCheck(cwd, args.slice(1), raw),
4189
4631
  'estimate-calibration': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibration(cwd, args.slice(1), raw),
4632
+ // #3418: writes `last_mapped_commit` into every codebase-map document that
4633
+ // exists, closing the loop drift.cjs was built for. A LEAF verb rather than a
4634
+ // `verify` subcommand on purpose -- the verify family is read-only by
4635
+ // contract and this one mutates; ADR-2346 promotes a leaf to a family only at
4636
+ // >=3 verbs, and this is one.
4637
+ 'stamp-codebase-map': ({ args, cwd, raw, error }) => {
4638
+ const { files } = parseNamedArgsOrExit(args, { valueFlags: ['files'], positionals: 1 }, error);
4639
+ // A value flag with no value parses to `null`, same as an absent one, so
4640
+ // presence is read off `args`: a bare `--files` (an unquoted empty shell
4641
+ // variable) must hit the empty-filter refusal, not widen to all seven.
4642
+ const only = args.includes('--files')
4643
+ ? String(files ?? '').split(',').map((f) => f.trim()).filter(Boolean)
4644
+ : undefined;
4645
+ verify.cmdStampCodebaseMap(cwd, raw, only);
4646
+ },
4190
4647
  'estimate-calibrate': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibrate(cwd, args.slice(1), raw),
4191
4648
  'config-new-project': routeConfigNewProject,
4192
4649
  'config-path': routeConfigPath,
@@ -4198,6 +4655,8 @@ const HOST_COMMAND_ROUTERS = {
4198
4655
  'smart-entry': routeSmartEntry,
4199
4656
  'check': routeCheck,
4200
4657
  'find-phase': routeFindPhase,
4658
+ // #4906 Phase 5 (#4661): commit-scope selection for /gsd:undo --phase/--plan.
4659
+ 'select-revert-commits': routeSelectRevertCommits,
4201
4660
  'commit': routeCommit,
4202
4661
  'check-commit': routeCheckCommit,
4203
4662
  'commit-docs-guard': routeCommitDocsGuard,
@@ -4275,7 +4734,7 @@ const HOST_COMMAND_ROUTERS = {
4275
4734
  // through. Prototype-pollution-safe: own-property lookup rejects
4276
4735
  // `__proto__`/`constructor`/`prototype` command keys (same guard as
4277
4736
  // dispatchCapabilityCommand).
4278
- async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext }) {
4737
+ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd }) {
4279
4738
  if (
4280
4739
  command === '__proto__' ||
4281
4740
  command === 'constructor' ||
@@ -4290,7 +4749,13 @@ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValu
4290
4749
  if (typeof router !== 'function') return false;
4291
4750
  // `await` so async host routers (e.g. capability's install/upgrade ops)
4292
4751
  // complete before runCommand returns; sync routers pass through unchanged.
4293
- await router({ args, cwd, raw, error, defaultValue, workstreamContext });
4752
+ // `invokingCwd` (#4906/#4465): the pre-worktree-remap cwd, for the rare
4753
+ // router (currently only `select-revert-commits`) whose own git operation
4754
+ // must see the INVOKING worktree's HEAD, never the main-worktree remap
4755
+ // every other router legitimately wants via `cwd`. Optional — callers/tests
4756
+ // that omit it (every existing one) leave routers that don't read it
4757
+ // byte-unaffected.
4758
+ await router({ args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd });
4294
4759
  return true; // consumed — don't emit "Unknown command"
4295
4760
  }
4296
4761
 
@@ -4323,7 +4788,7 @@ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValu
4323
4788
  // keep working. No shell is spawned (argv array) — no injection surface beyond
4324
4789
  // the old `timeout … bash -c "$CMD"`.
4325
4790
  function runWithTimeout(argv) {
4326
- const { spawn } = require('node:child_process');
4791
+ const { spawn, spawnSync } = require('node:child_process');
4327
4792
  const os = require('node:os');
4328
4793
 
4329
4794
  const USAGE = 'Usage: gsd_run run-with-timeout <seconds> [--] <command> [args...]';
@@ -4348,9 +4813,11 @@ function runWithTimeout(argv) {
4348
4813
 
4349
4814
  const isWin = process.platform === 'win32';
4350
4815
  // Detached (own process group) on POSIX so a timeout can reap the WHOLE tree —
4351
- // a bare child.kill() misses grandchildren (e.g. a test runner's workers) and
4352
- // would not actually bound the wall clock. Windows has no POSIX process
4353
- // groups; a direct kill is the best portable option there.
4816
+ // a bare child.kill() misses grandchildren (e.g. a test runner's workers).
4817
+ // Windows has no POSIX process groups and process.kill(-pid) is unsupported
4818
+ // there, so EVERY killTree attempt on Windows tree-kills via
4819
+ // `taskkill /PID <pid> /T /F` while the root is alive (see killTree) — by the
4820
+ // time the direct child exits, its descendants are already orphaned.
4354
4821
  const detached = !isWin && secs > 0;
4355
4822
  const spawnFailureCode = (err) =>
4356
4823
  (err && err.code === 'ENOENT' ? 127 : err && err.code === 'EACCES' ? 126 : 125);
@@ -4374,9 +4841,26 @@ function runWithTimeout(argv) {
4374
4841
  // cap's process-group kill (the wrapped child escapes reap → exit 124 never
4375
4842
  // fires) and risks cmd.exe mis-parsing an arg like `-e "setTimeout(()=>{})"`.
4376
4843
  // Only .cmd/.bat are the CVE-2024-27980 EINVAL cases that require mediation.
4844
+ // #4797: the mediation is NOT hand-rolled here — the private `/d /s /c <cmd>
4845
+ // ...args` copy broke on any shim path containing a space (Node quotes the
4846
+ // argv token; `/s` strips the FIRST and LAST quote of the /c string, so
4847
+ // cmd.exe took the pre-space fragment as the program). projectSpawnInvocation
4848
+ // (the declared single owner, #3411/#3617) wraps the WHOLE command line in
4849
+ // one extra quote pair with windowsVerbatimArguments — the shape that
4850
+ // survives spaces. One behavior delta, accepted: the seam DECLINES mediation
4851
+ // when the target or an arg carries CR/LF (the old block mediated anyway) —
4852
+ // the unmediated spawn of a .cmd then fails EINVAL, loud, at the catch below.
4377
4853
  const winShim = isWin && /\.(cmd|bat)$/i.test(path.basename(cmd));
4378
- const spawnCmd = winShim ? (process.env.ComSpec || 'cmd.exe') : cmd;
4379
- const spawnArgs = winShim ? ['/d', '/s', '/c', cmd, ...cmdArgs] : cmdArgs;
4854
+ let spawnCmd = cmd;
4855
+ let spawnArgs = cmdArgs;
4856
+ let spawnOpts;
4857
+ if (winShim) {
4858
+ const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
4859
+ const inv = projectSpawnInvocation(cmd, cmdArgs);
4860
+ spawnCmd = inv.command;
4861
+ spawnArgs = inv.args;
4862
+ if (inv.windowsVerbatimArguments) spawnOpts = { windowsVerbatimArguments: true };
4863
+ }
4380
4864
  // Node's setTimeout delay is a 32-bit signed ms int; a larger value silently
4381
4865
  // clamps to 1ms → a spurious immediate timeout. Cap the budget (~24.8 days).
4382
4866
  const timerMs = Math.min(Math.round(secs * 1000), 2 ** 31 - 1);
@@ -4387,11 +4871,13 @@ function runWithTimeout(argv) {
4387
4871
  return new Promise((resolve) => {
4388
4872
  let child;
4389
4873
  try {
4390
- // #2667: on win32 `.cmd`/`.bat`/`.exe`, spawn cmd.exe with an explicit argv
4391
- // array (spawnCmd/spawnArgs) rather than the shim directly — preserves the
4392
- // array-only, no-shell-string argv contract. `detached` is always false on
4393
- // win32, so it never co-occurs with the cmd.exe mediation.
4394
- child = spawn(spawnCmd, spawnArgs, { stdio: 'inherit', detached });
4874
+ // #2667: on win32 `.cmd`/`.bat` shims, spawn cmd.exe with an explicit
4875
+ // argv ARRAY rather than the shim directly — preserves the array-only,
4876
+ // no-shell-string argv contract. #4797: the exact argv shape (quote
4877
+ // wrapping, verbatim arguments) is projected by projectSpawnInvocation —
4878
+ // see the block above. `detached` is always false on win32, so it never
4879
+ // co-occurs with the cmd.exe mediation.
4880
+ child = spawn(spawnCmd, spawnArgs, { stdio: 'inherit', detached, ...spawnOpts });
4395
4881
  } catch (err) {
4396
4882
  process.stderr.write(`run-with-timeout: ${cmd}: ${err && err.message ? err.message : 'failed to start'}\n`);
4397
4883
  resolve(spawnFailureCode(err));
@@ -4403,6 +4889,27 @@ function runWithTimeout(argv) {
4403
4889
  if (detached && child.pid) {
4404
4890
  try { process.kill(-child.pid, signal); return; } catch { /* group already gone */ }
4405
4891
  }
4892
+ if (isWin && child.pid) {
4893
+ // #4601: Windows has no POSIX process groups, so the tree kill rides
4894
+ // on `taskkill /T`, which walks the child's descendants the way
4895
+ // `process.kill(-pid)` reaches a POSIX group — this is what bounds
4896
+ // the wall clock when the direct child mediates (cmd.exe /c shim) or
4897
+ // spawns its own children. Deliberately NOT gated on the SIGKILL
4898
+ // stage: child.kill on Windows is TerminateProcess regardless of
4899
+ // signal, so by the time the direct child exits its descendants are
4900
+ // orphaned and no taskkill can reach them — the tree kill must ride
4901
+ // the FIRST attempt, while the root is still alive. /F is required:
4902
+ // without it taskkill posts WM_CLOSE, which a headless CLI never
4903
+ // pumps. Spawned as an argv array per the no-shell-for-argv-array
4904
+ // contract, and bounded — a non-zero/absent status means taskkill
4905
+ // lost a race with an exiting process, and we fall through to the
4906
+ // direct kill so the attempt is never weaker than before.
4907
+ const reap = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
4908
+ encoding: 'utf8',
4909
+ timeout: 15000, // taskkill /T is sub-second in practice; bounded so a wedged taskkill can't hang the gate
4910
+ });
4911
+ if (reap.status === 0) return;
4912
+ }
4406
4913
  child.kill(signal);
4407
4914
  } catch { /* already exited */ }
4408
4915
  };
@@ -4485,7 +4992,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <fiel
4485
4992
  'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, planning, profile-questionnaire, ' +
4486
4993
  'profile-sample, progress, project-instruction-file, prompt-budget, quick-batch, quick-tasks-append, quick-tasks-migrate, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, runtime-identity, scaffold, smart-entry, state, ' +
4487
4994
  'config-set-model-profile, dispatch-capacity, dispatch-isolation, dispatch-should-flatten, inspect-dispatch-isolation, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-agent, resolve-dispatch-type, ' +
4488
- 'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
4995
+ 'resolve-execution, review-lane, select-revert-commits, skill-manifest, skills-root, stamp-codebase-map, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
4489
4996
  'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
4490
4997
  'Global flags:\n' +
4491
4998
  ' --raw Emit raw output without post-processing\n' +
@@ -4707,10 +5214,25 @@ async function main() {
4707
5214
  cwd = resolvedProjectDir;
4708
5215
  projectDirExplicit = true;
4709
5216
  }
5217
+ // #4894: verification derives its root from a phase directory, not `cwd`, so
5218
+ // hand it the validated explicit root. Always (re)set — `null` when the flag is
5219
+ // absent — so a prior in-process main() call can never leak an override.
5220
+ projectRoot.setExplicitProjectRoot(projectDirExplicit ? cwd : null);
4710
5221
 
4711
5222
  // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
4712
5223
  // However, in monorepo worktrees where the subdirectory itself owns .planning/,
4713
5224
  // skip worktree resolution — the CWD is already the correct project root.
5225
+ //
5226
+ // #4906/#4465: this remap is a BLANKET rewrite applied to every command, before
5227
+ // `command` is even known — most commands need it (they read `.planning/`, which
5228
+ // in a linked worktree lives only in the main one). A command whose own git
5229
+ // operation must run against the INVOKING worktree's OWN HEAD/branch (never the
5230
+ // main worktree's) needs the PRE-remap value instead — preserved here so it can be
5231
+ // threaded through dispatchHostCommand as `invokingCwd` (see routeSelectRevertCommits,
5232
+ // the first consumer: its `git log` must see the caller's own reachable history, not
5233
+ // main's, or a linked-worktree revert range silently resolves against the wrong repo
5234
+ // state — reproduced in tests/undo-commit-selection-4465.test.cjs's linked-worktree case).
5235
+ const preWorktreeRemapCwd = cwd;
4714
5236
  cwd = resolveMainWorktreeCwd(cwd);
4715
5237
 
4716
5238
  // Optional workstream override for parallel milestone work.
@@ -4836,7 +5358,7 @@ async function main() {
4836
5358
  // false "output was not JSON" (negative space N8).
4837
5359
  if (pickField) {
4838
5360
  const captured = await captureStdoutSyncWrites(async () => {
4839
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
5361
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd);
4840
5362
  });
4841
5363
  const resolved = resolveAtFileOutput(captured);
4842
5364
  let obj;
@@ -4868,7 +5390,7 @@ async function main() {
4868
5390
  // every workflow to have a bash-specific `if [[ "$INIT" == @file:* ]]` check
4869
5391
  // that breaks on PowerShell and other non-bash shells.
4870
5392
  const captured = await captureStdoutSyncWrites(async () => {
4871
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
5393
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd);
4872
5394
  });
4873
5395
  fs.writeSync(1, resolveAtFileOutput(captured));
4874
5396
  }
@@ -4984,7 +5506,7 @@ function extractField(obj, fieldPath) {
4984
5506
  return { found: true, value: current };
4985
5507
  }
4986
5508
 
4987
- async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
5509
+ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null, invokingCwd) {
4988
5510
  switch (command) {
4989
5511
 
4990
5512
  default: {
@@ -5005,7 +5527,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
5005
5527
  // commands (state, …) routed via their `route*Command` router instead of
5006
5528
  // a hardcoded `case` arm. Tried after capability/overlay dispatch and
5007
5529
  // before the unknown-command error.
5008
- if (await dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext })) break;
5530
+ if (await dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd })) break;
5009
5531
 
5010
5532
  // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
5011
5533
  // above split it so `command` here is the head ("foo"). Use
@@ -5050,6 +5572,10 @@ module.exports = {
5050
5572
  // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
5051
5573
  // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
5052
5574
  resolveSpawnBinary,
5575
+ // #4208 review: exported for tests — the two-list commit parser is otherwise
5576
+ // reachable only by spawning the CLI, which a property test cannot afford.
5577
+ collectListFlagValues,
5578
+ COMMIT_LIST_FLAGS,
5053
5579
  // #3714 follow-up: exported for tests — the dispatch model-pin VALUE
5054
5580
  // policy (charset accept/render parity, max-length boundary, leading-char
5055
5581
  // anchor) is otherwise unreachable from outside the dispatchOverlayCapabilityCommand closure.