@opengsd/gsd-core 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -19,20 +19,20 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
19
19
  // providing a deterministic failure path when git stalls (locked index, hung
20
20
  // remote, stalled NFS mount, etc.). Callers can override via deps.timeout.
21
21
  const DEFAULT_GIT_TIMEOUT_MS = 10000;
22
- const WORKTREE_AGENT_BRANCH_RE = /^(worktree-)?agent-[A-Za-z0-9._/-]+$/;
22
+ // #3021: accept the Workflow tool's worktree-wf_<runid>-<n> naming convention
23
+ // (claude-orchestration's isolation:"worktree" emission) alongside the
24
+ // existing agent-<id> / worktree-agent-<id> shapes.
25
+ const WORKTREE_AGENT_BRANCH_RE = /^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/;
23
26
  const WORKTREE_AGENT_BRANCH_PATTERN = WORKTREE_AGENT_BRANCH_RE.source;
24
27
  /**
25
- * Execute a git command via the shell-projection seam, with a derived
26
- * `timedOut` field. Tests inject mocks via deps.execGit using the new
28
+ * Execute a git command via the shell-projection seam, applying the module's
29
+ * default timeout. `timedOut` is now derived by the seam itself
30
+ * (shell-command-projection.cts's `_spawnResult`), so this is a thin
31
+ * passthrough. Tests inject mocks via deps.execGit using the same
27
32
  * (args, opts) shape — see worktree-safety-policy.test.cjs.
28
- *
29
- * Return shape: { exitCode, stdout, stderr, timedOut, error, signal }
30
- * - timedOut: true when spawnSync reports SIGTERM + ETIMEDOUT
31
33
  */
32
34
  function execGitDefault(args, opts = {}) {
33
- const result = (0, shell_command_projection_cjs_1.execGit)(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
34
- const timedOut = result.signal === 'SIGTERM' && result.error?.code === 'ETIMEDOUT';
35
- return { ...result, timedOut };
35
+ return (0, shell_command_projection_cjs_1.execGit)(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
36
36
  }
37
37
  function parseWorktreePorcelain(porcelain) {
38
38
  return parseWorktreeEntries(porcelain).filter((entry) => entry.branch !== null).map((entry) => ({
@@ -92,19 +92,39 @@ function readWorktreeList(repoRoot, deps = {}) {
92
92
  entries: parseWorktreeEntries(listResult.stdout),
93
93
  };
94
94
  }
95
- function resolveWorktreeContext(cwd, deps = {}) {
95
+ /**
96
+ * Shortcut-free git-dir-vs-git-common-dir comparison: the actual primitive
97
+ * that distinguishes a linked worktree from the main worktree.
98
+ *
99
+ * Deliberately factored out of `resolveWorktreeContext` (#3045). That
100
+ * function's `has_local_planning` shortcut answers a DIFFERENT question ("is
101
+ * there already a usable project root right here") and must NOT be consulted
102
+ * for isolation detection: a git worktree created specifically to isolate an
103
+ * executor is a full checkout, so it normally has its OWN checked-out
104
+ * `.planning/` too. A caller that ran the shortcut first would read that
105
+ * correctly-isolated worktree as `current_directory`/`has_local_planning` —
106
+ * i.e. "not isolated" — a false positive that defeats the very isolation
107
+ * guard that needs this check (see `hooks/gsd-cursor-subagent-start.js`,
108
+ * #3045). `resolveWorktreeLinkage` always performs the real git-dir
109
+ * comparison, independent of whether `.planning` exists locally.
110
+ */
111
+ function resolveWorktreeLinkage(cwd, deps = {}) {
96
112
  const execGit = deps.execGit || execGitDefault;
97
- const existsSync = deps.existsSync || node_fs_1.default.existsSync;
98
- // Local .planning takes precedence over linked-worktree remapping.
99
- if (existsSync(node_path_1.default.join(cwd, '.planning'))) {
113
+ const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
114
+ const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
115
+ // A TIMEOUT means the command never completed — it is not evidence of "not a
116
+ // git repository" (which completes fast, with a clean non-zero exit). Surface
117
+ // it under a distinct reason so callers can tell "genuinely not a repo" apart
118
+ // from "could not determine" (#3050). effectiveRoot still degrades to cwd
119
+ // (there is no safer default without a resolved git-dir), but the reason is
120
+ // no longer indistinguishable from the benign case.
121
+ if (gitDir.timedOut || commonDir.timedOut) {
100
122
  return {
101
123
  effectiveRoot: cwd,
102
124
  mode: 'current_directory',
103
- reason: 'has_local_planning',
125
+ reason: 'git_timed_out',
104
126
  };
105
127
  }
106
- const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
107
- const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
108
128
  if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) {
109
129
  return {
110
130
  effectiveRoot: cwd,
@@ -127,6 +147,18 @@ function resolveWorktreeContext(cwd, deps = {}) {
127
147
  reason: 'main_worktree',
128
148
  };
129
149
  }
150
+ function resolveWorktreeContext(cwd, deps = {}) {
151
+ const existsSync = deps.existsSync || node_fs_1.default.existsSync;
152
+ // Local .planning takes precedence over linked-worktree remapping.
153
+ if (existsSync(node_path_1.default.join(cwd, '.planning'))) {
154
+ return {
155
+ effectiveRoot: cwd,
156
+ mode: 'current_directory',
157
+ reason: 'has_local_planning',
158
+ };
159
+ }
160
+ return resolveWorktreeLinkage(cwd, deps);
161
+ }
130
162
  function planWorktreePrune(repoRoot, options = {}, deps = {}) {
131
163
  const parsePorcelain = deps.parseWorktreePorcelain || parseWorktreePorcelain;
132
164
  const destructiveModeRequested = Boolean(options.allowDestructive);
@@ -140,17 +172,26 @@ function planWorktreePrune(repoRoot, options = {}, deps = {}) {
140
172
  };
141
173
  }
142
174
  let worktrees = [];
175
+ let parseFailed = false;
143
176
  try {
144
177
  worktrees = parsePorcelain(listed.porcelain);
145
178
  }
146
179
  catch {
147
180
  // Keep historical behavior: still run metadata prune when parsing fails.
181
+ // #3050/#3057 (B6): but the reason must NOT collide with the
182
+ // genuinely-empty-list case below — a parser that could not read the
183
+ // porcelain output is not the same fact as "there are no worktrees", and
184
+ // this plan drives a PRUNE, so conflating them means a prune decision made
185
+ // on unread data would be indistinguishable from one made on real data.
148
186
  worktrees = [];
187
+ parseFailed = true;
149
188
  }
150
189
  return {
151
190
  repoRoot,
152
191
  action: 'metadata_prune_only',
153
- reason: worktrees.length === 0 ? 'no_worktrees' : 'worktrees_present',
192
+ reason: parseFailed
193
+ ? 'parse_failed'
194
+ : (worktrees.length === 0 ? 'no_worktrees' : 'worktrees_present'),
154
195
  destructiveModeRequested,
155
196
  };
156
197
  }
@@ -220,13 +261,25 @@ function inspectWorktreeHealth(repoRoot, options = {}, deps = {}) {
220
261
  }
221
262
  const findings = [];
222
263
  for (const entry of inventory.entries) {
223
- if (!entry.exists) {
264
+ if (entry.exists === 'absent') {
224
265
  findings.push({
225
266
  kind: 'orphan',
226
267
  path: entry.path,
227
268
  });
228
269
  continue;
229
270
  }
271
+ if (entry.exists === 'unverified') {
272
+ // #3050/#3057 (B5): existsSync confirmed the path is present but statSync
273
+ // threw, so age/staleness could not be determined. This is neither
274
+ // "orphan" (existsSync says it IS there) nor "healthy" (we never verified
275
+ // it) — surface it as its own finding so a caller can't silently treat an
276
+ // unverifiable worktree as confirmed present-and-not-stale.
277
+ findings.push({
278
+ kind: 'unverified',
279
+ path: entry.path,
280
+ });
281
+ continue;
282
+ }
230
283
  if (entry.isStale) {
231
284
  findings.push({
232
285
  kind: 'stale',
@@ -256,7 +309,7 @@ function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
256
309
  }
257
310
  const entries = [];
258
311
  for (const worktreePath of listed.paths) {
259
- let exists = false;
312
+ let exists = 'absent';
260
313
  let isStale = false;
261
314
  let ageMinutes = null;
262
315
  if (!existsSync(worktreePath)) {
@@ -268,9 +321,9 @@ function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
268
321
  });
269
322
  continue;
270
323
  }
271
- exists = true;
272
324
  try {
273
325
  const stat = statSync(worktreePath);
326
+ exists = 'present';
274
327
  const ageMs = nowMs - stat.mtimeMs;
275
328
  ageMinutes = Math.round(ageMs / 60000);
276
329
  if (ageMs > staleAfterMs) {
@@ -278,7 +331,12 @@ function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
278
331
  }
279
332
  }
280
333
  catch {
281
- // Keep historical behavior: stat failures are ignored.
334
+ // #3050/#3057 (B5): a statSync throw means presence could not be
335
+ // verified — do NOT report exists:'present' (a guard that could not
336
+ // check must not claim the worktree is confirmed present). Distinguish
337
+ // from the genuinely-absent case above with a third state ('unverified')
338
+ // rather than silently falling through to the pre-existing 'present' default.
339
+ exists = 'unverified';
282
340
  }
283
341
  entries.push({
284
342
  path: worktreePath,
@@ -308,13 +366,24 @@ function normalizeCleanupManifestEntry(entry) {
308
366
  return null;
309
367
  const rawAllowedBases = Array.isArray(e.allowed_bases) ? e.allowed_bases : [];
310
368
  const allowedBases = Array.from(new Set([expectedBase, ...rawAllowedBases.filter((base) => typeof base === 'string' && base.length > 0)]));
311
- return {
369
+ // #2596: liberal in what we accept — non-array, or non-string / empty
370
+ // elements, are dropped rather than coerced. An EMPTY result omits the field
371
+ // entirely, so "declared nothing" is indistinguishable from "not recorded":
372
+ // that ambiguity is already resolved as *unknown* by the per-plan submodule
373
+ // gate's own `[ -z "$PLAN_FILES" ]` rule, and inventing a second rule here
374
+ // would make an unrecorded plan look 100% out of scope.
375
+ const filesModified = (Array.isArray(e.files_modified) ? e.files_modified : [])
376
+ .filter((f) => typeof f === 'string' && f.trim().length > 0);
377
+ const normalized = {
312
378
  agent_id: typeof e.agent_id === 'string' ? e.agent_id : null,
313
379
  worktree_path: worktreePath,
314
380
  branch,
315
381
  expected_base: expectedBase,
316
382
  allowed_bases: allowedBases,
317
383
  };
384
+ if (filesModified.length > 0)
385
+ normalized.files_modified = filesModified;
386
+ return normalized;
318
387
  }
319
388
  function normalizeCleanupManifest(manifest) {
320
389
  let parsed = manifest;
@@ -371,6 +440,71 @@ function planWorktreeWaveCleanup(repoRoot, manifest) {
371
440
  function gitResultOk(result) {
372
441
  return !!(result && result.exitCode === 0 && !result.timedOut);
373
442
  }
443
+ /**
444
+ * #2852: after a failed `git merge` + a `git merge --abort` attempt, determine
445
+ * whether `repoRoot` is STILL mid-merge — the only condition that genuinely
446
+ * invalidates the rest of a cleanup wave.
447
+ *
448
+ * `git merge --abort`'s own exit code is NOT a reliable signal here: git refuses
449
+ * many merges (e.g. "your local changes to the following files would be
450
+ * overwritten by merge") WITHOUT ever creating a `MERGE_HEAD`, in which case
451
+ * `repoRoot`'s tree was never touched and `git merge --abort` correctly fails
452
+ * with "fatal: There is no merge to abort (MERGE_HEAD missing)?" — a SAFE
453
+ * outcome, not a broken one. Trusting that exit code alone would misclassify an
454
+ * ordinary per-entry merge failure as a repo-level one and strand the rest of
455
+ * the wave (caught in review).
456
+ *
457
+ * Checked directly via `git rev-parse --verify -q MERGE_HEAD` against the git
458
+ * ref itself rather than the filesystem: exit 0 means a merge is genuinely still
459
+ * in progress (unrecoverable — halt); exit 1 (the ref simply doesn't exist) means
460
+ * repoRoot is clean, whether because no merge state was ever entered or because
461
+ * abort successfully cleared it (safe — isolate and continue). Anything else
462
+ * (a timeout, or an unexpected git error) is treated conservatively as "still
463
+ * mid-merge" — degrade to the safe/halting answer rather than throw or guess.
464
+ */
465
+ function repoRootStillMidMerge(execGit, repoRoot) {
466
+ const check = execGit(['rev-parse', '--verify', '-q', 'MERGE_HEAD'], { cwd: repoRoot });
467
+ if (check.timedOut)
468
+ return true; // fail closed — cannot confirm safety
469
+ if (check.exitCode === 0)
470
+ return true; // MERGE_HEAD exists — genuinely still mid-merge
471
+ if (check.exitCode === 1)
472
+ return false; // ref not found — repoRoot is not mid-merge
473
+ return true; // any other exit code (e.g. a fatal git error) — fail closed
474
+ }
475
+ // #2596: the single definition of "this file is an executor-written SUMMARY
476
+ // artifact". Shared by `defaultFindSummaryFiles` (which walks for them to
477
+ // rescue) and the scope advisory below (which must never flag them) — a plan's
478
+ // declared `files_modified` never lists a SUMMARY, because the executor writes
479
+ // it by orchestration contract, so a second copy of this rule would make the
480
+ // advisory fire on essentially every wave.
481
+ const SUMMARY_ARTIFACT_DIR = '.planning';
482
+ const SUMMARY_ARTIFACT_SUFFIX = 'SUMMARY.md';
483
+ /**
484
+ * Normalize one path for scope comparison. Applied to BOTH sides so a declared
485
+ * path and a git-reported path meet in the same shape: backslashes become
486
+ * slashes unconditionally (a backslash path is not a Windows-only input),
487
+ * a leading `./` and any trailing `/` are stripped. This is the single
488
+ * normalizer shared by the SUMMARY-artifact predicate and the scope advisory,
489
+ * so the two can never disagree about what `./a\b/` means.
490
+ */
491
+ function normalizeScopePath(raw) {
492
+ return String(raw || '')
493
+ .replace(/\\/g, '/')
494
+ .trim()
495
+ .replace(/^\.\//, '')
496
+ .replace(/\/+$/, '');
497
+ }
498
+ /**
499
+ * True when a worktree-relative path is a SUMMARY artifact. Input may use
500
+ * either separator; normalization to POSIX is unconditional (backslash paths
501
+ * reach Linux too).
502
+ */
503
+ function isSummaryArtifactRelPath(relPath) {
504
+ const normalized = normalizeScopePath(relPath);
505
+ return normalized.startsWith(`${SUMMARY_ARTIFACT_DIR}/`)
506
+ && normalized.endsWith(SUMMARY_ARTIFACT_SUFFIX);
507
+ }
374
508
  /**
375
509
  * Walk <worktreePath>/.planning/ recursively and collect absolute paths of
376
510
  * all files whose names match *SUMMARY.md. Returns [] when the directory
@@ -380,7 +514,7 @@ function gitResultOk(result) {
380
514
  * find "$WT/.planning" -name "*SUMMARY.md"
381
515
  */
382
516
  function defaultFindSummaryFiles(worktreePath) {
383
- const planningDir = node_path_1.default.join(worktreePath, '.planning');
517
+ const planningDir = node_path_1.default.join(worktreePath, SUMMARY_ARTIFACT_DIR);
384
518
  const results = [];
385
519
  function walk(dir) {
386
520
  let entries;
@@ -395,7 +529,7 @@ function defaultFindSummaryFiles(worktreePath) {
395
529
  if (entry.isDirectory()) {
396
530
  walk(full);
397
531
  }
398
- else if (entry.isFile() && entry.name.endsWith('SUMMARY.md')) {
532
+ else if (entry.isFile() && entry.name.endsWith(SUMMARY_ARTIFACT_SUFFIX)) {
399
533
  results.push(full);
400
534
  }
401
535
  }
@@ -498,6 +632,77 @@ function rescueSummaryArtifacts(worktreePath, repoRoot, deps) {
498
632
  }
499
633
  return { rescuedRelPaths, failures };
500
634
  }
635
+ /**
636
+ * #2596: advisory codes emitted by the wave-cleanup gauntlet. Frozen and
637
+ * exported so tests assert on a code rather than on rendered prose (this repo
638
+ * forbids raw-text matching on test output).
639
+ */
640
+ const WAVE_CLEANUP_WARNING = Object.freeze({
641
+ /** A committed path fell outside the plan's declared `files_modified`. */
642
+ SCOPE_OUT_OF_DECLARED: 'scope_out_of_declared',
643
+ /** The scope diff could not be computed, so conformance is unknown. */
644
+ SCOPE_CHECK_UNAVAILABLE: 'scope_check_unavailable',
645
+ });
646
+ /**
647
+ * The literal directory prefix a declared path covers, or `null` when the
648
+ * pattern begins with a glob metacharacter and therefore has no usable prefix.
649
+ *
650
+ * Deliberately literal-prefix only — NOT a glob engine. A hand-rolled
651
+ * `**`/`*`/`?` matcher inside a worktree-lifecycle module is an informal,
652
+ * undocumented pattern language living where no language belongs, and this
653
+ * repo forbids external deps in core. The submodule-intersection gate
654
+ * (`workflows/execute-phase/steps/per-plan-worktree-gate.md`) already ships
655
+ * exactly this glob-prefix rule; reusing it beats inventing a second one.
656
+ *
657
+ * `null` (no literal prefix, e.g. `*.md`) means "matches everything": for an
658
+ * ADVISORY, a false alarm costs more than a miss, so the ambiguous case
659
+ * suppresses rather than shouts.
660
+ */
661
+ function declaredScopePrefix(declared) {
662
+ const globAt = declared.search(/[*?[]/);
663
+ if (globAt < 0)
664
+ return declared;
665
+ const literal = declared.slice(0, globAt).replace(/\/+$/, '');
666
+ return literal.length > 0 ? literal : null;
667
+ }
668
+ /**
669
+ * #2596: compare a branch's actual committed paths against the plan's declared
670
+ * scope. Pure — no git, no IO. Returns one warning per out-of-scope path, in
671
+ * the order the paths were given. Returns [] when nothing usable was declared:
672
+ * absence of data is not evidence of over-reach.
673
+ */
674
+ function planWaveScopeConformance(changedPaths, declaredFiles, branch) {
675
+ if (!Array.isArray(declaredFiles))
676
+ return [];
677
+ const prefixes = [];
678
+ for (const declared of declaredFiles) {
679
+ if (typeof declared !== 'string')
680
+ continue;
681
+ const normalized = normalizeScopePath(declared);
682
+ if (!normalized)
683
+ continue;
684
+ prefixes.push(declaredScopePrefix(normalized));
685
+ }
686
+ if (prefixes.length === 0)
687
+ return [];
688
+ const warnings = [];
689
+ const seen = new Set();
690
+ for (const raw of Array.isArray(changedPaths) ? changedPaths : []) {
691
+ if (typeof raw !== 'string')
692
+ continue;
693
+ const changed = normalizeScopePath(raw);
694
+ if (!changed || seen.has(changed))
695
+ continue;
696
+ seen.add(changed);
697
+ if (isSummaryArtifactRelPath(changed))
698
+ continue;
699
+ const covered = prefixes.some((prefix) => (prefix === null || changed === prefix || changed.startsWith(`${prefix}/`)));
700
+ if (covered)
701
+ continue;
702
+ warnings.push({ code: WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED, branch, path: changed });
703
+ }
704
+ return warnings;
705
+ }
501
706
  function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
502
707
  const execGit = deps.execGit || execGitDefault;
503
708
  const entries = Array.isArray(plan?.entries) ? plan.entries : [];
@@ -508,11 +713,25 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
508
713
  reason: plan ? (plan.reason || 'missing_entries') : 'missing_plan',
509
714
  entries: [],
510
715
  pending: entries,
716
+ warnings: [],
511
717
  };
512
718
  }
513
719
  const results = [];
514
720
  const pending = [];
721
+ const allWarnings = [];
515
722
  let ok = true;
723
+ // #2852: every per-entry failure site marks the SAME shape — status='blocked',
724
+ // a reason code, the captured stderr, push to results, flip the overall `ok`
725
+ // flag — and then either `continue` (isolate, the default) or, for the one
726
+ // repo-level-failure carve-out, `break`. Factored out so the 8 call sites below
727
+ // don't repeat the assembly; each site still owns its own control-flow decision.
728
+ function blockEntry(result, reason, stderr) {
729
+ result.status = 'blocked';
730
+ result.reason = reason;
731
+ result.stderr = stderr;
732
+ results.push(result);
733
+ ok = false;
734
+ }
516
735
  for (let i = 0; i < entries.length; i += 1) {
517
736
  const entry = entries[i];
518
737
  const result = {
@@ -520,71 +739,68 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
520
739
  status: 'pending',
521
740
  reason: null,
522
741
  stderr: '',
742
+ warnings: [],
523
743
  };
524
744
  const branchCheck = execGit(['-C', entry.worktree_path, 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: plan.repoRoot });
525
745
  if (!gitResultOk(branchCheck) || branchCheck.stdout.trim() !== entry.branch) {
526
- result.status = 'blocked';
527
- result.reason = 'branch_mismatch';
528
- result.stderr = branchCheck?.stderr || '';
529
- results.push(result);
530
- pending.push(...entries.slice(i + 1));
531
- ok = false;
532
- break;
746
+ blockEntry(result, 'branch_mismatch', branchCheck?.stderr || '');
747
+ // #2852: isolate — this entry's problem does not touch repoRoot's git state,
748
+ // so every remaining entry is still independently evaluated.
749
+ continue;
533
750
  }
534
751
  const mergeBase = execGit(['merge-base', 'HEAD', entry.branch], { cwd: plan.repoRoot });
535
752
  const allowedBases = Array.isArray(entry.allowed_bases) && entry.allowed_bases.length > 0
536
753
  ? entry.allowed_bases
537
754
  : [entry.expected_base];
538
755
  if (!gitResultOk(mergeBase) || !allowedBases.includes(mergeBase.stdout.trim())) {
539
- result.status = 'blocked';
540
- result.reason = 'base_mismatch';
541
- result.stderr = mergeBase?.stderr || '';
542
- results.push(result);
543
- pending.push(...entries.slice(i + 1));
544
- ok = false;
545
- break;
756
+ blockEntry(result, 'base_mismatch', mergeBase?.stderr || '');
757
+ continue; // #2852: isolate
546
758
  }
547
759
  const deletions = execGit(['diff', '--diff-filter=D', '--name-only', `HEAD...${entry.branch}`], { cwd: plan.repoRoot });
548
760
  if (!gitResultOk(deletions)) {
549
- result.status = 'blocked';
550
- result.reason = 'deletion_check_failed';
551
- result.stderr = deletions?.stderr || '';
552
- results.push(result);
553
- pending.push(...entries.slice(i + 1));
554
- ok = false;
555
- break;
761
+ blockEntry(result, 'deletion_check_failed', deletions?.stderr || '');
762
+ continue; // #2852: isolate
556
763
  }
557
764
  if (deletions.stdout) {
558
- result.status = 'blocked';
559
- result.reason = 'branch_contains_deletions';
560
- result.stderr = deletions.stdout;
561
- results.push(result);
562
- pending.push(...entries.slice(i + 1));
563
- ok = false;
564
- break;
765
+ // Unconditional: any deletion in this entry's branch blocks THIS entry. Whether
766
+ // that guard should have an opt-in for intentional deletions is a deferred
767
+ // product decision (issue #2852's own triage scoped it out — tracked in #3003);
768
+ // this fix only isolates the block to this one entry (#2852) instead of aborting
769
+ // the rest of the wave, same as every other block reason below.
770
+ blockEntry(result, 'branch_contains_deletions', deletions.stdout);
771
+ continue; // #2852: isolate
772
+ }
773
+ // #2596: advisory scope conformance — does the branch's ACTUAL committed
774
+ // diff stay inside the scope the plan declared? Gated on a declared scope
775
+ // being present: with nothing declared there is nothing to compare, so no
776
+ // git subprocess is spent at all (and every pre-#2596 fixture, none of
777
+ // which declares one, issues exactly the git calls it always did).
778
+ //
779
+ // ADVISORY ONLY. Unlike the deletions check above, a finding here does NOT
780
+ // call blockEntry and does NOT touch `ok` — the merge proceeds. Promotion
781
+ // to a hard gate is a separate, disclosed change.
782
+ if (Array.isArray(entry.files_modified) && entry.files_modified.length > 0) {
783
+ const scopeDiff = execGit(['diff', '--name-only', `HEAD...${entry.branch}`], { cwd: plan.repoRoot });
784
+ const scopeWarnings = !gitResultOk(scopeDiff)
785
+ // A broken advisory must never become a gate: record that conformance
786
+ // is unknown rather than blocking (or, worse, silently passing).
787
+ ? [{ code: WAVE_CLEANUP_WARNING.SCOPE_CHECK_UNAVAILABLE, branch: entry.branch, path: null }]
788
+ : planWaveScopeConformance((scopeDiff.stdout || '').split('\n'), entry.files_modified, entry.branch);
789
+ result.warnings.push(...scopeWarnings);
790
+ allWarnings.push(...scopeWarnings);
565
791
  }
566
792
  // Safety net: rescue uncommitted SUMMARY.md artifacts before the dirty check.
567
793
  // The executor leaves <quick_id>-SUMMARY.md uncommitted by contract — the
568
794
  // orchestrator commits it. Mirrors quick.md shell fallback (#2296, #2070, #2838, #3804).
569
795
  const { rescuedRelPaths, failures: rescueFailures } = rescueSummaryArtifacts(entry.worktree_path, plan.repoRoot, deps);
570
796
  if (rescueFailures.length > 0) {
571
- result.status = 'blocked';
572
- result.reason = 'summary_rescue_failed';
573
- result.stderr = rescueFailures.map((f) => `${f.relPath}: ${f.error}`).join('; ');
574
- results.push(result);
575
- pending.push(...entries.slice(i + 1));
576
- ok = false;
577
- break;
797
+ blockEntry(result, 'summary_rescue_failed', rescueFailures.map((f) => `${f.relPath}: ${f.error}`).join('; '));
798
+ continue; // #2852: isolate
578
799
  }
579
800
  const worktreeStatus = execGit(['-C', entry.worktree_path, 'status', '--porcelain', '--untracked-files=all'], { cwd: plan.repoRoot });
580
801
  if (!gitResultOk(worktreeStatus)) {
581
- result.status = 'blocked';
582
- result.reason = 'worktree_dirty';
583
- result.stderr = worktreeStatus?.stderr || '';
584
- results.push(result);
585
- pending.push(...entries.slice(i + 1));
586
- ok = false;
587
- break;
802
+ blockEntry(result, 'worktree_dirty', worktreeStatus?.stderr || '');
803
+ continue; // #2852: isolate
588
804
  }
589
805
  // Filter rescued SUMMARY paths out of the porcelain output before deciding dirty.
590
806
  // A line like "?? .planning/q1-SUMMARY.md" should not block when the SUMMARY
@@ -599,23 +815,31 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
599
815
  return !rescuedRelPaths.has(filePath);
600
816
  });
601
817
  if (dirtyLines.length > 0) {
602
- result.status = 'blocked';
603
- result.reason = 'worktree_dirty';
604
- result.stderr = dirtyLines.join('\n');
605
- results.push(result);
606
- pending.push(...entries.slice(i + 1));
607
- ok = false;
608
- break;
818
+ blockEntry(result, 'worktree_dirty', dirtyLines.join('\n'));
819
+ continue; // #2852: isolate
609
820
  }
610
821
  const merge = execGit(['merge', entry.branch, '--no-ff', '--no-edit', '-m', `chore: merge executor worktree (${entry.branch})`], { cwd: plan.repoRoot });
611
822
  if (!gitResultOk(merge)) {
612
- result.status = 'blocked';
613
- result.reason = 'merge_failed';
614
- result.stderr = merge?.stderr || merge?.stdout || '';
615
- results.push(result);
616
- pending.push(...entries.slice(i + 1));
617
- ok = false;
618
- break;
823
+ blockEntry(result, 'merge_failed', merge?.stderr || merge?.stdout || '');
824
+ // #2852: a failed --no-ff merge MIGHT leave repoRoot itself mid-merge
825
+ // (MERGE_HEAD set, conflict markers in the tree) — unlike every other block
826
+ // reason above, that specific state is NOT scoped to this one entry: a second
827
+ // `git merge` cannot even start while one is in progress, so every remaining
828
+ // entry would be corrupted by it. But git also refuses many merges WITHOUT ever
829
+ // entering a merge state (e.g. "your local changes would be overwritten by
830
+ // merge") — in that case repoRoot's tree was never touched and this failure is
831
+ // scoped to this entry, same as everything else. Attempt the abort as a
832
+ // best-effort cleanup, then check repoRoot's ACTUAL state directly — not
833
+ // `git merge --abort`'s own exit code, which fails "There is no merge to abort"
834
+ // in the safe case too and would misclassify it as unrecoverable (caught in
835
+ // review). Only a repo genuinely still mid-merge afterward legitimately halts
836
+ // the rest of the wave (the brief's "infrastructure-level failure" carve-out).
837
+ execGit(['merge', '--abort'], { cwd: plan.repoRoot });
838
+ if (repoRootStillMidMerge(execGit, plan.repoRoot)) {
839
+ pending.push(...entries.slice(i + 1));
840
+ break;
841
+ }
842
+ continue; // #2852: isolate — repoRoot is not (or no longer) mid-merge
619
843
  }
620
844
  let remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
621
845
  if (!gitResultOk(remove)) {
@@ -626,13 +850,10 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
626
850
  remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
627
851
  }
628
852
  if (!gitResultOk(remove)) {
629
- result.status = 'blocked';
630
- result.reason = 'worktree_remove_failed';
631
- result.stderr = remove?.stderr || '';
632
- results.push(result);
633
- pending.push(...entries.slice(i + 1));
634
- ok = false;
635
- break;
853
+ blockEntry(result, 'worktree_remove_failed', remove?.stderr || '');
854
+ // #2852: isolate — the merge already landed on repoRoot; only this entry's
855
+ // worktree/branch teardown is affected.
856
+ continue;
636
857
  }
637
858
  const branchDelete = execGit(['branch', '-D', entry.branch], { cwd: plan.repoRoot });
638
859
  if (!gitResultOk(branchDelete)) {
@@ -653,6 +874,7 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
653
874
  reason: ok ? 'ok' : 'cleanup_blocked',
654
875
  entries: results,
655
876
  pending,
877
+ warnings: allWarnings,
656
878
  };
657
879
  }
658
880
  function cmdWorktreeCleanupWave(cwd, args = []) {
@@ -693,6 +915,15 @@ function cmdWorktreeCleanupWave(cwd, args = []) {
693
915
  process.exitCode = 1;
694
916
  }
695
917
  }
918
+ /**
919
+ * #2596: split a `--files` value into declared paths. Whitespace-separated,
920
+ * matching the `PLAN_FILES` shape the per-plan worktree gate already builds
921
+ * with `jq -r '.files_modified // [] | join(" ")'`. Values are DATA compared
922
+ * against a diff — never opened, never passed to a shell.
923
+ */
924
+ function parseDeclaredScopeFlag(raw) {
925
+ return String(raw || '').split(/\s+/).filter((token) => token.length > 0);
926
+ }
696
927
  /**
697
928
  * Pure planner for the per-agent wave-manifest append.
698
929
  *
@@ -709,8 +940,9 @@ function cmdWorktreeCleanupWave(cwd, args = []) {
709
940
  * rejected loudly — the reader dedups on that key, so a re-record would be
710
941
  * silently dropped (the failure mode this verb exists to eliminate). The
711
942
  * on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
712
- * `branch`, `expected_base`) — no schema change; the reader re-derives
713
- * `allowed_bases`.
943
+ * `branch`, `expected_base`) unless `--files` declares a scope, in which case
944
+ * an optional `files_modified` is appended (#2596); the reader still
945
+ * re-derives `allowed_bases`.
714
946
  */
715
947
  function planWorktreeRecordAgent(manifestRaw, fields) {
716
948
  // 1. Write-strict required-field check (loud, with which flag is missing).
@@ -740,18 +972,20 @@ function planWorktreeRecordAgent(manifestRaw, fields) {
740
972
  }
741
973
  // 2. Shared validation: run the candidate through the reader's normalizer.
742
974
  // If it returns null the reader would drop this entry on read — reject now.
975
+ const declaredScope = parseDeclaredScopeFlag(fields.files);
743
976
  const candidate = {
744
977
  agent_id: agentId,
745
978
  worktree_path: worktreePath,
746
979
  branch,
747
980
  expected_base: base,
981
+ ...(declaredScope.length > 0 ? { files_modified: declaredScope } : {}),
748
982
  };
749
983
  const entry = normalizeCleanupManifestEntry(candidate);
750
984
  if (!entry) {
751
985
  return {
752
986
  ok: false,
753
987
  reason: 'invalid_entry',
754
- hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts both agent-<id> and worktree-agent-<id> namespaces; got branch="${branch}"). Fix the field and re-run.`,
988
+ hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts agent-<id>, worktree-agent-<id>, and worktree-wf_<runid> namespaces; got branch="${branch}"). Fix the field and re-run.`,
755
989
  entry: null,
756
990
  manifest: null,
757
991
  };
@@ -833,6 +1067,11 @@ function planWorktreeRecordAgent(manifestRaw, fields) {
833
1067
  branch: entry.branch,
834
1068
  expected_base: entry.expected_base,
835
1069
  };
1070
+ // #2596: only written when a scope was actually declared — conservative in
1071
+ // what we send, so a blank --files leaves the 4-field shape untouched.
1072
+ if (entry.files_modified && entry.files_modified.length > 0) {
1073
+ recorded.files_modified = entry.files_modified;
1074
+ }
836
1075
  worktrees.push(recorded);
837
1076
  return {
838
1077
  ok: true,
@@ -844,7 +1083,7 @@ function planWorktreeRecordAgent(manifestRaw, fields) {
844
1083
  /**
845
1084
  * CLI command: append a validated per-agent entry to a wave cleanup manifest.
846
1085
  *
847
- * Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
1086
+ * Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--files "<space-separated paths>"]
848
1087
  *
849
1088
  * Fails loudly (non-zero exit + recovery hint on stderr) when a field is
850
1089
  * missing/garbled or the manifest is absent/malformed, rather than appending an
@@ -859,7 +1098,7 @@ function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
859
1098
  const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
860
1099
  const manifestPath = flag('--manifest');
861
1100
  if (!manifestPath) {
862
- writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
1101
+ writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--files "<space-separated paths>"]\n');
863
1102
  process.exitCode = 2;
864
1103
  return { ok: false, reason: 'usage', entry: null };
865
1104
  }
@@ -881,6 +1120,7 @@ function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
881
1120
  worktreePath: flag('--path'),
882
1121
  branch: flag('--branch'),
883
1122
  base: flag('--base'),
1123
+ files: flag('--files'),
884
1124
  });
885
1125
  if (!plan.ok || plan.manifest === null) {
886
1126
  writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
@@ -924,18 +1164,20 @@ function planWorktreeCreate(fields) {
924
1164
  entry: null,
925
1165
  };
926
1166
  }
1167
+ const declaredScope = parseDeclaredScopeFlag(fields.files);
927
1168
  const candidate = {
928
1169
  agent_id: agentId,
929
1170
  worktree_path: worktreePath,
930
1171
  branch,
931
1172
  expected_base: base,
1173
+ ...(declaredScope.length > 0 ? { files_modified: declaredScope } : {}),
932
1174
  };
933
1175
  const entry = normalizeCleanupManifestEntry(candidate);
934
1176
  if (!entry) {
935
1177
  return {
936
1178
  ok: false,
937
1179
  reason: 'invalid_entry',
938
- hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts both agent-<id> and worktree-agent-<id> namespaces; got branch="${branch}"). Fix the field and re-run.`,
1180
+ hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts agent-<id>, worktree-agent-<id>, and worktree-wf_<runid> namespaces; got branch="${branch}"). Fix the field and re-run.`,
939
1181
  entry: null,
940
1182
  };
941
1183
  }
@@ -1056,7 +1298,7 @@ function executeWorktreeCreatePlan(plan, repoRoot, deps = {}) {
1056
1298
  * validated manifest entry so the worktree is immediately manageable by
1057
1299
  * `worktree cleanup-wave` / `worktree reap-orphans`.
1058
1300
  *
1059
- * Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
1301
+ * Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir> [--files "<space-separated paths>"]
1060
1302
  *
1061
1303
  * #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
1062
1304
  * plan step runs BEFORE the git side effect (step 5). The ONLY manifest
@@ -1076,7 +1318,7 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
1076
1318
  const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
1077
1319
  const manifestPath = flag('--manifest');
1078
1320
  if (!manifestPath) {
1079
- writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--root <dir>]\n');
1321
+ writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir> [--files "<space-separated paths>"]\n');
1080
1322
  process.exitCode = 2;
1081
1323
  return { ok: false, reason: 'usage' };
1082
1324
  }
@@ -1141,6 +1383,7 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
1141
1383
  worktreePath: flag('--path'),
1142
1384
  branch: flag('--branch'),
1143
1385
  base: flag('--base'),
1386
+ files: flag('--files'),
1144
1387
  });
1145
1388
  if (!plan.ok || !plan.entry) {
1146
1389
  writeErr(`[gsd] worktree.create: ${plan.reason} — ${plan.hint || ''}\n`);
@@ -1148,25 +1391,38 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
1148
1391
  process.exitCode = 1;
1149
1392
  return { ok: false, reason: plan.reason, hint: plan.hint };
1150
1393
  }
1151
- // 3b. Optional root confinement (#2627, Phase 3 — the confinement Phase 2
1152
- // deferred here from planWorktreeCreate's path-traversal guard).
1153
- // planWorktreeCreate rejects a literal ".." SEGMENT, but a plain absolute
1154
- // path outside the project contains no ".." and passes. Phase 3 makes the
1155
- // orchestrator SPAWN executor processes into these paths, so an
1156
- // unconfined --path is a write primitive aimed anywhere on the filesystem.
1394
+ // 3b. Mandatory root confinement (#2627 Phase 3 introduced it; #3050 made it
1395
+ // mandatory — the confinement Phase 2 deferred here from
1396
+ // planWorktreeCreate's path-traversal guard). planWorktreeCreate rejects a
1397
+ // literal ".." SEGMENT, but a plain absolute path outside the project
1398
+ // contains no ".." and passes. The orchestrator SPAWNS executor processes
1399
+ // into these paths, so an unconfined --path is a write primitive aimed
1400
+ // anywhere on the filesystem.
1157
1401
  //
1158
1402
  // The root is DECLARED by the caller (`--root`) rather than inferred: agent
1159
1403
  // worktrees legitimately live outside the orchestrator's own root (a lane
1160
1404
  // orchestrator creates siblings under the repo's .claude/worktrees/), so
1161
- // there is no layout this module could derive without guessing. Absent
1162
- // `--root` the behavior is exactly as shipped in Phase 2 — the
1163
- // orchestrator-worktree scheduler path always passes it.
1405
+ // there is no layout this module could derive without guessing.
1164
1406
  //
1165
1407
  // Lexical by design: the worktree does not exist yet, so there is nothing
1166
1408
  // to realpath, and resolving only the root would not close a symlinked-leaf
1167
1409
  // hole. Pairs with the leading-dash and ".."-segment guards above.
1410
+ //
1411
+ // #3050: confinement does not depend on the caller remembering to pass
1412
+ // `--root` — it used to be silently skippable, so a caller that forgot the
1413
+ // flag got an unconfined `--path` with no warning. Fail closed instead:
1414
+ // absent `--root`, this verb refuses to create anything. The one current
1415
+ // caller (execute-phase's orchestrator-worktree dispatch) always passes
1416
+ // `--root`, so this closes the gap without breaking it.
1168
1417
  const rootFlag = flag('--root');
1169
- if (rootFlag) {
1418
+ if (!rootFlag) {
1419
+ const hint = '--root is required (fail-closed root confinement, #3050). Pass --root <orchestrator-root-dir> so worktree.create can verify --path resolves inside it before creating anything.';
1420
+ writeErr(`[gsd] worktree.create: root_required — ${hint}\n`);
1421
+ write(`${JSON.stringify({ ok: false, reason: 'root_required', hint }, null, 2)}\n`);
1422
+ process.exitCode = 1;
1423
+ return { ok: false, reason: 'root_required', hint };
1424
+ }
1425
+ {
1170
1426
  const absRoot = node_path_1.default.resolve(cwd, rootFlag);
1171
1427
  const absWorktree = node_path_1.default.resolve(cwd, plan.entry.worktree_path);
1172
1428
  const rel = node_path_1.default.relative(absRoot, absWorktree);
@@ -1194,6 +1450,11 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
1194
1450
  branch: plan.entry.branch,
1195
1451
  expected_base: plan.entry.expected_base,
1196
1452
  };
1453
+ // #2596: only written when a scope was actually declared — conservative in
1454
+ // what we send, so a blank --files leaves the 4-field shape untouched.
1455
+ if (Array.isArray(plan.entry.files_modified) && plan.entry.files_modified.length > 0) {
1456
+ recorded.files_modified = plan.entry.files_modified;
1457
+ }
1197
1458
  const dedupeKey = `${recorded.worktree_path}\0${recorded.branch}`;
1198
1459
  const alreadyPresent = worktrees.some((existing) => {
1199
1460
  const normalized = normalizeCleanupManifestEntry(existing);
@@ -1350,8 +1611,21 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
1350
1611
  // worktreePath may not exist yet (already removed); use as-is.
1351
1612
  }
1352
1613
  // 4a. Stale-lock guard: skip if lock is too fresh (PID recycling / race).
1614
+ //
1615
+ // The two causes are reported SEPARATELY (#3057). A lock whose mtime could
1616
+ // not be read is not "fresh" in any sense: `lock_too_fresh` tells an
1617
+ // operator that waiting will resolve the skip, and waiting never resolves a
1618
+ // stat failure — the lock could be seconds or months old and the sweep has
1619
+ // no way to tell. Conflating them is the same defect this module already
1620
+ // fixed for `parse_failed` vs `no_worktrees` in planWorktreePrune: a
1621
+ // decision made on unread data must not be indistinguishable from one made
1622
+ // on real data.
1353
1623
  const lockMtime = mtimeSafe(lockedFile);
1354
- if (!lockMtime || nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
1624
+ if (!lockMtime) {
1625
+ results.push({ path: worktreePath, status: 'skipped', reason: 'lock_age_unknown' });
1626
+ continue;
1627
+ }
1628
+ if (nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
1355
1629
  results.push({ path: worktreePath, status: 'skipped', reason: 'lock_too_fresh' });
1356
1630
  continue;
1357
1631
  }
@@ -1362,9 +1636,26 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
1362
1636
  continue;
1363
1637
  }
1364
1638
  const pid = parseInt(pidStr, 10);
1639
+ // Number.isFinite, not Number.isNaN: pidStr is captured by /^\d+/ above, so
1640
+ // pid can never be NaN. A 309-or-more-digit string parses to Infinity.
1641
+ //
1642
+ // NOT LOAD-BEARING FOR SAFETY — do not delete it as redundant. Fail-closed
1643
+ // liveness now lives in defaultIsPidAlive, which treats every non-ESRCH
1644
+ // outcome (including the TypeError process.kill throws for Infinity) as
1645
+ // ALIVE. This guard survives because it produces a more ACCURATE verdict
1646
+ // for garbage input: `lock_owner_unknown` says "the lock names a PID this
1647
+ // parse could not represent", whereas falling through would report
1648
+ // `pid_alive` — an assertion about an owner that was never probed.
1649
+ // Note this is an EARLIER, DIFFERENT gate than the process.kill range
1650
+ // limit: process.kill accepts up to 2147483647 and rejects 2147483648
1651
+ // (measured), far below the parse cliff this guard catches.
1652
+ if (!Number.isFinite(pid)) {
1653
+ results.push({ path: worktreePath, status: 'skipped', reason: 'lock_owner_unknown' });
1654
+ continue;
1655
+ }
1365
1656
  let pidIsAlive;
1366
1657
  try {
1367
- pidIsAlive = Number.isNaN(pid) || isPidAliveCheck(pid);
1658
+ pidIsAlive = isPidAliveCheck(pid);
1368
1659
  }
1369
1660
  catch {
1370
1661
  pidIsAlive = true; // Cannot determine liveness — treat as alive, do not reap.
@@ -1420,15 +1711,29 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
1420
1711
  return results;
1421
1712
  }
1422
1713
  // ─── reapOrphanWorktrees deps helpers ─────────────────────────────────────────
1714
+ /**
1715
+ * Liveness probe for a lock-owner PID — FAILS CLOSED (#3057).
1716
+ *
1717
+ * `ESRCH` ("no such process") is the ONLY outcome that proves the owner is
1718
+ * gone. Every other failure means the probe could not determine liveness:
1719
+ * - `EPERM` — the process exists, we just may not signal it;
1720
+ * - `TypeError` / `ERR_INVALID_ARG_TYPE` — `process.kill` accepts a pid up
1721
+ * to 2147483647 and REJECTS 2147483648 and above (measured), so a finite
1722
+ * but out-of-range pid never reaches the OS at all;
1723
+ * - anything else — an outcome this helper does not recognise.
1724
+ *
1725
+ * The return value feeds a DESTRUCTIVE decision (`git worktree remove
1726
+ * --force`), so an unrecognised failure must never read as "dead". Hence the
1727
+ * inversion: only ESRCH returns false; everything else returns true (alive,
1728
+ * do not reap).
1729
+ */
1423
1730
  function defaultIsPidAlive(pid) {
1424
1731
  try {
1425
1732
  process.kill(pid, 0);
1426
1733
  return true;
1427
1734
  }
1428
1735
  catch (err) {
1429
- if (err && err.code === 'EPERM')
1430
- return true;
1431
- return false;
1736
+ return err?.code !== 'ESRCH';
1432
1737
  }
1433
1738
  }
1434
1739
  function defaultReadDirSafe(dir) {
@@ -1455,36 +1760,45 @@ function defaultMtimeSafe(file) {
1455
1760
  return null;
1456
1761
  }
1457
1762
  }
1458
- function cmdWorktreeReapOrphans(cwd) {
1763
+ function cmdWorktreeReapOrphans(cwd, deps = {}) {
1764
+ const write = deps.write || ((s) => process.stdout.write(s));
1765
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
1459
1766
  let result;
1460
1767
  try {
1461
- result = reapOrphanWorktrees(cwd);
1768
+ result = reapOrphanWorktrees(cwd, deps);
1462
1769
  }
1463
1770
  catch (err) {
1464
1771
  // Surface failure as a one-line warning; keep exit-zero so workflows don't break.
1465
- process.stderr.write(`[gsd] worktree.reap-orphans failed: ${err && err.message ? err.message : String(err)}\n`);
1772
+ writeErr(`[gsd] worktree.reap-orphans failed: ${err && err.message ? err.message : String(err)}\n`);
1466
1773
  result = [];
1467
1774
  }
1468
1775
  const skippedCount = result.filter((r) => r.status === 'skipped').length;
1469
1776
  if (skippedCount > 0) {
1470
1777
  // Surface skipped entries so operators are aware of unresolved orphans.
1471
- process.stderr.write(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
1778
+ writeErr(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
1472
1779
  }
1473
- process.stdout.write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
1780
+ write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
1474
1781
  }
1475
1782
  // Unused exports kept for API compatibility
1476
1783
  void parseWorktreeListPaths;
1477
1784
  // ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
1478
1785
  /**
1479
- * Resolve the main worktree root when running inside a git worktree.
1480
- * In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
1481
- * Returns the main worktree path, or cwd if not in a worktree.
1786
+ * Resolve the main worktree root when running inside a git worktree, along
1787
+ * with the `reason` that produced it (#3050). Callers MUST inspect `reason`
1788
+ * before trusting `root` unconditionally — a `reason` of `git_timed_out`
1789
+ * means the git subprocess used to distinguish "linked worktree" from
1790
+ * "not a repo" never completed, so `root` is a best-effort fallback (cwd),
1791
+ * not a confirmed worktree root. Degrading to cwd rather than throwing is
1792
+ * intentional (return degraded result on timeout; do not throw) — but the
1793
+ * reason must still reach the caller so it can surface the risk instead of
1794
+ * silently trusting the wrong root.
1482
1795
  */
1483
- function resolveWorktreeRoot(cwd) {
1796
+ function resolveWorktreeRoot(cwd, deps = {}) {
1484
1797
  const context = resolveWorktreeContext(cwd, {
1485
- existsSync: node_fs_1.default.existsSync,
1798
+ existsSync: deps.existsSync || node_fs_1.default.existsSync,
1799
+ execGit: deps.execGit,
1486
1800
  });
1487
- return context.effectiveRoot;
1801
+ return { root: context.effectiveRoot, reason: context.reason };
1488
1802
  }
1489
1803
  /**
1490
1804
  * Clear stale worktree metadata references via `git worktree prune`.
@@ -1495,12 +1809,19 @@ function resolveWorktreeRoot(cwd) {
1495
1809
  * the repository; used as `cwd` for git commands.
1496
1810
  * @returns list of worktree paths that were removed (always empty)
1497
1811
  */
1498
- function pruneOrphanedWorktrees(repoRoot) {
1812
+ function pruneOrphanedWorktrees(repoRoot, deps = {}) {
1813
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
1499
1814
  try {
1500
- const plan = planWorktreePrune(repoRoot, { allowDestructive: false }, { parseWorktreePorcelain });
1501
- const pruneResult = executeWorktreePrunePlan(plan);
1815
+ // `...deps` comes LAST deliberately: `parseWorktreePorcelain` is a declared
1816
+ // member of WorktreeDeps and planWorktreePrune already reads
1817
+ // `deps.parseWorktreePorcelain` before falling back to the module function,
1818
+ // so a caller-supplied parser is an intended override, not an accident.
1819
+ // The hard-coded key is only a restatement of that same default. Do not
1820
+ // reorder the two — `tests/worktree-safety-reap.test.cjs` pins the override.
1821
+ const plan = planWorktreePrune(repoRoot, { allowDestructive: false }, { parseWorktreePorcelain, ...deps });
1822
+ const pruneResult = executeWorktreePrunePlan(plan, deps);
1502
1823
  if (pruneResult && pruneResult.timedOut) {
1503
- process.stderr.write('[gsd-tools] WARNING: worktree health check degraded' +
1824
+ writeErr('[gsd-tools] WARNING: worktree health check degraded' +
1504
1825
  ' — git worktree prune timed out after 10s.' +
1505
1826
  ' Orphaned worktree metadata may remain until the next successful run.\n');
1506
1827
  }
@@ -1510,6 +1831,7 @@ function pruneOrphanedWorktrees(repoRoot) {
1510
1831
  }
1511
1832
  module.exports = {
1512
1833
  resolveWorktreeContext,
1834
+ resolveWorktreeLinkage,
1513
1835
  parseWorktreePorcelain,
1514
1836
  planWorktreePrune,
1515
1837
  executeWorktreePrunePlan,
@@ -1519,6 +1841,9 @@ module.exports = {
1519
1841
  normalizeCleanupManifest,
1520
1842
  planWorktreeWaveCleanup,
1521
1843
  executeWorktreeWaveCleanupPlan,
1844
+ WAVE_CLEANUP_WARNING,
1845
+ planWaveScopeConformance,
1846
+ isSummaryArtifactRelPath,
1522
1847
  cmdWorktreeCleanupWave,
1523
1848
  planWorktreeRecordAgent,
1524
1849
  cmdWorktreeRecordAgent,