gsd-pi 2.22.0 → 2.24.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 (228) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.js +74 -7
  3. package/dist/headless.d.ts +25 -0
  4. package/dist/headless.js +454 -0
  5. package/dist/help-text.js +47 -0
  6. package/dist/mcp-server.d.ts +20 -3
  7. package/dist/mcp-server.js +21 -1
  8. package/dist/models-resolver.d.ts +32 -0
  9. package/dist/models-resolver.js +50 -0
  10. package/dist/resource-loader.js +64 -9
  11. package/dist/resources/extensions/bg-shell/output-formatter.ts +36 -16
  12. package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
  13. package/dist/resources/extensions/bg-shell/types.ts +33 -1
  14. package/dist/resources/extensions/browser-tools/capture.ts +18 -16
  15. package/dist/resources/extensions/browser-tools/index.ts +20 -0
  16. package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  17. package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  18. package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  19. package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
  20. package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
  21. package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  22. package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  23. package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  24. package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  25. package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  26. package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  27. package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
  28. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  29. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  30. package/dist/resources/extensions/gsd/auto-recovery.ts +51 -2
  31. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  32. package/dist/resources/extensions/gsd/auto.ts +560 -52
  33. package/dist/resources/extensions/gsd/captures.ts +49 -0
  34. package/dist/resources/extensions/gsd/commands.ts +194 -11
  35. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  36. package/dist/resources/extensions/gsd/dashboard-overlay.ts +54 -2
  37. package/dist/resources/extensions/gsd/diff-context.ts +73 -80
  38. package/dist/resources/extensions/gsd/doctor.ts +76 -12
  39. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  40. package/dist/resources/extensions/gsd/forensics.ts +95 -52
  41. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  42. package/dist/resources/extensions/gsd/guided-flow.ts +85 -5
  43. package/dist/resources/extensions/gsd/index.ts +34 -1
  44. package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
  45. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  46. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  47. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  48. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  50. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  51. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
  52. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  53. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  54. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  55. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  56. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
  57. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  58. package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
  59. package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
  60. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  61. package/dist/resources/extensions/gsd/state.ts +72 -30
  62. package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  63. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  64. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  65. package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  66. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  67. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
  68. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  69. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  70. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  71. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  72. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  73. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  74. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  75. package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  76. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  77. package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  78. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  79. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  80. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  81. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  82. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  83. package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  84. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  85. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  86. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  87. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  88. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  89. package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
  90. package/dist/resources/extensions/gsd/types.ts +15 -1
  91. package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  92. package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
  93. package/dist/resources/extensions/subagent/index.ts +5 -0
  94. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  95. package/dist/update-check.d.ts +9 -0
  96. package/dist/update-check.js +97 -0
  97. package/package.json +6 -1
  98. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  99. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  100. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  101. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  102. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  103. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  104. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  105. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  106. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  107. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  108. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  109. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  110. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  111. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  112. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  113. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  114. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  115. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  116. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  117. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  118. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  119. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  121. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  122. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
  123. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
  124. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
  125. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
  126. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
  127. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  128. package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
  129. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  130. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
  131. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
  133. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  135. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  136. package/packages/pi-coding-agent/dist/index.js +1 -1
  137. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  138. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  139. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  140. package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
  141. package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
  142. package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
  143. package/packages/pi-coding-agent/src/index.ts +1 -0
  144. package/scripts/postinstall.js +7 -109
  145. package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
  146. package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
  147. package/src/resources/extensions/bg-shell/types.ts +33 -1
  148. package/src/resources/extensions/browser-tools/capture.ts +18 -16
  149. package/src/resources/extensions/browser-tools/index.ts +20 -0
  150. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  151. package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  152. package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  153. package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
  154. package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
  155. package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  156. package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  157. package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  158. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  159. package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  160. package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  161. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  162. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  163. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  164. package/src/resources/extensions/gsd/auto-recovery.ts +51 -2
  165. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  166. package/src/resources/extensions/gsd/auto.ts +560 -52
  167. package/src/resources/extensions/gsd/captures.ts +49 -0
  168. package/src/resources/extensions/gsd/commands.ts +194 -11
  169. package/src/resources/extensions/gsd/complexity.ts +1 -0
  170. package/src/resources/extensions/gsd/dashboard-overlay.ts +54 -2
  171. package/src/resources/extensions/gsd/diff-context.ts +73 -80
  172. package/src/resources/extensions/gsd/doctor.ts +76 -12
  173. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  174. package/src/resources/extensions/gsd/forensics.ts +95 -52
  175. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  176. package/src/resources/extensions/gsd/guided-flow.ts +85 -5
  177. package/src/resources/extensions/gsd/index.ts +34 -1
  178. package/src/resources/extensions/gsd/mcp-server.ts +33 -12
  179. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  180. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  181. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  182. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  183. package/src/resources/extensions/gsd/preferences.ts +65 -1
  184. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  185. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
  186. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  187. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  188. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  189. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  190. package/src/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
  191. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  192. package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
  193. package/src/resources/extensions/gsd/session-forensics.ts +36 -2
  194. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  195. package/src/resources/extensions/gsd/state.ts +72 -30
  196. package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  197. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  198. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  199. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  200. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  201. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
  202. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  203. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  204. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  205. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  206. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  207. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  208. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  209. package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  210. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  211. package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  212. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  213. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  214. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  215. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  216. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  217. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  218. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  219. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  220. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  221. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  222. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  223. package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
  224. package/src/resources/extensions/gsd/types.ts +15 -1
  225. package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  226. package/src/resources/extensions/gsd/workspace-index.ts +34 -6
  227. package/src/resources/extensions/subagent/index.ts +5 -0
  228. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -108,6 +108,7 @@ import {
108
108
  autoWorktreeBranch,
109
109
  } from "./auto-worktree.js";
110
110
  import { pruneQueueOrder } from "./queue-order.js";
111
+ import { consumeSignal } from "./session-status-io.js";
111
112
  import { showNextAction } from "../shared/next-action-ui.js";
112
113
  import { debugLog, debugTime, debugCount, debugPeak, enableDebug, isDebugEnabled, writeDebugSummary, getDebugLogPath } from "./debug-logger.js";
113
114
  import {
@@ -125,6 +126,18 @@ import {
125
126
  reconcileMergeState,
126
127
  } from "./auto-recovery.js";
127
128
  import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js";
129
+ import {
130
+ buildResearchSlicePrompt,
131
+ buildResearchMilestonePrompt,
132
+ buildPlanSlicePrompt,
133
+ buildPlanMilestonePrompt,
134
+ buildExecuteTaskPrompt,
135
+ buildCompleteSlicePrompt,
136
+ buildCompleteMilestonePrompt,
137
+ buildReassessRoadmapPrompt,
138
+ buildRunUatPrompt,
139
+ buildReplanSlicePrompt,
140
+ } from "./auto-prompts.js";
128
141
  import {
129
142
  type AutoDashboardData,
130
143
  updateProgressWidget as _updateProgressWidget,
@@ -183,6 +196,35 @@ function syncStateToProjectRoot(worktreePath: string, projectRoot: string, miles
183
196
  cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
184
197
  }
185
198
  } catch { /* non-fatal */ }
199
+
200
+ // 3. Merge completed-units.json (set-union of both locations)
201
+ // Prevents already-completed units from being re-dispatched after crash/restart.
202
+ const srcKeysFile = join(wtGsd, "completed-units.json");
203
+ const dstKeysFile = join(prGsd, "completed-units.json");
204
+ if (existsSync(srcKeysFile)) {
205
+ try {
206
+ const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
207
+ let dstKeys: string[] = [];
208
+ if (existsSync(dstKeysFile)) {
209
+ try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
210
+ }
211
+ const merged = [...new Set([...dstKeys, ...srcKeys])];
212
+ writeFileSync(dstKeysFile, JSON.stringify(merged, null, 2));
213
+ } catch { /* non-fatal */ }
214
+ }
215
+
216
+ // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
217
+ // Without this, a crash during a unit leaves the runtime record only in the
218
+ // worktree. If the next session resolves basePath before worktree re-entry,
219
+ // selfHeal can't find or clear the stale record (#769).
220
+ try {
221
+ const srcRuntime = join(wtGsd, "runtime", "units");
222
+ const dstRuntime = join(prGsd, "runtime", "units");
223
+ if (existsSync(srcRuntime)) {
224
+ mkdirSync(dstRuntime, { recursive: true });
225
+ cpSync(srcRuntime, dstRuntime, { recursive: true, force: true });
226
+ }
227
+ } catch { /* non-fatal */ }
186
228
  }
187
229
 
188
230
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -211,6 +253,11 @@ const MAX_LIFETIME_DISPATCHES = 6;
211
253
  /** Tracks recovery attempt count per unit for backoff and diagnostics. */
212
254
  const unitRecoveryCount = new Map<string, number>();
213
255
 
256
+ /** Track consecutive skips per unit — catches infinite skip loops where deriveState
257
+ * keeps returning the same already-completed unit. Reset on any real dispatch. */
258
+ const unitConsecutiveSkips = new Map<string, number>();
259
+ const MAX_CONSECUTIVE_SKIPS = 3;
260
+
214
261
  /** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */
215
262
  const completedKeySet = new Set<string>();
216
263
 
@@ -297,6 +344,9 @@ let currentUnit: { type: string; id: string; startedAt: number } | null = null;
297
344
  /** Track dynamic routing decision for the current unit (for metrics) */
298
345
  let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
299
346
 
347
+ /** Queue of quick-task captures awaiting dispatch after triage resolution */
348
+ let pendingQuickTasks: import("./captures.js").CaptureEntry[] = [];
349
+
300
350
  /**
301
351
  * Model captured at auto-mode start. Used to prevent model bleed between
302
352
  * concurrent GSD instances sharing the same global settings.json (#650).
@@ -334,14 +384,19 @@ let lastBaselineCharCount: number | undefined;
334
384
  /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
335
385
  let _sigtermHandler: (() => void) | null = null;
336
386
 
337
- /** Tool calls currently being executed — prevents false idle detection during long-running tools. */
338
- const inFlightTools = new Set<string>();
387
+ /**
388
+ * Tool calls currently being executed — prevents false idle detection during long-running tools.
389
+ * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
390
+ * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
391
+ */
392
+ const inFlightTools = new Map<string, number>();
339
393
 
340
- type BudgetAlertLevel = 0 | 75 | 90 | 100;
394
+ type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100;
341
395
 
342
396
  export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
343
397
  if (budgetPct >= 1.0) return 100;
344
398
  if (budgetPct >= 0.90) return 90;
399
+ if (budgetPct >= 0.80) return 80;
345
400
  if (budgetPct >= 0.75) return 75;
346
401
  return 0;
347
402
  }
@@ -414,11 +469,11 @@ export function isAutoPaused(): boolean {
414
469
 
415
470
  /**
416
471
  * Mark a tool execution as in-flight. Called from index.ts on tool_execution_start.
417
- * Prevents the idle watchdog from declaring the agent idle while tools are executing.
472
+ * Records start time so the idle watchdog can detect tools hung longer than the idle timeout.
418
473
  */
419
474
  export function markToolStart(toolCallId: string): void {
420
475
  if (!active) return;
421
- inFlightTools.add(toolCallId);
476
+ inFlightTools.set(toolCallId, Date.now());
422
477
  }
423
478
 
424
479
  /**
@@ -428,6 +483,16 @@ export function markToolEnd(toolCallId: string): void {
428
483
  inFlightTools.delete(toolCallId);
429
484
  }
430
485
 
486
+ /**
487
+ * Returns the age (ms) of the oldest currently in-flight tool, or 0 if none.
488
+ * Exported for testing.
489
+ */
490
+ export function getOldestInFlightToolAgeMs(): number {
491
+ if (inFlightTools.size === 0) return 0;
492
+ const oldestStart = Math.min(...inFlightTools.values());
493
+ return Date.now() - oldestStart;
494
+ }
495
+
431
496
  /**
432
497
  * Return the base path to use for the auto.lock file.
433
498
  * Always uses the original project root (not the worktree) so that
@@ -518,11 +583,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
518
583
  await dispatchNextUnit(ctx, pi);
519
584
  } catch (retryErr) {
520
585
  const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
521
- ctx.ui.notify(
522
- `Dispatch gap recovery failed: ${message}. Stopping auto-mode.`,
523
- "error",
524
- );
525
- await stopAuto(ctx, pi);
586
+ await stopAuto(ctx, pi, `Dispatch gap recovery failed: ${message}`);
526
587
  return;
527
588
  }
528
589
 
@@ -530,17 +591,14 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
530
591
  // (no sendMessage called → no timeout set), auto-mode is permanently
531
592
  // stalled. Stop cleanly instead of leaving it active but idle (#537).
532
593
  if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
533
- ctx.ui.notify(
534
- "Auto-mode stalled — no dispatchable unit found after retry. Stopping. Run /gsd auto to restart.",
535
- "warning",
536
- );
537
- await stopAuto(ctx, pi);
594
+ await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry");
538
595
  }
539
596
  }, DISPATCH_GAP_TIMEOUT_MS);
540
597
  }
541
598
 
542
- export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise<void> {
599
+ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise<void> {
543
600
  if (!active && !paused) return;
601
+ const reasonSuffix = reason ? ` — ${reason}` : "";
544
602
  clearUnitTimeout();
545
603
  if (lockBase()) clearLock(lockBase());
546
604
  clearSkillSnapshot();
@@ -592,11 +650,11 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
592
650
  if (ledger && ledger.units.length > 0) {
593
651
  const totals = getProjectTotals(ledger.units);
594
652
  ctx?.ui.notify(
595
- `Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
653
+ `Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
596
654
  "info",
597
655
  );
598
656
  } else {
599
- ctx?.ui.notify("Auto-mode stopped.", "info");
657
+ ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info");
600
658
  }
601
659
 
602
660
  // Sync disk state so next resume starts from accurate state
@@ -621,6 +679,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
621
679
  stepMode = false;
622
680
  unitDispatchCount.clear();
623
681
  unitRecoveryCount.clear();
682
+ unitConsecutiveSkips.clear();
624
683
  inFlightTools.clear();
625
684
  lastBudgetAlertLevel = 0;
626
685
  unitLifetimeDispatches.clear();
@@ -629,6 +688,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
629
688
  currentMilestoneId = null;
630
689
  originalBasePath = "";
631
690
  completedUnits = [];
691
+ pendingQuickTasks = [];
632
692
  clearSliceProgressCache();
633
693
  clearActivityLogState();
634
694
  resetProactiveHealing();
@@ -710,6 +770,7 @@ export async function startAuto(
710
770
  basePath = base;
711
771
  unitDispatchCount.clear();
712
772
  unitLifetimeDispatches.clear();
773
+ unitConsecutiveSkips.clear();
713
774
  // Re-initialize metrics in case ledger was lost during pause
714
775
  if (!getLedger()) initMetrics(base);
715
776
  // Ensure milestone ID is set on git service for integration branch resolution
@@ -782,6 +843,9 @@ export async function startAuto(
782
843
  pausedSessionFile = null;
783
844
  }
784
845
 
846
+ // Write lock on resume so cross-process status detection works (#723).
847
+ writeLock(lockBase(), "resuming", currentMilestoneId ?? "unknown", completedUnits.length);
848
+
785
849
  await dispatchNextUnit(ctx, pi);
786
850
  return;
787
851
  }
@@ -988,6 +1052,7 @@ export async function startAuto(
988
1052
  basePath = base;
989
1053
  unitDispatchCount.clear();
990
1054
  unitRecoveryCount.clear();
1055
+ unitConsecutiveSkips.clear();
991
1056
  lastBudgetAlertLevel = 0;
992
1057
  unitLifetimeDispatches.clear();
993
1058
  completedKeySet.clear();
@@ -998,6 +1063,7 @@ export async function startAuto(
998
1063
  autoStartTime = Date.now();
999
1064
  resourceSyncedAtOnStart = readResourceSyncedAt();
1000
1065
  completedUnits = [];
1066
+ pendingQuickTasks = [];
1001
1067
  currentUnit = null;
1002
1068
  currentMilestoneId = state.activeMilestone?.id ?? null;
1003
1069
  originalModelId = ctx.model?.id ?? null;
@@ -1052,6 +1118,13 @@ export async function startAuto(
1052
1118
  }
1053
1119
  // Re-register SIGTERM handler with the original basePath (lock lives there)
1054
1120
  registerSigtermHandler(originalBasePath);
1121
+
1122
+ // After worktree entry, load completed keys from BOTH locations (project root
1123
+ // + worktree) so the in-memory set is the union. Prevents re-dispatch of units
1124
+ // completed in either location after crash/restart (#769).
1125
+ if (basePath !== originalBasePath) {
1126
+ loadPersistedKeys(basePath, completedKeySet);
1127
+ }
1055
1128
  } catch (err) {
1056
1129
  // Worktree creation is non-fatal — continue in the project root.
1057
1130
  ctx.ui.notify(
@@ -1088,11 +1161,12 @@ export async function startAuto(
1088
1161
  }
1089
1162
  }
1090
1163
 
1091
- // Initialize metrics — loads existing ledger from disk
1092
- initMetrics(base);
1164
+ // Initialize metrics — loads existing ledger from disk.
1165
+ // Use basePath (not base) so worktree-mode reads the worktree ledger (#769).
1166
+ initMetrics(basePath);
1093
1167
 
1094
1168
  // Initialize routing history for adaptive learning
1095
- initRoutingHistory(base);
1169
+ initRoutingHistory(basePath);
1096
1170
 
1097
1171
  // Capture the session's current model at auto-mode start (#650).
1098
1172
  // This prevents model bleed when multiple GSD instances share the
@@ -1116,6 +1190,11 @@ export async function startAuto(
1116
1190
  : "Will loop until milestone complete.";
1117
1191
  ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
1118
1192
 
1193
+ // Write initial lock file immediately so cross-process status detection
1194
+ // works even before the first unit is dispatched (#723).
1195
+ // The lock is updated with unit-specific info on each dispatch and cleared on stop.
1196
+ writeLock(lockBase(), "starting", currentMilestoneId ?? "unknown", 0);
1197
+
1119
1198
  // Secrets collection gate — collect pending secrets before first dispatch
1120
1199
  const mid = state.activeMilestone!.id;
1121
1200
  try {
@@ -1138,8 +1217,10 @@ export async function startAuto(
1138
1217
  );
1139
1218
  }
1140
1219
 
1141
- // Self-heal: clear stale runtime records where artifacts already exist
1142
- await selfHealRuntimeRecords(base, ctx, completedKeySet);
1220
+ // Self-heal: clear stale runtime records where artifacts already exist.
1221
+ // Use basePath (not base) — in worktree mode, basePath points to the worktree
1222
+ // where runtime records and artifacts actually live (#769).
1223
+ await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
1143
1224
 
1144
1225
  // Self-heal: remove stale .git/index.lock from prior crash.
1145
1226
  // A stale lock file blocks all git operations (commit, merge, checkout).
@@ -1205,6 +1286,27 @@ export async function handleAgentEnd(
1205
1286
  // Unit completed — clear its timeout
1206
1287
  clearUnitTimeout();
1207
1288
 
1289
+ // ── Parallel worker signal check ─────────────────────────────────────
1290
+ // When running as a parallel worker (GSD_MILESTONE_LOCK set), check for
1291
+ // coordinator signals before dispatching the next unit.
1292
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
1293
+ if (milestoneLock) {
1294
+ const signal = consumeSignal(basePath, milestoneLock);
1295
+ if (signal) {
1296
+ if (signal.signal === "stop") {
1297
+ _handlingAgentEnd = false;
1298
+ await stopAuto(ctx, pi);
1299
+ return;
1300
+ }
1301
+ if (signal.signal === "pause") {
1302
+ _handlingAgentEnd = false;
1303
+ await pauseAuto(ctx, pi);
1304
+ return;
1305
+ }
1306
+ // "resume" and "rebase" signals are handled elsewhere or no-op here
1307
+ }
1308
+ }
1309
+
1208
1310
  // Invalidate all caches — the unit just completed and may have
1209
1311
  // written planning files (task summaries, roadmap checkboxes, etc.)
1210
1312
  invalidateAllCaches();
@@ -1297,6 +1399,53 @@ export async function handleAgentEnd(
1297
1399
  }
1298
1400
  }
1299
1401
 
1402
+ // ── Post-triage: execute actionable resolutions (inject, replan, queue quick-tasks) ──
1403
+ // After a triage-captures unit completes, the LLM has classified captures and
1404
+ // updated CAPTURES.md. Now we execute those classifications: inject tasks into
1405
+ // the plan, write replan triggers, and queue quick-tasks for dispatch.
1406
+ if (currentUnit.type === "triage-captures") {
1407
+ try {
1408
+ const { executeTriageResolutions } = await import("./triage-resolution.js");
1409
+ const state = await deriveState(basePath);
1410
+ const mid = state.activeMilestone?.id;
1411
+ const sid = state.activeSlice?.id;
1412
+
1413
+ if (mid && sid) {
1414
+ const triageResult = executeTriageResolutions(basePath, mid, sid);
1415
+
1416
+ if (triageResult.injected > 0) {
1417
+ ctx.ui.notify(
1418
+ `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`,
1419
+ "info",
1420
+ );
1421
+ }
1422
+ if (triageResult.replanned > 0) {
1423
+ ctx.ui.notify(
1424
+ `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`,
1425
+ "info",
1426
+ );
1427
+ }
1428
+ if (triageResult.quickTasks.length > 0) {
1429
+ // Queue quick-tasks for dispatch. They'll be picked up by the
1430
+ // quick-task dispatch block below the triage check.
1431
+ for (const qt of triageResult.quickTasks) {
1432
+ pendingQuickTasks.push(qt);
1433
+ }
1434
+ ctx.ui.notify(
1435
+ `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`,
1436
+ "info",
1437
+ );
1438
+ }
1439
+ for (const action of triageResult.actions) {
1440
+ process.stderr.write(`gsd-triage: ${action}\n`);
1441
+ }
1442
+ }
1443
+ } catch (err) {
1444
+ // Non-fatal — triage resolution failure shouldn't block dispatch
1445
+ process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`);
1446
+ }
1447
+ }
1448
+
1300
1449
  // ── Path A fix: verify artifact and persist completion before re-entering dispatch ──
1301
1450
  // After doctor + rebuildState, check whether the just-completed unit actually
1302
1451
  // produced its expected artifact. If so, persist the completion key now so the
@@ -1391,7 +1540,7 @@ export async function handleAgentEnd(
1391
1540
  const result = await cmdCtx!.newSession();
1392
1541
  if (result.cancelled) {
1393
1542
  resetHookState();
1394
- await stopAuto(ctx, pi);
1543
+ await stopAuto(ctx, pi, "Hook session cancelled");
1395
1544
  return;
1396
1545
  }
1397
1546
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -1521,7 +1670,7 @@ export async function handleAgentEnd(
1521
1670
  return;
1522
1671
  }
1523
1672
  const sessionFile = ctx.sessionManager.getSessionFile();
1524
- writeLock(basePath, triageUnitType, triageUnitId, completedUnits.length, sessionFile);
1673
+ writeLock(lockBase(), triageUnitType, triageUnitId, completedUnits.length, sessionFile);
1525
1674
 
1526
1675
  // Start unit timeout for triage (use same supervisor config as hooks)
1527
1676
  clearUnitTimeout();
@@ -1551,6 +1700,85 @@ export async function handleAgentEnd(
1551
1700
  }
1552
1701
  }
1553
1702
 
1703
+ // ── Quick-task dispatch: execute queued quick-tasks from triage resolution ──
1704
+ // Quick-tasks are self-contained one-off tasks that don't modify the plan.
1705
+ // They're queued during post-triage resolution and dispatched here one at a time.
1706
+ if (
1707
+ !stepMode &&
1708
+ pendingQuickTasks.length > 0 &&
1709
+ currentUnit &&
1710
+ currentUnit.type !== "quick-task"
1711
+ ) {
1712
+ try {
1713
+ const capture = pendingQuickTasks.shift()!;
1714
+ const { buildQuickTaskPrompt } = await import("./triage-resolution.js");
1715
+ const { markCaptureExecuted } = await import("./captures.js");
1716
+ const prompt = buildQuickTaskPrompt(capture);
1717
+
1718
+ ctx.ui.notify(
1719
+ `Executing quick-task: ${capture.id} — "${capture.text}"`,
1720
+ "info",
1721
+ );
1722
+
1723
+ // Close out previous unit metrics
1724
+ if (currentUnit) {
1725
+ const modelId = ctx.model?.id ?? "unknown";
1726
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1727
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1728
+ }
1729
+
1730
+ // Dispatch quick-task as a new unit
1731
+ const qtUnitType = "quick-task";
1732
+ const qtUnitId = `${currentMilestoneId}/${capture.id}`;
1733
+ const qtStartedAt = Date.now();
1734
+ currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt };
1735
+ writeUnitRuntimeRecord(basePath, qtUnitType, qtUnitId, qtStartedAt, {
1736
+ phase: "dispatched",
1737
+ wrapupWarningSent: false,
1738
+ timeoutAt: null,
1739
+ lastProgressAt: qtStartedAt,
1740
+ progressCount: 0,
1741
+ lastProgressKind: "dispatch",
1742
+ });
1743
+ const state = await deriveState(basePath);
1744
+ updateProgressWidget(ctx, qtUnitType, qtUnitId, state);
1745
+
1746
+ const result = await cmdCtx!.newSession();
1747
+ if (result.cancelled) {
1748
+ await stopAuto(ctx, pi);
1749
+ return;
1750
+ }
1751
+ const sessionFile = ctx.sessionManager.getSessionFile();
1752
+ writeLock(lockBase(), qtUnitType, qtUnitId, completedUnits.length, sessionFile);
1753
+
1754
+ // Mark capture as executed now that the unit is dispatched
1755
+ markCaptureExecuted(basePath, capture.id);
1756
+
1757
+ // Start unit timeout for quick-task
1758
+ clearUnitTimeout();
1759
+ const supervisor = resolveAutoSupervisorConfig();
1760
+ const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
1761
+ unitTimeoutHandle = setTimeout(async () => {
1762
+ unitTimeoutHandle = null;
1763
+ if (!active) return;
1764
+ ctx.ui.notify(
1765
+ `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`,
1766
+ "warning",
1767
+ );
1768
+ await pauseAuto(ctx, pi);
1769
+ }, qtTimeoutMs);
1770
+
1771
+ if (!active) return;
1772
+ pi.sendMessage(
1773
+ { customType: "gsd-auto", content: prompt, display: verbose },
1774
+ { triggerTurn: true },
1775
+ );
1776
+ return; // handleAgentEnd will fire again when quick-task session completes
1777
+ } catch {
1778
+ // Non-fatal — proceed to normal dispatch
1779
+ }
1780
+ }
1781
+
1554
1782
  // In step mode, pause and show a wizard instead of immediately dispatching
1555
1783
  if (stepMode) {
1556
1784
  await showStepWizard(ctx, pi);
@@ -1610,7 +1838,15 @@ async function showStepWizard(
1610
1838
 
1611
1839
  // If no active milestone or everything is complete, stop
1612
1840
  if (!mid || state.phase === "complete") {
1613
- await stopAuto(ctx, pi);
1841
+ const incomplete = state.registry.filter(m => m.status !== "complete");
1842
+ if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") {
1843
+ const ids = incomplete.map(m => m.id).join(", ");
1844
+ const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1845
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
1846
+ await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`);
1847
+ } else {
1848
+ await stopAuto(ctx, pi, state.phase === "complete" ? "All work complete" : "No active milestone");
1849
+ }
1614
1850
  return;
1615
1851
  }
1616
1852
 
@@ -1733,8 +1969,7 @@ async function dispatchNextUnit(
1733
1969
  // doesn't provide. Stop gracefully instead of crashing.
1734
1970
  const staleMsg = checkResourcesStale();
1735
1971
  if (staleMsg) {
1736
- await stopAuto(ctx, pi);
1737
- ctx.ui.notify(staleMsg, "error");
1972
+ await stopAuto(ctx, pi, staleMsg);
1738
1973
  return;
1739
1974
  }
1740
1975
 
@@ -1788,6 +2023,7 @@ async function dispatchNextUnit(
1788
2023
  // Reset stuck detection for new milestone
1789
2024
  unitDispatchCount.clear();
1790
2025
  unitRecoveryCount.clear();
2026
+ unitConsecutiveSkips.clear();
1791
2027
  unitLifetimeDispatches.clear();
1792
2028
  // Clear completed-units.json for the finished milestone
1793
2029
  try {
@@ -1880,8 +2116,25 @@ async function dispatchNextUnit(
1880
2116
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
1881
2117
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1882
2118
  }
1883
- sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
1884
- await stopAuto(ctx, pi);
2119
+
2120
+ const incomplete = state.registry.filter(m => m.status !== "complete");
2121
+ if (incomplete.length === 0) {
2122
+ // Genuinely all complete
2123
+ sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
2124
+ await stopAuto(ctx, pi, "All milestones complete");
2125
+ } else if (state.phase === "blocked") {
2126
+ // Milestones exist but are dependency-blocked
2127
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2128
+ await stopAuto(ctx, pi, blockerMsg);
2129
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
2130
+ sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2131
+ } else {
2132
+ // Milestones with remaining work exist but none became active — unexpected
2133
+ const ids = incomplete.map(m => m.id).join(", ");
2134
+ const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
2135
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
2136
+ await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
2137
+ }
1885
2138
  return;
1886
2139
  }
1887
2140
 
@@ -1889,8 +2142,8 @@ async function dispatchNextUnit(
1889
2142
  // The !mid check above returns early if mid is falsy; midTitle comes from
1890
2143
  // the same object so it should always be present when mid is.
1891
2144
  if (!midTitle) {
1892
- await stopAuto(ctx, pi);
1893
- return;
2145
+ midTitle = mid; // Defensive fallback: use milestone ID as title
2146
+ ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
1894
2147
  }
1895
2148
 
1896
2149
  // ── Mid-merge safety check: detect leftover merge state from a prior session ──
@@ -1908,7 +2161,10 @@ async function dispatchNextUnit(
1908
2161
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
1909
2162
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1910
2163
  }
1911
- await stopAuto(ctx, pi);
2164
+ const noMilestoneReason = !mid
2165
+ ? "No active milestone after merge reconciliation"
2166
+ : `Milestone ${mid} has no title after reconciliation`;
2167
+ await stopAuto(ctx, pi, noMilestoneReason);
1912
2168
  return;
1913
2169
  }
1914
2170
 
@@ -1983,7 +2239,7 @@ async function dispatchNextUnit(
1983
2239
  }
1984
2240
  }
1985
2241
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
1986
- await stopAuto(ctx, pi);
2242
+ await stopAuto(ctx, pi, `Milestone ${mid} complete`);
1987
2243
  return;
1988
2244
  }
1989
2245
 
@@ -1993,8 +2249,8 @@ async function dispatchNextUnit(
1993
2249
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
1994
2250
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1995
2251
  }
1996
- await stopAuto(ctx, pi);
1997
2252
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2253
+ await stopAuto(ctx, pi, blockerMsg);
1998
2254
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
1999
2255
  sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2000
2256
  return;
@@ -2020,9 +2276,8 @@ async function dispatchNextUnit(
2020
2276
  const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
2021
2277
  lastBudgetAlertLevel = newBudgetAlertLevel;
2022
2278
  if (budgetEnforcementAction === "halt") {
2023
- ctx.ui.notify(`${msg} Stopping auto-mode.`, "error");
2024
2279
  sendDesktopNotification("GSD", msg, "error", "budget");
2025
- await stopAuto(ctx, pi);
2280
+ await stopAuto(ctx, pi, "Budget ceiling reached");
2026
2281
  return;
2027
2282
  }
2028
2283
  if (budgetEnforcementAction === "pause") {
@@ -2037,6 +2292,10 @@ async function dispatchNextUnit(
2037
2292
  lastBudgetAlertLevel = newBudgetAlertLevel;
2038
2293
  ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2039
2294
  sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2295
+ } else if (newBudgetAlertLevel === 80) {
2296
+ lastBudgetAlertLevel = newBudgetAlertLevel;
2297
+ ctx.ui.notify(`Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2298
+ sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2040
2299
  } else if (newBudgetAlertLevel === 75) {
2041
2300
  lastBudgetAlertLevel = newBudgetAlertLevel;
2042
2301
  ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
@@ -2101,8 +2360,7 @@ async function dispatchNextUnit(
2101
2360
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2102
2361
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2103
2362
  }
2104
- await stopAuto(ctx, pi);
2105
- ctx.ui.notify(dispatchResult.reason, dispatchResult.level);
2363
+ await stopAuto(ctx, pi, dispatchResult.reason);
2106
2364
  return;
2107
2365
  }
2108
2366
 
@@ -2142,8 +2400,7 @@ async function dispatchNextUnit(
2142
2400
 
2143
2401
  const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
2144
2402
  if (priorSliceBlocker) {
2145
- await stopAuto(ctx, pi);
2146
- ctx.ui.notify(priorSliceBlocker, "error");
2403
+ await stopAuto(ctx, pi, priorSliceBlocker);
2147
2404
  return;
2148
2405
  }
2149
2406
 
@@ -2155,6 +2412,26 @@ async function dispatchNextUnit(
2155
2412
  // Cross-validate: does the expected artifact actually exist?
2156
2413
  const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
2157
2414
  if (artifactExists) {
2415
+ // Guard against infinite skip loops: if deriveState keeps returning the
2416
+ // same completed unit, consecutive skips will trip this breaker. Evict the
2417
+ // key so the next dispatch forces full reconciliation instead of looping.
2418
+ const skipCount = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2419
+ unitConsecutiveSkips.set(idempotencyKey, skipCount);
2420
+ if (skipCount > MAX_CONSECUTIVE_SKIPS) {
2421
+ unitConsecutiveSkips.delete(idempotencyKey);
2422
+ completedKeySet.delete(idempotencyKey);
2423
+ removePersistedKey(basePath, idempotencyKey);
2424
+ invalidateStateCache();
2425
+ ctx.ui.notify(
2426
+ `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`,
2427
+ "warning",
2428
+ );
2429
+ _skipDepth++;
2430
+ await new Promise(r => setTimeout(r, 50));
2431
+ await dispatchNextUnit(ctx, pi);
2432
+ _skipDepth = Math.max(0, _skipDepth - 1);
2433
+ return;
2434
+ }
2158
2435
  ctx.ui.notify(
2159
2436
  `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
2160
2437
  "info",
@@ -2184,6 +2461,24 @@ async function dispatchNextUnit(
2184
2461
  persistCompletedKey(basePath, idempotencyKey);
2185
2462
  completedKeySet.add(idempotencyKey);
2186
2463
  invalidateStateCache();
2464
+ // Same consecutive-skip guard as the idempotency path above.
2465
+ const skipCount2 = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2466
+ unitConsecutiveSkips.set(idempotencyKey, skipCount2);
2467
+ if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
2468
+ unitConsecutiveSkips.delete(idempotencyKey);
2469
+ completedKeySet.delete(idempotencyKey);
2470
+ removePersistedKey(basePath, idempotencyKey);
2471
+ invalidateStateCache();
2472
+ ctx.ui.notify(
2473
+ `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`,
2474
+ "warning",
2475
+ );
2476
+ _skipDepth++;
2477
+ await new Promise(r => setTimeout(r, 50));
2478
+ await dispatchNextUnit(ctx, pi);
2479
+ _skipDepth = Math.max(0, _skipDepth - 1);
2480
+ return;
2481
+ }
2187
2482
  ctx.ui.notify(
2188
2483
  `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
2189
2484
  "info",
@@ -2199,6 +2494,8 @@ async function dispatchNextUnit(
2199
2494
  // Pattern A→B→A→B would reset retryCount every time; this map catches it.
2200
2495
  const dispatchKey = `${unitType}/${unitId}`;
2201
2496
  const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
2497
+ // Real dispatch reached — clear the consecutive-skip counter for this unit.
2498
+ unitConsecutiveSkips.delete(dispatchKey);
2202
2499
 
2203
2500
  debugLog("dispatch-unit", {
2204
2501
  type: unitType,
@@ -2220,9 +2517,9 @@ async function dispatchNextUnit(
2220
2517
  }
2221
2518
  saveActivityLog(ctx, basePath, unitType, unitId);
2222
2519
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2223
- await stopAuto(ctx, pi);
2520
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`);
2224
2521
  ctx.ui.notify(
2225
- `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles). Stopping.${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
2522
+ `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
2226
2523
  "error",
2227
2524
  );
2228
2525
  return;
@@ -2315,7 +2612,7 @@ async function dispatchNextUnit(
2315
2612
 
2316
2613
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2317
2614
  const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
2318
- await stopAuto(ctx, pi);
2615
+ await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`);
2319
2616
  sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
2320
2617
  ctx.ui.notify(
2321
2618
  `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
@@ -2456,8 +2753,7 @@ async function dispatchNextUnit(
2456
2753
  // Fresh session
2457
2754
  const result = await cmdCtx!.newSession();
2458
2755
  if (result.cancelled) {
2459
- await stopAuto(ctx, pi);
2460
- ctx.ui.notify("Auto-mode stopped.", "info");
2756
+ await stopAuto(ctx, pi, "Session cancelled");
2461
2757
  return;
2462
2758
  }
2463
2759
 
@@ -2713,13 +3009,27 @@ async function dispatchNextUnit(
2713
3009
  if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
2714
3010
 
2715
3011
  // Agent has tool calls currently executing (await_job, long bash, etc.) —
2716
- // not idle, just waiting for tool completion.
3012
+ // not idle, just waiting for tool completion. But only suppress recovery
3013
+ // if the tool started recently. A tool in-flight for longer than the idle
3014
+ // timeout is likely stuck — e.g., `python -m http.server 8080 &` keeps the
3015
+ // shell's stdout/stderr open, causing the Bash tool to hang indefinitely.
2717
3016
  if (inFlightTools.size > 0) {
2718
- writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2719
- lastProgressAt: Date.now(),
2720
- lastProgressKind: "tool-in-flight",
2721
- });
2722
- return;
3017
+ const oldestStart = Math.min(...inFlightTools.values());
3018
+ const toolAgeMs = Date.now() - oldestStart;
3019
+ if (toolAgeMs < idleTimeoutMs) {
3020
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
3021
+ lastProgressAt: Date.now(),
3022
+ lastProgressKind: "tool-in-flight",
3023
+ });
3024
+ return;
3025
+ }
3026
+ // Oldest tool has been running >= idleTimeoutMs — treat as a stuck/hung
3027
+ // tool (e.g., background process holding stdout open). Fall through to
3028
+ // idle recovery without resetting the progress clock.
3029
+ ctx.ui.notify(
3030
+ `Stalled tool detected: a tool has been in-flight for ${Math.round(toolAgeMs / 60000)}min. Treating as hung — attempting idle recovery.`,
3031
+ "warning",
3032
+ );
2723
3033
  }
2724
3034
 
2725
3035
  // Before triggering recovery, check if the agent is actually producing
@@ -3144,6 +3454,14 @@ export {
3144
3454
  buildLoopRemediationSteps,
3145
3455
  } from "./auto-recovery.js";
3146
3456
 
3457
+ /**
3458
+ * Test-only: expose skip-loop state for unit tests.
3459
+ * Not part of the public API.
3460
+ */
3461
+ export function _getUnitConsecutiveSkips(): Map<string, number> { return unitConsecutiveSkips; }
3462
+ export function _resetUnitConsecutiveSkips(): void { unitConsecutiveSkips.clear(); }
3463
+ export { MAX_CONSECUTIVE_SKIPS };
3464
+
3147
3465
  /**
3148
3466
  * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
3149
3467
  * Used for manual hook triggers via /gsd run-hook.
@@ -3168,6 +3486,7 @@ export async function dispatchHookUnit(
3168
3486
  autoStartTime = Date.now();
3169
3487
  currentUnit = null;
3170
3488
  completedUnits = [];
3489
+ pendingQuickTasks = [];
3171
3490
  }
3172
3491
 
3173
3492
  const hookUnitType = `hook/${hookName}`;
@@ -3248,3 +3567,192 @@ export async function dispatchHookUnit(
3248
3567
 
3249
3568
  return true;
3250
3569
  }
3570
+
3571
+
3572
+ // ─── Direct Phase Dispatch ────────────────────────────────────────────────────
3573
+
3574
+ export async function dispatchDirectPhase(
3575
+ ctx: ExtensionCommandContext,
3576
+ pi: ExtensionAPI,
3577
+ phase: string,
3578
+ base: string,
3579
+ ): Promise<void> {
3580
+ const state = await deriveState(base);
3581
+ const mid = state.activeMilestone?.id;
3582
+ const midTitle = state.activeMilestone?.title ?? "";
3583
+
3584
+ if (!mid) {
3585
+ ctx.ui.notify("Cannot dispatch: no active milestone.", "warning");
3586
+ return;
3587
+ }
3588
+
3589
+ const normalized = phase.toLowerCase();
3590
+ let unitType: string;
3591
+ let unitId: string;
3592
+ let prompt: string;
3593
+
3594
+ switch (normalized) {
3595
+ case "research":
3596
+ case "research-milestone":
3597
+ case "research-slice": {
3598
+ const isSlice = normalized === "research-slice" || (normalized === "research" && state.phase !== "pre-planning");
3599
+ if (isSlice) {
3600
+ const sid = state.activeSlice?.id;
3601
+ const sTitle = state.activeSlice?.title ?? "";
3602
+ if (!sid) {
3603
+ ctx.ui.notify("Cannot dispatch research-slice: no active slice.", "warning");
3604
+ return;
3605
+ }
3606
+ unitType = "research-slice";
3607
+ unitId = `${mid}/${sid}`;
3608
+ prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, base);
3609
+ } else {
3610
+ unitType = "research-milestone";
3611
+ unitId = mid;
3612
+ prompt = await buildResearchMilestonePrompt(mid, midTitle, base);
3613
+ }
3614
+ break;
3615
+ }
3616
+
3617
+ case "plan":
3618
+ case "plan-milestone":
3619
+ case "plan-slice": {
3620
+ const isSlice = normalized === "plan-slice" || (normalized === "plan" && state.phase !== "pre-planning");
3621
+ if (isSlice) {
3622
+ const sid = state.activeSlice?.id;
3623
+ const sTitle = state.activeSlice?.title ?? "";
3624
+ if (!sid) {
3625
+ ctx.ui.notify("Cannot dispatch plan-slice: no active slice.", "warning");
3626
+ return;
3627
+ }
3628
+ unitType = "plan-slice";
3629
+ unitId = `${mid}/${sid}`;
3630
+ prompt = await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, base);
3631
+ } else {
3632
+ unitType = "plan-milestone";
3633
+ unitId = mid;
3634
+ prompt = await buildPlanMilestonePrompt(mid, midTitle, base);
3635
+ }
3636
+ break;
3637
+ }
3638
+
3639
+ case "execute":
3640
+ case "execute-task": {
3641
+ const sid = state.activeSlice?.id;
3642
+ const sTitle = state.activeSlice?.title ?? "";
3643
+ const tid = state.activeTask?.id;
3644
+ const tTitle = state.activeTask?.title ?? "";
3645
+ if (!sid) {
3646
+ ctx.ui.notify("Cannot dispatch execute-task: no active slice.", "warning");
3647
+ return;
3648
+ }
3649
+ if (!tid) {
3650
+ ctx.ui.notify("Cannot dispatch execute-task: no active task.", "warning");
3651
+ return;
3652
+ }
3653
+ unitType = "execute-task";
3654
+ unitId = `${mid}/${sid}/${tid}`;
3655
+ prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
3656
+ break;
3657
+ }
3658
+
3659
+ case "complete":
3660
+ case "complete-slice":
3661
+ case "complete-milestone": {
3662
+ const isSlice = normalized === "complete-slice" || (normalized === "complete" && state.phase === "summarizing");
3663
+ if (isSlice) {
3664
+ const sid = state.activeSlice?.id;
3665
+ const sTitle = state.activeSlice?.title ?? "";
3666
+ if (!sid) {
3667
+ ctx.ui.notify("Cannot dispatch complete-slice: no active slice.", "warning");
3668
+ return;
3669
+ }
3670
+ unitType = "complete-slice";
3671
+ unitId = `${mid}/${sid}`;
3672
+ prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, base);
3673
+ } else {
3674
+ unitType = "complete-milestone";
3675
+ unitId = mid;
3676
+ prompt = await buildCompleteMilestonePrompt(mid, midTitle, base);
3677
+ }
3678
+ break;
3679
+ }
3680
+
3681
+ case "reassess":
3682
+ case "reassess-roadmap": {
3683
+ const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
3684
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
3685
+ if (!roadmapContent) {
3686
+ ctx.ui.notify("Cannot dispatch reassess-roadmap: no roadmap found.", "warning");
3687
+ return;
3688
+ }
3689
+ const roadmap = parseRoadmap(roadmapContent);
3690
+ const completedSlices = roadmap.slices.filter(s => s.done);
3691
+ if (completedSlices.length === 0) {
3692
+ ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning");
3693
+ return;
3694
+ }
3695
+ const completedSliceId = completedSlices[completedSlices.length - 1].id;
3696
+ unitType = "reassess-roadmap";
3697
+ unitId = `${mid}/${completedSliceId}`;
3698
+ prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, base);
3699
+ break;
3700
+ }
3701
+
3702
+ case "uat":
3703
+ case "run-uat": {
3704
+ const sid = state.activeSlice?.id;
3705
+ if (!sid) {
3706
+ ctx.ui.notify("Cannot dispatch run-uat: no active slice.", "warning");
3707
+ return;
3708
+ }
3709
+ const uatFile = resolveSliceFile(base, mid, sid, "UAT");
3710
+ if (!uatFile) {
3711
+ ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");
3712
+ return;
3713
+ }
3714
+ const uatContent = await loadFile(uatFile);
3715
+ if (!uatContent) {
3716
+ ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning");
3717
+ return;
3718
+ }
3719
+ const uatPath = relSliceFile(base, mid, sid, "UAT");
3720
+ unitType = "run-uat";
3721
+ unitId = `${mid}/${sid}`;
3722
+ prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base);
3723
+ break;
3724
+ }
3725
+
3726
+ case "replan":
3727
+ case "replan-slice": {
3728
+ const sid = state.activeSlice?.id;
3729
+ const sTitle = state.activeSlice?.title ?? "";
3730
+ if (!sid) {
3731
+ ctx.ui.notify("Cannot dispatch replan-slice: no active slice.", "warning");
3732
+ return;
3733
+ }
3734
+ unitType = "replan-slice";
3735
+ unitId = `${mid}/${sid}`;
3736
+ prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base);
3737
+ break;
3738
+ }
3739
+
3740
+ default:
3741
+ ctx.ui.notify(
3742
+ `Unknown phase "${phase}". Valid phases: research, plan, execute, complete, reassess, uat, replan.`,
3743
+ "warning",
3744
+ );
3745
+ return;
3746
+ }
3747
+
3748
+ ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info");
3749
+ const result = await ctx.newSession();
3750
+ if (result.cancelled) {
3751
+ ctx.ui.notify("Session creation cancelled.", "warning");
3752
+ return;
3753
+ }
3754
+ pi.sendMessage(
3755
+ { customType: "gsd-dispatch", content: prompt, display: false },
3756
+ { triggerTurn: true },
3757
+ );
3758
+ }