@opengsd/gsd-core 1.9.0 → 1.10.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 (223) 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 +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -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,
@@ -371,6 +429,38 @@ function planWorktreeWaveCleanup(repoRoot, manifest) {
371
429
  function gitResultOk(result) {
372
430
  return !!(result && result.exitCode === 0 && !result.timedOut);
373
431
  }
432
+ /**
433
+ * #2852: after a failed `git merge` + a `git merge --abort` attempt, determine
434
+ * whether `repoRoot` is STILL mid-merge — the only condition that genuinely
435
+ * invalidates the rest of a cleanup wave.
436
+ *
437
+ * `git merge --abort`'s own exit code is NOT a reliable signal here: git refuses
438
+ * many merges (e.g. "your local changes to the following files would be
439
+ * overwritten by merge") WITHOUT ever creating a `MERGE_HEAD`, in which case
440
+ * `repoRoot`'s tree was never touched and `git merge --abort` correctly fails
441
+ * with "fatal: There is no merge to abort (MERGE_HEAD missing)?" — a SAFE
442
+ * outcome, not a broken one. Trusting that exit code alone would misclassify an
443
+ * ordinary per-entry merge failure as a repo-level one and strand the rest of
444
+ * the wave (caught in review).
445
+ *
446
+ * Checked directly via `git rev-parse --verify -q MERGE_HEAD` against the git
447
+ * ref itself rather than the filesystem: exit 0 means a merge is genuinely still
448
+ * in progress (unrecoverable — halt); exit 1 (the ref simply doesn't exist) means
449
+ * repoRoot is clean, whether because no merge state was ever entered or because
450
+ * abort successfully cleared it (safe — isolate and continue). Anything else
451
+ * (a timeout, or an unexpected git error) is treated conservatively as "still
452
+ * mid-merge" — degrade to the safe/halting answer rather than throw or guess.
453
+ */
454
+ function repoRootStillMidMerge(execGit, repoRoot) {
455
+ const check = execGit(['rev-parse', '--verify', '-q', 'MERGE_HEAD'], { cwd: repoRoot });
456
+ if (check.timedOut)
457
+ return true; // fail closed — cannot confirm safety
458
+ if (check.exitCode === 0)
459
+ return true; // MERGE_HEAD exists — genuinely still mid-merge
460
+ if (check.exitCode === 1)
461
+ return false; // ref not found — repoRoot is not mid-merge
462
+ return true; // any other exit code (e.g. a fatal git error) — fail closed
463
+ }
374
464
  /**
375
465
  * Walk <worktreePath>/.planning/ recursively and collect absolute paths of
376
466
  * all files whose names match *SUMMARY.md. Returns [] when the directory
@@ -513,6 +603,18 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
513
603
  const results = [];
514
604
  const pending = [];
515
605
  let ok = true;
606
+ // #2852: every per-entry failure site marks the SAME shape — status='blocked',
607
+ // a reason code, the captured stderr, push to results, flip the overall `ok`
608
+ // flag — and then either `continue` (isolate, the default) or, for the one
609
+ // repo-level-failure carve-out, `break`. Factored out so the 8 call sites below
610
+ // don't repeat the assembly; each site still owns its own control-flow decision.
611
+ function blockEntry(result, reason, stderr) {
612
+ result.status = 'blocked';
613
+ result.reason = reason;
614
+ result.stderr = stderr;
615
+ results.push(result);
616
+ ok = false;
617
+ }
516
618
  for (let i = 0; i < entries.length; i += 1) {
517
619
  const entry = entries[i];
518
620
  const result = {
@@ -523,68 +625,45 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
523
625
  };
524
626
  const branchCheck = execGit(['-C', entry.worktree_path, 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: plan.repoRoot });
525
627
  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;
628
+ blockEntry(result, 'branch_mismatch', branchCheck?.stderr || '');
629
+ // #2852: isolate — this entry's problem does not touch repoRoot's git state,
630
+ // so every remaining entry is still independently evaluated.
631
+ continue;
533
632
  }
534
633
  const mergeBase = execGit(['merge-base', 'HEAD', entry.branch], { cwd: plan.repoRoot });
535
634
  const allowedBases = Array.isArray(entry.allowed_bases) && entry.allowed_bases.length > 0
536
635
  ? entry.allowed_bases
537
636
  : [entry.expected_base];
538
637
  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;
638
+ blockEntry(result, 'base_mismatch', mergeBase?.stderr || '');
639
+ continue; // #2852: isolate
546
640
  }
547
641
  const deletions = execGit(['diff', '--diff-filter=D', '--name-only', `HEAD...${entry.branch}`], { cwd: plan.repoRoot });
548
642
  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;
643
+ blockEntry(result, 'deletion_check_failed', deletions?.stderr || '');
644
+ continue; // #2852: isolate
556
645
  }
557
646
  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;
647
+ // Unconditional: any deletion in this entry's branch blocks THIS entry. Whether
648
+ // that guard should have an opt-in for intentional deletions is a deferred
649
+ // product decision (issue #2852's own triage scoped it out — tracked in #3003);
650
+ // this fix only isolates the block to this one entry (#2852) instead of aborting
651
+ // the rest of the wave, same as every other block reason below.
652
+ blockEntry(result, 'branch_contains_deletions', deletions.stdout);
653
+ continue; // #2852: isolate
565
654
  }
566
655
  // Safety net: rescue uncommitted SUMMARY.md artifacts before the dirty check.
567
656
  // The executor leaves <quick_id>-SUMMARY.md uncommitted by contract — the
568
657
  // orchestrator commits it. Mirrors quick.md shell fallback (#2296, #2070, #2838, #3804).
569
658
  const { rescuedRelPaths, failures: rescueFailures } = rescueSummaryArtifacts(entry.worktree_path, plan.repoRoot, deps);
570
659
  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;
660
+ blockEntry(result, 'summary_rescue_failed', rescueFailures.map((f) => `${f.relPath}: ${f.error}`).join('; '));
661
+ continue; // #2852: isolate
578
662
  }
579
663
  const worktreeStatus = execGit(['-C', entry.worktree_path, 'status', '--porcelain', '--untracked-files=all'], { cwd: plan.repoRoot });
580
664
  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;
665
+ blockEntry(result, 'worktree_dirty', worktreeStatus?.stderr || '');
666
+ continue; // #2852: isolate
588
667
  }
589
668
  // Filter rescued SUMMARY paths out of the porcelain output before deciding dirty.
590
669
  // A line like "?? .planning/q1-SUMMARY.md" should not block when the SUMMARY
@@ -599,23 +678,31 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
599
678
  return !rescuedRelPaths.has(filePath);
600
679
  });
601
680
  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;
681
+ blockEntry(result, 'worktree_dirty', dirtyLines.join('\n'));
682
+ continue; // #2852: isolate
609
683
  }
610
684
  const merge = execGit(['merge', entry.branch, '--no-ff', '--no-edit', '-m', `chore: merge executor worktree (${entry.branch})`], { cwd: plan.repoRoot });
611
685
  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;
686
+ blockEntry(result, 'merge_failed', merge?.stderr || merge?.stdout || '');
687
+ // #2852: a failed --no-ff merge MIGHT leave repoRoot itself mid-merge
688
+ // (MERGE_HEAD set, conflict markers in the tree) — unlike every other block
689
+ // reason above, that specific state is NOT scoped to this one entry: a second
690
+ // `git merge` cannot even start while one is in progress, so every remaining
691
+ // entry would be corrupted by it. But git also refuses many merges WITHOUT ever
692
+ // entering a merge state (e.g. "your local changes would be overwritten by
693
+ // merge") — in that case repoRoot's tree was never touched and this failure is
694
+ // scoped to this entry, same as everything else. Attempt the abort as a
695
+ // best-effort cleanup, then check repoRoot's ACTUAL state directly — not
696
+ // `git merge --abort`'s own exit code, which fails "There is no merge to abort"
697
+ // in the safe case too and would misclassify it as unrecoverable (caught in
698
+ // review). Only a repo genuinely still mid-merge afterward legitimately halts
699
+ // the rest of the wave (the brief's "infrastructure-level failure" carve-out).
700
+ execGit(['merge', '--abort'], { cwd: plan.repoRoot });
701
+ if (repoRootStillMidMerge(execGit, plan.repoRoot)) {
702
+ pending.push(...entries.slice(i + 1));
703
+ break;
704
+ }
705
+ continue; // #2852: isolate — repoRoot is not (or no longer) mid-merge
619
706
  }
620
707
  let remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
621
708
  if (!gitResultOk(remove)) {
@@ -626,13 +713,10 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
626
713
  remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
627
714
  }
628
715
  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;
716
+ blockEntry(result, 'worktree_remove_failed', remove?.stderr || '');
717
+ // #2852: isolate — the merge already landed on repoRoot; only this entry's
718
+ // worktree/branch teardown is affected.
719
+ continue;
636
720
  }
637
721
  const branchDelete = execGit(['branch', '-D', entry.branch], { cwd: plan.repoRoot });
638
722
  if (!gitResultOk(branchDelete)) {
@@ -751,7 +835,7 @@ function planWorktreeRecordAgent(manifestRaw, fields) {
751
835
  return {
752
836
  ok: false,
753
837
  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.`,
838
+ 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
839
  entry: null,
756
840
  manifest: null,
757
841
  };
@@ -935,7 +1019,7 @@ function planWorktreeCreate(fields) {
935
1019
  return {
936
1020
  ok: false,
937
1021
  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.`,
1022
+ 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
1023
  entry: null,
940
1024
  };
941
1025
  }
@@ -1056,7 +1140,7 @@ function executeWorktreeCreatePlan(plan, repoRoot, deps = {}) {
1056
1140
  * validated manifest entry so the worktree is immediately manageable by
1057
1141
  * `worktree cleanup-wave` / `worktree reap-orphans`.
1058
1142
  *
1059
- * Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
1143
+ * Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>
1060
1144
  *
1061
1145
  * #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
1062
1146
  * plan step runs BEFORE the git side effect (step 5). The ONLY manifest
@@ -1076,7 +1160,7 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
1076
1160
  const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
1077
1161
  const manifestPath = flag('--manifest');
1078
1162
  if (!manifestPath) {
1079
- writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--root <dir>]\n');
1163
+ writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>\n');
1080
1164
  process.exitCode = 2;
1081
1165
  return { ok: false, reason: 'usage' };
1082
1166
  }
@@ -1148,25 +1232,38 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
1148
1232
  process.exitCode = 1;
1149
1233
  return { ok: false, reason: plan.reason, hint: plan.hint };
1150
1234
  }
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.
1235
+ // 3b. Mandatory root confinement (#2627 Phase 3 introduced it; #3050 made it
1236
+ // mandatory — the confinement Phase 2 deferred here from
1237
+ // planWorktreeCreate's path-traversal guard). planWorktreeCreate rejects a
1238
+ // literal ".." SEGMENT, but a plain absolute path outside the project
1239
+ // contains no ".." and passes. The orchestrator SPAWNS executor processes
1240
+ // into these paths, so an unconfined --path is a write primitive aimed
1241
+ // anywhere on the filesystem.
1157
1242
  //
1158
1243
  // The root is DECLARED by the caller (`--root`) rather than inferred: agent
1159
1244
  // worktrees legitimately live outside the orchestrator's own root (a lane
1160
1245
  // 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.
1246
+ // there is no layout this module could derive without guessing.
1164
1247
  //
1165
1248
  // Lexical by design: the worktree does not exist yet, so there is nothing
1166
1249
  // to realpath, and resolving only the root would not close a symlinked-leaf
1167
1250
  // hole. Pairs with the leading-dash and ".."-segment guards above.
1251
+ //
1252
+ // #3050: confinement does not depend on the caller remembering to pass
1253
+ // `--root` — it used to be silently skippable, so a caller that forgot the
1254
+ // flag got an unconfined `--path` with no warning. Fail closed instead:
1255
+ // absent `--root`, this verb refuses to create anything. The one current
1256
+ // caller (execute-phase's orchestrator-worktree dispatch) always passes
1257
+ // `--root`, so this closes the gap without breaking it.
1168
1258
  const rootFlag = flag('--root');
1169
- if (rootFlag) {
1259
+ if (!rootFlag) {
1260
+ 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.';
1261
+ writeErr(`[gsd] worktree.create: root_required — ${hint}\n`);
1262
+ write(`${JSON.stringify({ ok: false, reason: 'root_required', hint }, null, 2)}\n`);
1263
+ process.exitCode = 1;
1264
+ return { ok: false, reason: 'root_required', hint };
1265
+ }
1266
+ {
1170
1267
  const absRoot = node_path_1.default.resolve(cwd, rootFlag);
1171
1268
  const absWorktree = node_path_1.default.resolve(cwd, plan.entry.worktree_path);
1172
1269
  const rel = node_path_1.default.relative(absRoot, absWorktree);
@@ -1350,8 +1447,21 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
1350
1447
  // worktreePath may not exist yet (already removed); use as-is.
1351
1448
  }
1352
1449
  // 4a. Stale-lock guard: skip if lock is too fresh (PID recycling / race).
1450
+ //
1451
+ // The two causes are reported SEPARATELY (#3057). A lock whose mtime could
1452
+ // not be read is not "fresh" in any sense: `lock_too_fresh` tells an
1453
+ // operator that waiting will resolve the skip, and waiting never resolves a
1454
+ // stat failure — the lock could be seconds or months old and the sweep has
1455
+ // no way to tell. Conflating them is the same defect this module already
1456
+ // fixed for `parse_failed` vs `no_worktrees` in planWorktreePrune: a
1457
+ // decision made on unread data must not be indistinguishable from one made
1458
+ // on real data.
1353
1459
  const lockMtime = mtimeSafe(lockedFile);
1354
- if (!lockMtime || nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
1460
+ if (!lockMtime) {
1461
+ results.push({ path: worktreePath, status: 'skipped', reason: 'lock_age_unknown' });
1462
+ continue;
1463
+ }
1464
+ if (nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
1355
1465
  results.push({ path: worktreePath, status: 'skipped', reason: 'lock_too_fresh' });
1356
1466
  continue;
1357
1467
  }
@@ -1362,9 +1472,26 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
1362
1472
  continue;
1363
1473
  }
1364
1474
  const pid = parseInt(pidStr, 10);
1475
+ // Number.isFinite, not Number.isNaN: pidStr is captured by /^\d+/ above, so
1476
+ // pid can never be NaN. A 309-or-more-digit string parses to Infinity.
1477
+ //
1478
+ // NOT LOAD-BEARING FOR SAFETY — do not delete it as redundant. Fail-closed
1479
+ // liveness now lives in defaultIsPidAlive, which treats every non-ESRCH
1480
+ // outcome (including the TypeError process.kill throws for Infinity) as
1481
+ // ALIVE. This guard survives because it produces a more ACCURATE verdict
1482
+ // for garbage input: `lock_owner_unknown` says "the lock names a PID this
1483
+ // parse could not represent", whereas falling through would report
1484
+ // `pid_alive` — an assertion about an owner that was never probed.
1485
+ // Note this is an EARLIER, DIFFERENT gate than the process.kill range
1486
+ // limit: process.kill accepts up to 2147483647 and rejects 2147483648
1487
+ // (measured), far below the parse cliff this guard catches.
1488
+ if (!Number.isFinite(pid)) {
1489
+ results.push({ path: worktreePath, status: 'skipped', reason: 'lock_owner_unknown' });
1490
+ continue;
1491
+ }
1365
1492
  let pidIsAlive;
1366
1493
  try {
1367
- pidIsAlive = Number.isNaN(pid) || isPidAliveCheck(pid);
1494
+ pidIsAlive = isPidAliveCheck(pid);
1368
1495
  }
1369
1496
  catch {
1370
1497
  pidIsAlive = true; // Cannot determine liveness — treat as alive, do not reap.
@@ -1420,15 +1547,29 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
1420
1547
  return results;
1421
1548
  }
1422
1549
  // ─── reapOrphanWorktrees deps helpers ─────────────────────────────────────────
1550
+ /**
1551
+ * Liveness probe for a lock-owner PID — FAILS CLOSED (#3057).
1552
+ *
1553
+ * `ESRCH` ("no such process") is the ONLY outcome that proves the owner is
1554
+ * gone. Every other failure means the probe could not determine liveness:
1555
+ * - `EPERM` — the process exists, we just may not signal it;
1556
+ * - `TypeError` / `ERR_INVALID_ARG_TYPE` — `process.kill` accepts a pid up
1557
+ * to 2147483647 and REJECTS 2147483648 and above (measured), so a finite
1558
+ * but out-of-range pid never reaches the OS at all;
1559
+ * - anything else — an outcome this helper does not recognise.
1560
+ *
1561
+ * The return value feeds a DESTRUCTIVE decision (`git worktree remove
1562
+ * --force`), so an unrecognised failure must never read as "dead". Hence the
1563
+ * inversion: only ESRCH returns false; everything else returns true (alive,
1564
+ * do not reap).
1565
+ */
1423
1566
  function defaultIsPidAlive(pid) {
1424
1567
  try {
1425
1568
  process.kill(pid, 0);
1426
1569
  return true;
1427
1570
  }
1428
1571
  catch (err) {
1429
- if (err && err.code === 'EPERM')
1430
- return true;
1431
- return false;
1572
+ return err?.code !== 'ESRCH';
1432
1573
  }
1433
1574
  }
1434
1575
  function defaultReadDirSafe(dir) {
@@ -1455,36 +1596,45 @@ function defaultMtimeSafe(file) {
1455
1596
  return null;
1456
1597
  }
1457
1598
  }
1458
- function cmdWorktreeReapOrphans(cwd) {
1599
+ function cmdWorktreeReapOrphans(cwd, deps = {}) {
1600
+ const write = deps.write || ((s) => process.stdout.write(s));
1601
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
1459
1602
  let result;
1460
1603
  try {
1461
- result = reapOrphanWorktrees(cwd);
1604
+ result = reapOrphanWorktrees(cwd, deps);
1462
1605
  }
1463
1606
  catch (err) {
1464
1607
  // 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`);
1608
+ writeErr(`[gsd] worktree.reap-orphans failed: ${err && err.message ? err.message : String(err)}\n`);
1466
1609
  result = [];
1467
1610
  }
1468
1611
  const skippedCount = result.filter((r) => r.status === 'skipped').length;
1469
1612
  if (skippedCount > 0) {
1470
1613
  // 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`);
1614
+ writeErr(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
1472
1615
  }
1473
- process.stdout.write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
1616
+ write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
1474
1617
  }
1475
1618
  // Unused exports kept for API compatibility
1476
1619
  void parseWorktreeListPaths;
1477
1620
  // ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
1478
1621
  /**
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.
1622
+ * Resolve the main worktree root when running inside a git worktree, along
1623
+ * with the `reason` that produced it (#3050). Callers MUST inspect `reason`
1624
+ * before trusting `root` unconditionally — a `reason` of `git_timed_out`
1625
+ * means the git subprocess used to distinguish "linked worktree" from
1626
+ * "not a repo" never completed, so `root` is a best-effort fallback (cwd),
1627
+ * not a confirmed worktree root. Degrading to cwd rather than throwing is
1628
+ * intentional (return degraded result on timeout; do not throw) — but the
1629
+ * reason must still reach the caller so it can surface the risk instead of
1630
+ * silently trusting the wrong root.
1482
1631
  */
1483
- function resolveWorktreeRoot(cwd) {
1632
+ function resolveWorktreeRoot(cwd, deps = {}) {
1484
1633
  const context = resolveWorktreeContext(cwd, {
1485
- existsSync: node_fs_1.default.existsSync,
1634
+ existsSync: deps.existsSync || node_fs_1.default.existsSync,
1635
+ execGit: deps.execGit,
1486
1636
  });
1487
- return context.effectiveRoot;
1637
+ return { root: context.effectiveRoot, reason: context.reason };
1488
1638
  }
1489
1639
  /**
1490
1640
  * Clear stale worktree metadata references via `git worktree prune`.
@@ -1495,12 +1645,19 @@ function resolveWorktreeRoot(cwd) {
1495
1645
  * the repository; used as `cwd` for git commands.
1496
1646
  * @returns list of worktree paths that were removed (always empty)
1497
1647
  */
1498
- function pruneOrphanedWorktrees(repoRoot) {
1648
+ function pruneOrphanedWorktrees(repoRoot, deps = {}) {
1649
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
1499
1650
  try {
1500
- const plan = planWorktreePrune(repoRoot, { allowDestructive: false }, { parseWorktreePorcelain });
1501
- const pruneResult = executeWorktreePrunePlan(plan);
1651
+ // `...deps` comes LAST deliberately: `parseWorktreePorcelain` is a declared
1652
+ // member of WorktreeDeps and planWorktreePrune already reads
1653
+ // `deps.parseWorktreePorcelain` before falling back to the module function,
1654
+ // so a caller-supplied parser is an intended override, not an accident.
1655
+ // The hard-coded key is only a restatement of that same default. Do not
1656
+ // reorder the two — `tests/worktree-safety-reap.test.cjs` pins the override.
1657
+ const plan = planWorktreePrune(repoRoot, { allowDestructive: false }, { parseWorktreePorcelain, ...deps });
1658
+ const pruneResult = executeWorktreePrunePlan(plan, deps);
1502
1659
  if (pruneResult && pruneResult.timedOut) {
1503
- process.stderr.write('[gsd-tools] WARNING: worktree health check degraded' +
1660
+ writeErr('[gsd-tools] WARNING: worktree health check degraded' +
1504
1661
  ' — git worktree prune timed out after 10s.' +
1505
1662
  ' Orphaned worktree metadata may remain until the next successful run.\n');
1506
1663
  }
@@ -1510,6 +1667,7 @@ function pruneOrphanedWorktrees(repoRoot) {
1510
1667
  }
1511
1668
  module.exports = {
1512
1669
  resolveWorktreeContext,
1670
+ resolveWorktreeLinkage,
1513
1671
  parseWorktreePorcelain,
1514
1672
  planWorktreePrune,
1515
1673
  executeWorktreePrunePlan,