gsd-pi 2.38.0-dev.eeb3520 → 2.39.0-dev.64cd3ed

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 (255) hide show
  1. package/README.md +15 -11
  2. package/dist/app-paths.js +1 -1
  3. package/dist/cli.js +9 -0
  4. package/dist/extension-discovery.d.ts +5 -3
  5. package/dist/extension-discovery.js +14 -9
  6. package/dist/extension-registry.js +2 -2
  7. package/dist/remote-questions-config.js +2 -2
  8. package/dist/resource-loader.js +34 -1
  9. package/dist/resources/extensions/async-jobs/index.js +10 -0
  10. package/dist/resources/extensions/browser-tools/index.js +3 -1
  11. package/dist/resources/extensions/browser-tools/package.json +3 -1
  12. package/dist/resources/extensions/browser-tools/tools/verify.js +97 -0
  13. package/dist/resources/extensions/cmux/index.js +55 -1
  14. package/dist/resources/extensions/context7/package.json +1 -1
  15. package/dist/resources/extensions/env-utils.js +29 -0
  16. package/dist/resources/extensions/get-secrets-from-user.js +5 -24
  17. package/dist/resources/extensions/github-sync/cli.js +284 -0
  18. package/dist/resources/extensions/github-sync/index.js +73 -0
  19. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  20. package/dist/resources/extensions/github-sync/sync.js +424 -0
  21. package/dist/resources/extensions/github-sync/templates.js +118 -0
  22. package/dist/resources/extensions/github-sync/types.js +7 -0
  23. package/dist/resources/extensions/google-search/package.json +3 -1
  24. package/dist/resources/extensions/gsd/auto/session.js +6 -23
  25. package/dist/resources/extensions/gsd/auto-dispatch.js +8 -9
  26. package/dist/resources/extensions/gsd/auto-loop.js +650 -588
  27. package/dist/resources/extensions/gsd/auto-post-unit.js +99 -70
  28. package/dist/resources/extensions/gsd/auto-prompts.js +202 -48
  29. package/dist/resources/extensions/gsd/auto-start.js +13 -2
  30. package/dist/resources/extensions/gsd/auto-worktree-sync.js +13 -5
  31. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  32. package/dist/resources/extensions/gsd/auto.js +143 -96
  33. package/dist/resources/extensions/gsd/captures.js +9 -1
  34. package/dist/resources/extensions/gsd/commands-extensions.js +3 -2
  35. package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
  36. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  37. package/dist/resources/extensions/gsd/commands.js +24 -3
  38. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  39. package/dist/resources/extensions/gsd/detection.js +1 -2
  40. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  41. package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
  42. package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
  43. package/dist/resources/extensions/gsd/doctor-format.js +15 -0
  44. package/dist/resources/extensions/gsd/doctor-providers.js +30 -11
  45. package/dist/resources/extensions/gsd/doctor.js +204 -12
  46. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  47. package/dist/resources/extensions/gsd/export.js +1 -1
  48. package/dist/resources/extensions/gsd/files.js +48 -9
  49. package/dist/resources/extensions/gsd/forensics.js +1 -1
  50. package/dist/resources/extensions/gsd/git-service.js +30 -12
  51. package/dist/resources/extensions/gsd/gitignore.js +16 -3
  52. package/dist/resources/extensions/gsd/guided-flow.js +149 -38
  53. package/dist/resources/extensions/gsd/health-widget-core.js +32 -70
  54. package/dist/resources/extensions/gsd/health-widget.js +3 -86
  55. package/dist/resources/extensions/gsd/index.js +24 -20
  56. package/dist/resources/extensions/gsd/migrate/parsers.js +1 -1
  57. package/dist/resources/extensions/gsd/migrate-external.js +18 -1
  58. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  59. package/dist/resources/extensions/gsd/package.json +1 -1
  60. package/dist/resources/extensions/gsd/paths.js +3 -0
  61. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  62. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  63. package/dist/resources/extensions/gsd/preferences-validation.js +59 -11
  64. package/dist/resources/extensions/gsd/preferences.js +22 -11
  65. package/dist/resources/extensions/gsd/prompt-loader.js +6 -2
  66. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  67. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  68. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  69. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -3
  70. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  71. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  72. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  73. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  74. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  75. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  76. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  77. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  78. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  79. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  80. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  81. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  82. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  83. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  84. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  85. package/dist/resources/extensions/gsd/prompts/run-uat.md +28 -11
  86. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  87. package/dist/resources/extensions/gsd/repo-identity.js +21 -4
  88. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  89. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  90. package/dist/resources/extensions/gsd/state.js +42 -23
  91. package/dist/resources/extensions/gsd/templates/runtime.md +21 -0
  92. package/dist/resources/extensions/gsd/templates/task-plan.md +3 -0
  93. package/dist/resources/extensions/gsd/visualizer-data.js +1 -1
  94. package/dist/resources/extensions/gsd/worktree.js +35 -16
  95. package/dist/resources/extensions/mcp-client/index.js +14 -1
  96. package/dist/resources/extensions/remote-questions/status.js +4 -1
  97. package/dist/resources/extensions/remote-questions/store.js +4 -1
  98. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  99. package/dist/resources/extensions/shared/frontmatter.js +1 -1
  100. package/dist/resources/extensions/subagent/index.js +12 -3
  101. package/dist/resources/extensions/subagent/isolation.js +2 -1
  102. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  103. package/dist/resources/extensions/universal-config/package.json +1 -1
  104. package/dist/welcome-screen.d.ts +12 -0
  105. package/dist/welcome-screen.js +53 -0
  106. package/package.json +1 -1
  107. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  108. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  109. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  110. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  112. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  113. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
  115. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/skills.d.ts +1 -0
  117. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/skills.js +6 -1
  119. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  121. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  122. package/packages/pi-coding-agent/dist/index.js +1 -1
  123. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  124. package/packages/pi-coding-agent/package.json +1 -1
  125. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  126. package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
  127. package/packages/pi-coding-agent/src/core/skills.ts +9 -1
  128. package/packages/pi-coding-agent/src/index.ts +1 -0
  129. package/pkg/package.json +1 -1
  130. package/src/resources/extensions/async-jobs/index.ts +11 -0
  131. package/src/resources/extensions/browser-tools/index.ts +3 -0
  132. package/src/resources/extensions/browser-tools/tools/verify.ts +117 -0
  133. package/src/resources/extensions/cmux/index.ts +57 -1
  134. package/src/resources/extensions/env-utils.ts +31 -0
  135. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  136. package/src/resources/extensions/github-sync/cli.ts +364 -0
  137. package/src/resources/extensions/github-sync/index.ts +93 -0
  138. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  139. package/src/resources/extensions/github-sync/sync.ts +556 -0
  140. package/src/resources/extensions/github-sync/templates.ts +183 -0
  141. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  142. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  143. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  144. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  145. package/src/resources/extensions/github-sync/types.ts +47 -0
  146. package/src/resources/extensions/gsd/auto/session.ts +7 -25
  147. package/src/resources/extensions/gsd/auto-dispatch.ts +7 -9
  148. package/src/resources/extensions/gsd/auto-loop.ts +553 -546
  149. package/src/resources/extensions/gsd/auto-post-unit.ts +80 -44
  150. package/src/resources/extensions/gsd/auto-prompts.ts +247 -50
  151. package/src/resources/extensions/gsd/auto-start.ts +18 -2
  152. package/src/resources/extensions/gsd/auto-worktree-sync.ts +15 -4
  153. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  154. package/src/resources/extensions/gsd/auto.ts +139 -101
  155. package/src/resources/extensions/gsd/captures.ts +10 -1
  156. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  157. package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
  158. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  159. package/src/resources/extensions/gsd/commands.ts +26 -4
  160. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  161. package/src/resources/extensions/gsd/detection.ts +2 -2
  162. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  163. package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
  164. package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
  165. package/src/resources/extensions/gsd/doctor-format.ts +20 -0
  166. package/src/resources/extensions/gsd/doctor-providers.ts +30 -9
  167. package/src/resources/extensions/gsd/doctor-types.ts +16 -1
  168. package/src/resources/extensions/gsd/doctor.ts +199 -14
  169. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  170. package/src/resources/extensions/gsd/export.ts +1 -1
  171. package/src/resources/extensions/gsd/files.ts +51 -11
  172. package/src/resources/extensions/gsd/forensics.ts +1 -1
  173. package/src/resources/extensions/gsd/git-service.ts +44 -10
  174. package/src/resources/extensions/gsd/gitignore.ts +17 -3
  175. package/src/resources/extensions/gsd/guided-flow.ts +177 -44
  176. package/src/resources/extensions/gsd/health-widget-core.ts +28 -80
  177. package/src/resources/extensions/gsd/health-widget.ts +3 -89
  178. package/src/resources/extensions/gsd/index.ts +24 -17
  179. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  180. package/src/resources/extensions/gsd/migrate-external.ts +18 -1
  181. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  182. package/src/resources/extensions/gsd/paths.ts +4 -0
  183. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  184. package/src/resources/extensions/gsd/preferences-types.ts +4 -4
  185. package/src/resources/extensions/gsd/preferences-validation.ts +51 -11
  186. package/src/resources/extensions/gsd/preferences.ts +25 -11
  187. package/src/resources/extensions/gsd/prompt-loader.ts +7 -2
  188. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  189. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  190. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  191. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -3
  192. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  193. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  194. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  195. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  196. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  197. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  198. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  199. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  200. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  201. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  202. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  203. package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  204. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  205. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  206. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  207. package/src/resources/extensions/gsd/prompts/run-uat.md +28 -11
  208. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  209. package/src/resources/extensions/gsd/repo-identity.ts +23 -4
  210. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  211. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  212. package/src/resources/extensions/gsd/state.ts +39 -21
  213. package/src/resources/extensions/gsd/templates/runtime.md +21 -0
  214. package/src/resources/extensions/gsd/templates/task-plan.md +3 -0
  215. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  216. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +122 -68
  217. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +4 -3
  218. package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
  219. package/src/resources/extensions/gsd/tests/derive-state.test.ts +43 -0
  220. package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
  221. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
  222. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +50 -0
  223. package/src/resources/extensions/gsd/tests/health-widget.test.ts +16 -54
  224. package/src/resources/extensions/gsd/tests/parsers.test.ts +131 -14
  225. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +209 -0
  226. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  227. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  228. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  229. package/src/resources/extensions/gsd/tests/run-uat.test.ts +16 -4
  230. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +140 -0
  231. package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
  232. package/src/resources/extensions/gsd/types.ts +18 -1
  233. package/src/resources/extensions/gsd/verification-evidence.ts +16 -0
  234. package/src/resources/extensions/gsd/visualizer-data.ts +1 -1
  235. package/src/resources/extensions/gsd/worktree.ts +35 -15
  236. package/src/resources/extensions/mcp-client/index.ts +17 -1
  237. package/src/resources/extensions/remote-questions/status.ts +5 -1
  238. package/src/resources/extensions/remote-questions/store.ts +5 -1
  239. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  240. package/src/resources/extensions/shared/frontmatter.ts +1 -1
  241. package/src/resources/extensions/subagent/index.ts +12 -3
  242. package/src/resources/extensions/subagent/isolation.ts +3 -1
  243. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
  244. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  245. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  246. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  247. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  248. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  249. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  250. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  251. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  252. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  253. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  254. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  255. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -5,20 +5,20 @@
5
5
  * pattern with a while loop. The agent_end event resolves a promise instead
6
6
  * of recursing.
7
7
  *
8
- * MAINTENANCE RULE: The only module-level mutable state here is `_activeSession`,
9
- * used by the agent_end bridge. Promise state itself lives on AutoSession so
10
- * concurrent auto sessions cannot corrupt each other.
8
+ * MAINTENANCE RULE: Module-level mutable state is limited to `_currentResolve`
9
+ * (per-unit one-shot resolver) and `_sessionSwitchInFlight` (guard for
10
+ * session rotation). No queue stale agent_end events are dropped.
11
11
  */
12
12
 
13
- import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
13
+ import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent";
14
14
 
15
- import type { AutoSession } from "./auto/session.js";
15
+ import type { AutoSession, SidecarItem } from "./auto/session.js";
16
16
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
17
17
  import type { GSDPreferences } from "./preferences.js";
18
18
  import type { SessionLockStatus } from "./session-lock.js";
19
19
  import type { GSDState } from "./types.js";
20
20
  import type { CloseoutOptions } from "./auto-unit-closeout.js";
21
- import type { PostUnitContext } from "./auto-post-unit.js";
21
+ import type { PostUnitContext, PreVerificationOpts } from "./auto-post-unit.js";
22
22
  import type {
23
23
  VerificationContext,
24
24
  VerificationResult,
@@ -26,6 +26,9 @@ import type {
26
26
  import type { DispatchAction } from "./auto-dispatch.js";
27
27
  import type { WorktreeResolver } from "./worktree-resolver.js";
28
28
  import { debugLog } from "./debug-logger.js";
29
+ import { gsdRoot } from "./paths.js";
30
+ import { atomicWriteSync } from "./atomic-write.js";
31
+ import { join } from "node:path";
29
32
  import type { CmuxLogLevel } from "../cmux/index.js";
30
33
 
31
34
  /**
@@ -35,6 +38,23 @@ import type { CmuxLogLevel } from "../cmux/index.js";
35
38
  * generous headroom including retries and sidecar work.
36
39
  */
37
40
  const MAX_LOOP_ITERATIONS = 500;
41
+ /** Maximum characters of failure/crash context included in recovery prompts. */
42
+ const MAX_RECOVERY_CHARS = 50_000;
43
+
44
+ /** Data-driven budget threshold notifications (descending). The 100% entry
45
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
46
+ * a simple notification. */
47
+ const BUDGET_THRESHOLDS: Array<{
48
+ pct: number;
49
+ label: string;
50
+ notifyLevel: "info" | "warning" | "error";
51
+ cmuxLevel: "progress" | "warning" | "error";
52
+ }> = [
53
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
54
+ { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
55
+ { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
56
+ { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
57
+ ];
38
58
 
39
59
  // ─── Types ───────────────────────────────────────────────────────────────────
40
60
 
@@ -54,17 +74,15 @@ export interface UnitResult {
54
74
  event?: AgentEndEvent;
55
75
  }
56
76
 
57
- // ─── Session-scoped promise state ───────────────────────────────────────────
77
+ // ─── Per-unit one-shot promise state ────────────────────────────────────────
58
78
  //
59
- // pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level)
60
- // so concurrent sessions cannot corrupt each other's promises.
79
+ // A single module-level resolve function scoped to the current unit execution.
80
+ // No queue if an agent_end arrives with no pending resolver, it is dropped
81
+ // (logged as warning). This is simpler and safer than the previous session-
82
+ // scoped pendingResolve + pendingAgentEndQueue pattern.
61
83
 
62
- /**
63
- * The singleton session reference used by resolveAgentEnd. Set by autoLoop
64
- * on entry so that the agent_end handler in index.ts can resolve the correct
65
- * session's promise without needing a direct reference to `s`.
66
- */
67
- let _activeSession: AutoSession | null = null;
84
+ let _currentResolve: ((result: UnitResult) => void) | null = null;
85
+ let _sessionSwitchInFlight = false;
68
86
 
69
87
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
70
88
 
@@ -73,60 +91,105 @@ let _activeSession: AutoSession | null = null;
73
91
  * in-flight unit promise. One-shot: the resolver is nulled before calling
74
92
  * to prevent double-resolution from model fallback retries.
75
93
  *
76
- * If no pendingResolve exists (event arrived between loop iterations),
77
- * the event is queued on the session so the next runUnit can drain it.
94
+ * If no resolver exists (event arrived between loop iterations or during
95
+ * session switch), the event is dropped with a debug warning.
78
96
  */
79
97
  export function resolveAgentEnd(event: AgentEndEvent): void {
80
- const s = _activeSession;
81
- if (!s) {
82
- debugLog("resolveAgentEnd", {
83
- status: "no-active-session",
84
- warning: "agent_end with no active loop session",
85
- });
98
+ if (_sessionSwitchInFlight) {
99
+ debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
86
100
  return;
87
101
  }
88
-
89
- if (s.pendingResolve) {
102
+ if (_currentResolve) {
90
103
  debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
91
- const r = s.pendingResolve;
92
- s.pendingResolve = null;
104
+ const r = _currentResolve;
105
+ _currentResolve = null;
93
106
  r({ status: "completed", event });
94
107
  } else {
95
- // Queue the event so the next runUnit picks it up immediately
96
108
  debugLog("resolveAgentEnd", {
97
- status: "queued",
98
- queueLength: s.pendingAgentEndQueue.length + 1,
99
- warning:
100
- "agent_end arrived between loop iterations — queued for next runUnit",
109
+ status: "no-pending-resolve",
110
+ warning: "agent_end with no pending unit",
101
111
  });
102
- s.pendingAgentEndQueue.push(event);
103
112
  }
104
113
  }
105
114
 
106
115
  export function isSessionSwitchInFlight(): boolean {
107
- return _activeSession?.sessionSwitchInFlight ?? false;
116
+ return _sessionSwitchInFlight;
108
117
  }
109
118
 
110
119
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
111
120
 
112
121
  /**
113
- * Reset session promise state. Only exported for test cleanup — production code
114
- * should never call this.
122
+ * Reset module-level promise state. Only exported for test cleanup —
123
+ * production code should never call this.
115
124
  */
116
125
  export function _resetPendingResolve(): void {
117
- if (_activeSession) {
118
- _activeSession.pendingResolve = null;
119
- _activeSession.pendingAgentEndQueue = [];
120
- }
121
- _activeSession = null;
126
+ _currentResolve = null;
127
+ _sessionSwitchInFlight = false;
122
128
  }
123
129
 
124
130
  /**
125
- * Set the active session for resolveAgentEnd. Only exported for test setup —
126
- * production code sets this via autoLoop entry.
131
+ * No-op for backward compatibility with tests that previously set the
132
+ * active session. The module no longer holds a session reference.
127
133
  */
128
- export function _setActiveSession(session: AutoSession | null): void {
129
- _activeSession = session;
134
+ export function _setActiveSession(_session: AutoSession | null): void {
135
+ // No-op — kept for test backward compatibility
136
+ }
137
+
138
+ // ─── detectStuck ─────────────────────────────────────────────────────────────
139
+
140
+ type WindowEntry = { key: string; error?: string };
141
+
142
+ /**
143
+ * Analyze a sliding window of recent unit dispatches for stuck patterns.
144
+ * Returns a signal with reason if stuck, null otherwise.
145
+ *
146
+ * Rule 1: Same error string twice in a row → stuck immediately.
147
+ * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
148
+ * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
149
+ */
150
+ export function detectStuck(
151
+ window: readonly WindowEntry[],
152
+ ): { stuck: true; reason: string } | null {
153
+ if (window.length < 2) return null;
154
+
155
+ const last = window[window.length - 1];
156
+ const prev = window[window.length - 2];
157
+
158
+ // Rule 1: Same error repeated consecutively
159
+ if (last.error && prev.error && last.error === prev.error) {
160
+ return {
161
+ stuck: true,
162
+ reason: `Same error repeated: ${last.error.slice(0, 200)}`,
163
+ };
164
+ }
165
+
166
+ // Rule 2: Same unit 3+ consecutive times
167
+ if (window.length >= 3) {
168
+ const lastThree = window.slice(-3);
169
+ if (lastThree.every((u) => u.key === last.key)) {
170
+ return {
171
+ stuck: true,
172
+ reason: `${last.key} derived 3 consecutive times without progress`,
173
+ };
174
+ }
175
+ }
176
+
177
+ // Rule 3: Oscillation (A→B→A→B in last 4)
178
+ if (window.length >= 4) {
179
+ const w = window.slice(-4);
180
+ if (
181
+ w[0].key === w[2].key &&
182
+ w[1].key === w[3].key &&
183
+ w[0].key !== w[1].key
184
+ ) {
185
+ return {
186
+ stuck: true,
187
+ reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
188
+ };
189
+ }
190
+ }
191
+
192
+ return null;
130
193
  }
131
194
 
132
195
  // ─── runUnit ─────────────────────────────────────────────────────────────────
@@ -146,45 +209,18 @@ export async function runUnit(
146
209
  unitType: string,
147
210
  unitId: string,
148
211
  prompt: string,
149
- _prefs: GSDPreferences | undefined,
150
212
  ): Promise<UnitResult> {
151
213
  debugLog("runUnit", { phase: "start", unitType, unitId });
152
214
 
153
- // ── Drain queued events from error-recovery retries ──
154
- // If an agent_end arrived between iterations (e.g. from a model fallback
155
- // sendMessage retry), consume it immediately instead of creating a new promise.
156
- // Cap queue to 3 entries to prevent unbounded growth from stale events.
157
- if (s.pendingAgentEndQueue.length > 3) {
158
- debugLog("runUnit", {
159
- phase: "queue-overflow",
160
- dropped: s.pendingAgentEndQueue.length - 1,
161
- unitType,
162
- unitId,
163
- });
164
- s.pendingAgentEndQueue = [
165
- s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1]!,
166
- ];
167
- }
168
- if (s.pendingAgentEndQueue.length > 0) {
169
- const queued = s.pendingAgentEndQueue.shift()!;
170
- debugLog("runUnit", {
171
- phase: "drained-queued-event",
172
- unitType,
173
- unitId,
174
- queueRemaining: s.pendingAgentEndQueue.length,
175
- });
176
- return { status: "completed", event: queued };
177
- }
178
-
179
215
  // ── Session creation with timeout ──
180
216
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
181
217
 
182
218
  let sessionResult: { cancelled: boolean };
183
219
  let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
184
- s.sessionSwitchInFlight = true;
220
+ _sessionSwitchInFlight = true;
185
221
  try {
186
222
  const sessionPromise = s.cmdCtx!.newSession().finally(() => {
187
- s.sessionSwitchInFlight = false;
223
+ _sessionSwitchInFlight = false;
188
224
  });
189
225
  const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
190
226
  sessionTimeoutHandle = setTimeout(
@@ -216,11 +252,12 @@ export async function runUnit(
216
252
  return { status: "cancelled" };
217
253
  }
218
254
 
219
- // ── Create the agent_end promise (session-scoped) ──
255
+ // ── Create the agent_end promise (per-unit one-shot) ──
220
256
  // This happens after newSession completes so session-switch agent_end events
221
257
  // from the previous session cannot resolve the new unit.
258
+ _sessionSwitchInFlight = false;
222
259
  const unitPromise = new Promise<UnitResult>((resolve) => {
223
- s.pendingResolve = resolve;
260
+ _currentResolve = resolve;
224
261
  });
225
262
 
226
263
  // Ensure cwd matches basePath before dispatch (#1389).
@@ -250,6 +287,20 @@ export async function runUnit(
250
287
  status: result.status,
251
288
  });
252
289
 
290
+ // Discard trailing follow-up messages (e.g. async_job_result notifications)
291
+ // from the completed unit. Without this, queued follow-ups trigger wasteful
292
+ // LLM turns before the next session can start (#1642).
293
+ // clearQueue() lives on AgentSession but isn't part of the typed
294
+ // ExtensionCommandContext interface — call it via runtime check.
295
+ try {
296
+ const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
297
+ if (typeof cmdCtxAny?.clearQueue === "function") {
298
+ (cmdCtxAny.clearQueue as () => unknown)();
299
+ }
300
+ } catch {
301
+ // Non-fatal — clearQueue may not be available in all contexts
302
+ }
303
+
253
304
  return result;
254
305
  }
255
306
 
@@ -383,6 +434,7 @@ export interface LoopDeps {
383
434
  midTitle: string;
384
435
  state: GSDState;
385
436
  prefs: GSDPreferences | undefined;
437
+ session?: AutoSession;
386
438
  }) => Promise<DispatchAction>;
387
439
  runPreDispatchHooks: (
388
440
  unitType: string,
@@ -500,6 +552,7 @@ export interface LoopDeps {
500
552
  // Post-unit processing
501
553
  postUnitPreVerification: (
502
554
  pctx: PostUnitContext,
555
+ opts?: PreVerificationOpts,
503
556
  ) => Promise<"dispatched" | "continue">;
504
557
  runPostUnitVerification: (
505
558
  vctx: VerificationContext,
@@ -513,6 +566,96 @@ export interface LoopDeps {
513
566
  getSessionFile: (ctx: ExtensionContext) => string;
514
567
  }
515
568
 
569
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
570
+
571
+ /**
572
+ * Generate and write an HTML milestone report snapshot.
573
+ * Extracted from the milestone-transition block in autoLoop.
574
+ */
575
+ async function generateMilestoneReport(
576
+ s: AutoSession,
577
+ ctx: ExtensionContext,
578
+ milestoneId: string,
579
+ ): Promise<void> {
580
+ const { loadVisualizerData } = await importExtensionModule<typeof import("./visualizer-data.js")>(import.meta.url, "./visualizer-data.js");
581
+ const { generateHtmlReport } = await importExtensionModule<typeof import("./export-html.js")>(import.meta.url, "./export-html.js");
582
+ const { writeReportSnapshot } = await importExtensionModule<typeof import("./reports.js")>(import.meta.url, "./reports.js");
583
+ const { basename } = await import("node:path");
584
+
585
+ const snapData = await loadVisualizerData(s.basePath);
586
+ const completedMs = snapData.milestones.find(
587
+ (m: { id: string }) => m.id === milestoneId,
588
+ );
589
+ const msTitle = completedMs?.title ?? milestoneId;
590
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
591
+ const projName = basename(s.basePath);
592
+ const doneSlices = snapData.milestones.reduce(
593
+ (acc: number, m: { slices: { done: boolean }[] }) =>
594
+ acc + m.slices.filter((sl: { done: boolean }) => sl.done).length,
595
+ 0,
596
+ );
597
+ const totalSlices = snapData.milestones.reduce(
598
+ (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
599
+ 0,
600
+ );
601
+ const outPath = writeReportSnapshot({
602
+ basePath: s.basePath,
603
+ html: generateHtmlReport(snapData, {
604
+ projectName: projName,
605
+ projectPath: s.basePath,
606
+ gsdVersion,
607
+ milestoneId,
608
+ indexRelPath: "index.html",
609
+ }),
610
+ milestoneId,
611
+ milestoneTitle: msTitle,
612
+ kind: "milestone",
613
+ projectName: projName,
614
+ projectPath: s.basePath,
615
+ gsdVersion,
616
+ totalCost: snapData.totals?.cost ?? 0,
617
+ totalTokens: snapData.totals?.tokens.total ?? 0,
618
+ totalDuration: snapData.totals?.duration ?? 0,
619
+ doneSlices,
620
+ totalSlices,
621
+ doneMilestones: snapData.milestones.filter(
622
+ (m: { status: string }) => m.status === "complete",
623
+ ).length,
624
+ totalMilestones: snapData.milestones.length,
625
+ phase: snapData.phase,
626
+ });
627
+ ctx.ui.notify(
628
+ `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
629
+ "info",
630
+ );
631
+ }
632
+
633
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
634
+
635
+ /**
636
+ * If a unit is in-flight, close it out, then stop auto-mode.
637
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
638
+ */
639
+ async function closeoutAndStop(
640
+ ctx: ExtensionContext,
641
+ pi: ExtensionAPI,
642
+ s: AutoSession,
643
+ deps: LoopDeps,
644
+ reason: string,
645
+ ): Promise<void> {
646
+ if (s.currentUnit) {
647
+ await deps.closeoutUnit(
648
+ ctx,
649
+ s.basePath,
650
+ s.currentUnit.type,
651
+ s.currentUnit.id,
652
+ s.currentUnit.startedAt,
653
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
654
+ );
655
+ }
656
+ await deps.stopAuto(ctx, pi, reason);
657
+ }
658
+
516
659
  // ─── autoLoop ────────────────────────────────────────────────────────────────
517
660
 
518
661
  /**
@@ -530,10 +673,11 @@ export async function autoLoop(
530
673
  deps: LoopDeps,
531
674
  ): Promise<void> {
532
675
  debugLog("autoLoop", { phase: "enter" });
533
- _activeSession = s;
534
676
  let iteration = 0;
535
- let lastDerivedUnit = "";
536
- let sameUnitCount = 0;
677
+ // ── Sliding-window stuck detection ──
678
+ const recentUnits: Array<{ key: string; error?: string }> = [];
679
+ const STUCK_WINDOW_SIZE = 6;
680
+ let stuckRecoveryAttempts = 0;
537
681
 
538
682
  let consecutiveErrors = 0;
539
683
 
@@ -562,6 +706,19 @@ export async function autoLoop(
562
706
 
563
707
  try {
564
708
  // ── Blanket try/catch: one bad iteration must not kill the session
709
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
710
+
711
+ // ── Check sidecar queue before deriveState ──
712
+ let sidecarItem: SidecarItem | undefined;
713
+ if (s.sidecarQueue.length > 0) {
714
+ sidecarItem = s.sidecarQueue.shift()!;
715
+ debugLog("autoLoop", {
716
+ phase: "sidecar-dequeue",
717
+ kind: sidecarItem.kind,
718
+ unitType: sidecarItem.unitType,
719
+ unitId: sidecarItem.unitId,
720
+ });
721
+ }
565
722
 
566
723
  const sessionLockBase = deps.lockBase();
567
724
  if (sessionLockBase) {
@@ -583,6 +740,17 @@ export async function autoLoop(
583
740
  }
584
741
  }
585
742
 
743
+ // Variables shared between the sidecar and normal paths
744
+ let unitType: string;
745
+ let unitId: string;
746
+ let prompt: string;
747
+ let pauseAfterUatDispatch = false;
748
+ let state: GSDState;
749
+ let mid: string | undefined;
750
+ let midTitle: string | undefined;
751
+ let observabilityIssues: unknown[] = [];
752
+
753
+ if (!sidecarItem) {
586
754
  // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
587
755
 
588
756
  // Resource version guard
@@ -633,10 +801,10 @@ export async function autoLoop(
633
801
  }
634
802
 
635
803
  // Derive state
636
- let state = await deps.deriveState(s.basePath);
637
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
638
- let mid = state.activeMilestone?.id;
639
- let midTitle = state.activeMilestone?.title;
804
+ state = await deps.deriveState(s.basePath);
805
+ deps.syncCmuxSidebar(prefs, state);
806
+ mid = state.activeMilestone?.id;
807
+ midTitle = state.activeMilestone?.title;
640
808
  debugLog("autoLoop", {
641
809
  phase: "state-derived",
642
810
  iteration,
@@ -657,68 +825,18 @@ export async function autoLoop(
657
825
  "milestone",
658
826
  );
659
827
  deps.logCmuxEvent(
660
- deps.loadEffectiveGSDPreferences()?.preferences,
828
+ prefs,
661
829
  `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
662
830
  "success",
663
831
  );
664
832
 
665
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
833
+ const vizPrefs = prefs;
666
834
  if (vizPrefs?.auto_visualize) {
667
835
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
668
836
  }
669
837
  if (vizPrefs?.auto_report !== false) {
670
838
  try {
671
- const { loadVisualizerData } = await import("./visualizer-data.js");
672
- const { generateHtmlReport } = await import("./export-html.js");
673
- const { writeReportSnapshot } = await import("./reports.js");
674
- const { basename } = await import("node:path");
675
- const snapData = await loadVisualizerData(s.basePath);
676
- const completedMs = snapData.milestones.find(
677
- (m: { id: string }) => m.id === s.currentMilestoneId,
678
- );
679
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
680
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
681
- const projName = basename(s.basePath);
682
- const doneSlices = snapData.milestones.reduce(
683
- (acc: number, m: { slices: { done: boolean }[] }) =>
684
- acc +
685
- m.slices.filter((sl: { done: boolean }) => sl.done).length,
686
- 0,
687
- );
688
- const totalSlices = snapData.milestones.reduce(
689
- (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
690
- 0,
691
- );
692
- const outPath = writeReportSnapshot({
693
- basePath: s.basePath,
694
- html: generateHtmlReport(snapData, {
695
- projectName: projName,
696
- projectPath: s.basePath,
697
- gsdVersion,
698
- milestoneId: s.currentMilestoneId,
699
- indexRelPath: "index.html",
700
- }),
701
- milestoneId: s.currentMilestoneId!,
702
- milestoneTitle: msTitle,
703
- kind: "milestone",
704
- projectName: projName,
705
- projectPath: s.basePath,
706
- gsdVersion,
707
- totalCost: snapData.totals?.cost ?? 0,
708
- totalTokens: snapData.totals?.tokens.total ?? 0,
709
- totalDuration: snapData.totals?.duration ?? 0,
710
- doneSlices,
711
- totalSlices,
712
- doneMilestones: snapData.milestones.filter(
713
- (m: { status: string }) => m.status === "complete",
714
- ).length,
715
- totalMilestones: snapData.milestones.length,
716
- phase: snapData.phase,
717
- });
718
- ctx.ui.notify(
719
- `Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`,
720
- "info",
721
- );
839
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
722
840
  } catch (err) {
723
841
  ctx.ui.notify(
724
842
  `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -731,11 +849,30 @@ export async function autoLoop(
731
849
  s.unitDispatchCount.clear();
732
850
  s.unitRecoveryCount.clear();
733
851
  s.unitLifetimeDispatches.clear();
734
- lastDerivedUnit = "";
735
- sameUnitCount = 0;
852
+ recentUnits.length = 0;
853
+ stuckRecoveryAttempts = 0;
736
854
 
737
855
  // Worktree lifecycle on milestone transition — merge current, enter next
738
856
  deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
857
+
858
+ // Opt-in: create draft PR on milestone completion
859
+ if (prefs?.git?.auto_pr) {
860
+ try {
861
+ const { createDraftPR } = await import("./git-service.js");
862
+ const prUrl = createDraftPR(
863
+ s.basePath,
864
+ s.currentMilestoneId!,
865
+ `[GSD] ${s.currentMilestoneId} complete`,
866
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
867
+ );
868
+ if (prUrl) {
869
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
870
+ }
871
+ } catch {
872
+ // Non-fatal — PR creation is best-effort
873
+ }
874
+ }
875
+
739
876
  deps.invalidateAllCaches();
740
877
 
741
878
  state = await deps.deriveState(s.basePath);
@@ -745,9 +882,7 @@ export async function autoLoop(
745
882
  if (mid) {
746
883
  if (deps.getIsolationMode() !== "none") {
747
884
  deps.captureIntegrationBranch(s.basePath, mid, {
748
- commitDocs:
749
- deps.loadEffectiveGSDPreferences()?.preferences?.git
750
- ?.commit_docs,
885
+ commitDocs: prefs?.git?.commit_docs,
751
886
  });
752
887
  }
753
888
  deps.resolver.enterMilestone(mid, ctx.ui);
@@ -787,10 +922,28 @@ export async function autoLoop(
787
922
  (m: { status: string }) =>
788
923
  m.status !== "complete" && m.status !== "parked",
789
924
  );
790
- if (incomplete.length === 0) {
925
+ if (incomplete.length === 0 && state.registry.length > 0) {
791
926
  // All milestones complete — merge milestone branch before stopping
792
927
  if (s.currentMilestoneId) {
793
928
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
929
+
930
+ // Opt-in: create draft PR on milestone completion
931
+ if (prefs?.git?.auto_pr) {
932
+ try {
933
+ const { createDraftPR } = await import("./git-service.js");
934
+ const prUrl = createDraftPR(
935
+ s.basePath,
936
+ s.currentMilestoneId,
937
+ `[GSD] ${s.currentMilestoneId} complete`,
938
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
939
+ );
940
+ if (prUrl) {
941
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
942
+ }
943
+ } catch {
944
+ // Non-fatal — PR creation is best-effort
945
+ }
946
+ }
794
947
  }
795
948
  deps.sendDesktopNotification(
796
949
  "GSD",
@@ -799,17 +952,29 @@ export async function autoLoop(
799
952
  "milestone",
800
953
  );
801
954
  deps.logCmuxEvent(
802
- deps.loadEffectiveGSDPreferences()?.preferences,
955
+ prefs,
803
956
  "All milestones complete.",
804
957
  "success",
805
958
  );
806
959
  await deps.stopAuto(ctx, pi, "All milestones complete");
960
+ } else if (incomplete.length === 0 && state.registry.length === 0) {
961
+ // Empty registry — no milestones visible, likely a path resolution bug
962
+ const diag = `basePath=${s.basePath}, phase=${state.phase}`;
963
+ ctx.ui.notify(
964
+ `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`,
965
+ "error",
966
+ );
967
+ await deps.stopAuto(
968
+ ctx,
969
+ pi,
970
+ `No milestones found — check basePath resolution`,
971
+ );
807
972
  } else if (state.phase === "blocked") {
808
973
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
809
974
  await deps.stopAuto(ctx, pi, blockerMsg);
810
975
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
811
976
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
812
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
977
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
813
978
  } else {
814
979
  const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
815
980
  const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@@ -844,20 +1009,10 @@ export async function autoLoop(
844
1009
  }
845
1010
 
846
1011
  if (!mid || !midTitle) {
847
- if (s.currentUnit) {
848
- await deps.closeoutUnit(
849
- ctx,
850
- s.basePath,
851
- s.currentUnit.type,
852
- s.currentUnit.id,
853
- s.currentUnit.startedAt,
854
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
855
- );
856
- }
857
1012
  const noMilestoneReason = !mid
858
1013
  ? "No active milestone after merge reconciliation"
859
1014
  : `Milestone ${mid} has no title after reconciliation`;
860
- await deps.stopAuto(ctx, pi, noMilestoneReason);
1015
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
861
1016
  debugLog("autoLoop", {
862
1017
  phase: "exit",
863
1018
  reason: "no-milestone-after-reconciliation",
@@ -867,19 +1022,27 @@ export async function autoLoop(
867
1022
 
868
1023
  // Terminal: complete
869
1024
  if (state.phase === "complete") {
870
- if (s.currentUnit) {
871
- await deps.closeoutUnit(
872
- ctx,
873
- s.basePath,
874
- s.currentUnit.type,
875
- s.currentUnit.id,
876
- s.currentUnit.startedAt,
877
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
878
- );
879
- }
880
- // Milestone merge on complete
1025
+ // Milestone merge on complete (before closeout so branch state is clean)
881
1026
  if (s.currentMilestoneId) {
882
1027
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
1028
+
1029
+ // Opt-in: create draft PR on milestone completion
1030
+ if (prefs?.git?.auto_pr) {
1031
+ try {
1032
+ const { createDraftPR } = await import("./git-service.js");
1033
+ const prUrl = createDraftPR(
1034
+ s.basePath,
1035
+ s.currentMilestoneId,
1036
+ `[GSD] ${s.currentMilestoneId} complete`,
1037
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
1038
+ );
1039
+ if (prUrl) {
1040
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
1041
+ }
1042
+ } catch {
1043
+ // Non-fatal — PR creation is best-effort
1044
+ }
1045
+ }
883
1046
  }
884
1047
  deps.sendDesktopNotification(
885
1048
  "GSD",
@@ -888,40 +1051,28 @@ export async function autoLoop(
888
1051
  "milestone",
889
1052
  );
890
1053
  deps.logCmuxEvent(
891
- deps.loadEffectiveGSDPreferences()?.preferences,
1054
+ prefs,
892
1055
  `Milestone ${mid} complete.`,
893
1056
  "success",
894
1057
  );
895
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
1058
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
896
1059
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
897
1060
  break;
898
1061
  }
899
1062
 
900
1063
  // Terminal: blocked
901
1064
  if (state.phase === "blocked") {
902
- if (s.currentUnit) {
903
- await deps.closeoutUnit(
904
- ctx,
905
- s.basePath,
906
- s.currentUnit.type,
907
- s.currentUnit.id,
908
- s.currentUnit.startedAt,
909
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
910
- );
911
- }
912
1065
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
913
- await deps.stopAuto(ctx, pi, blockerMsg);
1066
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
914
1067
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
915
1068
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
916
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
1069
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
917
1070
  debugLog("autoLoop", { phase: "exit", reason: "blocked" });
918
1071
  break;
919
1072
  }
920
1073
 
921
1074
  // ── Phase 2: Guards ─────────────────────────────────────────────────
922
1075
 
923
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
924
-
925
1076
  // Budget ceiling guard
926
1077
  const budgetCeiling = prefs?.budget_ceiling;
927
1078
  if (budgetCeiling !== undefined && budgetCeiling > 0) {
@@ -941,84 +1092,49 @@ export async function autoLoop(
941
1092
  budgetPct,
942
1093
  );
943
1094
 
944
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
945
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1095
+ // Data-driven threshold check loop descending, fire first match
1096
+ const threshold = BUDGET_THRESHOLDS.find(
1097
+ (t) => newBudgetAlertLevel >= t.pct,
1098
+ );
1099
+ if (threshold) {
946
1100
  s.lastBudgetAlertLevel =
947
1101
  newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
948
- if (budgetEnforcementAction === "halt") {
949
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
950
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
951
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
952
- break;
953
- }
954
- if (budgetEnforcementAction === "pause") {
955
- ctx.ui.notify(
956
- `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
957
- "warning",
958
- );
1102
+
1103
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
1104
+ // 100% special enforcement logic (halt/pause/warn)
1105
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1106
+ if (budgetEnforcementAction === "halt") {
1107
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
1108
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1109
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1110
+ break;
1111
+ }
1112
+ if (budgetEnforcementAction === "pause") {
1113
+ ctx.ui.notify(
1114
+ `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1115
+ "warning",
1116
+ );
1117
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1118
+ deps.logCmuxEvent(prefs, msg, "warning");
1119
+ await deps.pauseAuto(ctx, pi);
1120
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1121
+ break;
1122
+ }
1123
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
959
1124
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
960
1125
  deps.logCmuxEvent(prefs, msg, "warning");
961
- await deps.pauseAuto(ctx, pi);
962
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
963
- break;
1126
+ } else if (threshold.pct < 100) {
1127
+ // Sub-100% simple notification
1128
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1129
+ ctx.ui.notify(msg, threshold.notifyLevel);
1130
+ deps.sendDesktopNotification(
1131
+ "GSD",
1132
+ msg,
1133
+ threshold.notifyLevel,
1134
+ "budget",
1135
+ );
1136
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
964
1137
  }
965
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
966
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
967
- deps.logCmuxEvent(prefs, msg, "warning");
968
- } else if (newBudgetAlertLevel === 90) {
969
- s.lastBudgetAlertLevel =
970
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
971
- ctx.ui.notify(
972
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
973
- "warning",
974
- );
975
- deps.sendDesktopNotification(
976
- "GSD",
977
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
978
- "warning",
979
- "budget",
980
- );
981
- deps.logCmuxEvent(
982
- prefs,
983
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
984
- "warning",
985
- );
986
- } else if (newBudgetAlertLevel === 80) {
987
- s.lastBudgetAlertLevel =
988
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
989
- ctx.ui.notify(
990
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
991
- "warning",
992
- );
993
- deps.sendDesktopNotification(
994
- "GSD",
995
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
996
- "warning",
997
- "budget",
998
- );
999
- deps.logCmuxEvent(
1000
- prefs,
1001
- `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1002
- "warning",
1003
- );
1004
- } else if (newBudgetAlertLevel === 75) {
1005
- s.lastBudgetAlertLevel =
1006
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1007
- ctx.ui.notify(
1008
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1009
- "info",
1010
- );
1011
- deps.sendDesktopNotification(
1012
- "GSD",
1013
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1014
- "info",
1015
- "budget",
1016
- );
1017
- deps.logCmuxEvent(
1018
- prefs,
1019
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1020
- "progress",
1021
- );
1022
1138
  } else if (budgetAlertLevel === 0) {
1023
1139
  s.lastBudgetAlertLevel = 0;
1024
1140
  }
@@ -1091,20 +1207,11 @@ export async function autoLoop(
1091
1207
  midTitle: midTitle!,
1092
1208
  state,
1093
1209
  prefs,
1210
+ session: s,
1094
1211
  });
1095
1212
 
1096
1213
  if (dispatchResult.action === "stop") {
1097
- if (s.currentUnit) {
1098
- await deps.closeoutUnit(
1099
- ctx,
1100
- s.basePath,
1101
- s.currentUnit.type,
1102
- s.currentUnit.id,
1103
- s.currentUnit.startedAt,
1104
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1105
- );
1106
- }
1107
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
1214
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1108
1215
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1109
1216
  break;
1110
1217
  }
@@ -1115,76 +1222,84 @@ export async function autoLoop(
1115
1222
  continue;
1116
1223
  }
1117
1224
 
1118
- let unitType = dispatchResult.unitType;
1119
- let unitId = dispatchResult.unitId;
1120
- let prompt = dispatchResult.prompt;
1121
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1225
+ unitType = dispatchResult.unitType;
1226
+ unitId = dispatchResult.unitId;
1227
+ prompt = dispatchResult.prompt;
1228
+ pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1122
1229
 
1123
- // ── Same-unit stuck counter with graduated recovery ──
1230
+ // ── Sliding-window stuck detection with graduated recovery ──
1124
1231
  const derivedKey = `${unitType}/${unitId}`;
1125
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
1126
- sameUnitCount++;
1127
- debugLog("autoLoop", {
1128
- phase: "stuck-check",
1129
- unitType,
1130
- unitId,
1131
- sameUnitCount,
1132
- });
1133
1232
 
1134
- if (sameUnitCount === 3) {
1135
- // Level 1: try verifying the artifact — maybe it was written but not detected
1136
- const artifactExists = deps.verifyExpectedArtifact(
1233
+ if (!s.pendingVerificationRetry) {
1234
+ recentUnits.push({ key: derivedKey });
1235
+ if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift();
1236
+
1237
+ const stuckSignal = detectStuck(recentUnits);
1238
+ if (stuckSignal) {
1239
+ debugLog("autoLoop", {
1240
+ phase: "stuck-check",
1137
1241
  unitType,
1138
1242
  unitId,
1139
- s.basePath,
1140
- );
1141
- if (artifactExists) {
1243
+ reason: stuckSignal.reason,
1244
+ recoveryAttempts: stuckRecoveryAttempts,
1245
+ });
1246
+
1247
+ if (stuckRecoveryAttempts === 0) {
1248
+ // Level 1: try verifying the artifact, then cache invalidation + retry
1249
+ stuckRecoveryAttempts++;
1250
+ const artifactExists = deps.verifyExpectedArtifact(
1251
+ unitType,
1252
+ unitId,
1253
+ s.basePath,
1254
+ );
1255
+ if (artifactExists) {
1256
+ debugLog("autoLoop", {
1257
+ phase: "stuck-recovery",
1258
+ level: 1,
1259
+ action: "artifact-found",
1260
+ });
1261
+ ctx.ui.notify(
1262
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1263
+ "info",
1264
+ );
1265
+ deps.invalidateAllCaches();
1266
+ continue;
1267
+ }
1268
+ ctx.ui.notify(
1269
+ `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
1270
+ "warning",
1271
+ );
1272
+ deps.invalidateAllCaches();
1273
+ } else {
1274
+ // Level 2: hard stop — genuinely stuck
1142
1275
  debugLog("autoLoop", {
1143
- phase: "stuck-recovery",
1144
- level: 1,
1145
- action: "artifact-found",
1276
+ phase: "stuck-detected",
1277
+ unitType,
1278
+ unitId,
1279
+ reason: stuckSignal.reason,
1146
1280
  });
1281
+ await deps.stopAuto(
1282
+ ctx,
1283
+ pi,
1284
+ `Stuck: ${stuckSignal.reason}`,
1285
+ );
1147
1286
  ctx.ui.notify(
1148
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1149
- "info",
1287
+ `Stuck on ${unitType} ${unitId} ${stuckSignal.reason}. The expected artifact was not written.`,
1288
+ "error",
1150
1289
  );
1151
- deps.invalidateAllCaches();
1152
- continue;
1290
+ break;
1291
+ }
1292
+ } else {
1293
+ // Progress detected — reset recovery counter
1294
+ if (stuckRecoveryAttempts > 0) {
1295
+ debugLog("autoLoop", {
1296
+ phase: "stuck-counter-reset",
1297
+ from: recentUnits[recentUnits.length - 2]?.key ?? "",
1298
+ to: derivedKey,
1299
+ });
1300
+ stuckRecoveryAttempts = 0;
1153
1301
  }
1154
- ctx.ui.notify(
1155
- `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
1156
- "warning",
1157
- );
1158
- deps.invalidateAllCaches();
1159
- } else if (sameUnitCount === 5) {
1160
- // Level 2: hard stop — genuinely stuck
1161
- debugLog("autoLoop", {
1162
- phase: "stuck-detected",
1163
- unitType,
1164
- unitId,
1165
- sameUnitCount,
1166
- });
1167
- await deps.stopAuto(
1168
- ctx,
1169
- pi,
1170
- `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
1171
- );
1172
- ctx.ui.notify(
1173
- `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
1174
- "error",
1175
- );
1176
- break;
1177
- }
1178
- } else {
1179
- if (derivedKey !== lastDerivedUnit) {
1180
- debugLog("autoLoop", {
1181
- phase: "stuck-counter-reset",
1182
- from: lastDerivedUnit,
1183
- to: derivedKey,
1184
- });
1185
1302
  }
1186
- lastDerivedUnit = derivedKey;
1187
- sameUnitCount = 0;
1188
1303
  }
1189
1304
 
1190
1305
  // Pre-dispatch hooks
@@ -1227,13 +1342,27 @@ export async function autoLoop(
1227
1342
  break;
1228
1343
  }
1229
1344
 
1230
- const observabilityIssues = await deps.collectObservabilityWarnings(
1345
+ observabilityIssues = await deps.collectObservabilityWarnings(
1231
1346
  ctx,
1232
1347
  s.basePath,
1233
1348
  unitType,
1234
1349
  unitId,
1235
1350
  );
1236
1351
 
1352
+ // Derive state for shared use in execution phase
1353
+ // (state, mid, midTitle already set above)
1354
+
1355
+ } else {
1356
+ // ── Sidecar path: use values from the sidecar item directly ──
1357
+ unitType = sidecarItem.unitType;
1358
+ unitId = sidecarItem.unitId;
1359
+ prompt = sidecarItem.prompt;
1360
+ // Derive minimal state for progress widget / execution context
1361
+ state = await deps.deriveState(s.basePath);
1362
+ mid = state.activeMilestone?.id;
1363
+ midTitle = state.activeMilestone?.title;
1364
+ }
1365
+
1237
1366
  // ── Phase 4: Unit execution ─────────────────────────────────────────
1238
1367
 
1239
1368
  debugLog("autoLoop", {
@@ -1251,61 +1380,6 @@ export async function autoLoop(
1251
1380
  );
1252
1381
  const previousTier = s.currentUnitRouting?.tier;
1253
1382
 
1254
- // Closeout previous unit
1255
- if (s.currentUnit) {
1256
- await deps.closeoutUnit(
1257
- ctx,
1258
- s.basePath,
1259
- s.currentUnit.type,
1260
- s.currentUnit.id,
1261
- s.currentUnit.startedAt,
1262
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1263
- );
1264
-
1265
- if (s.currentUnitRouting) {
1266
- const isRetry =
1267
- s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1268
- deps.recordOutcome(
1269
- s.currentUnit.type,
1270
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1271
- !isRetry,
1272
- );
1273
- }
1274
-
1275
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1276
- const incomingKey = `${unitType}/${unitId}`;
1277
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
1278
- const artifactVerified =
1279
- isHookUnit ||
1280
- deps.verifyExpectedArtifact(
1281
- s.currentUnit.type,
1282
- s.currentUnit.id,
1283
- s.basePath,
1284
- );
1285
- if (closeoutKey !== incomingKey && artifactVerified) {
1286
- s.completedUnits.push({
1287
- type: s.currentUnit.type,
1288
- id: s.currentUnit.id,
1289
- startedAt: s.currentUnit.startedAt,
1290
- finishedAt: Date.now(),
1291
- });
1292
- if (s.completedUnits.length > 200) {
1293
- s.completedUnits = s.completedUnits.slice(-200);
1294
- }
1295
- deps.clearUnitRuntimeRecord(
1296
- s.basePath,
1297
- s.currentUnit.type,
1298
- s.currentUnit.id,
1299
- );
1300
- s.unitDispatchCount.delete(
1301
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1302
- );
1303
- s.unitRecoveryCount.delete(
1304
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1305
- );
1306
- }
1307
- }
1308
-
1309
1383
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1310
1384
  deps.captureAvailableSkills();
1311
1385
  deps.writeUnitRuntimeRecord(
@@ -1332,7 +1406,6 @@ export async function autoLoop(
1332
1406
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1333
1407
 
1334
1408
  // Prompt injection
1335
- const MAX_RECOVERY_CHARS = 50_000;
1336
1409
  let finalPrompt = prompt;
1337
1410
 
1338
1411
  if (s.pendingVerificationRetry) {
@@ -1377,7 +1450,7 @@ export async function autoLoop(
1377
1450
  s.lastBaselineCharCount = undefined;
1378
1451
  if (deps.isDbAvailable()) {
1379
1452
  try {
1380
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
1453
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1381
1454
  const [decisionsContent, requirementsContent, projectContent] =
1382
1455
  await Promise.all([
1383
1456
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
@@ -1404,7 +1477,7 @@ export async function autoLoop(
1404
1477
  );
1405
1478
  }
1406
1479
 
1407
- // Select and apply model (with tier escalation on retry)
1480
+ // Select and apply model (with tier escalation on retry — normal units only)
1408
1481
  const modelResult = await deps.selectAndApplyModel(
1409
1482
  ctx,
1410
1483
  pi,
@@ -1414,7 +1487,7 @@ export async function autoLoop(
1414
1487
  prefs,
1415
1488
  s.verbose,
1416
1489
  s.autoModeStartModel,
1417
- { isRetry, previousTier },
1490
+ sidecarItem ? undefined : { isRetry, previousTier },
1418
1491
  );
1419
1492
  s.currentUnitRouting =
1420
1493
  modelResult.routing as AutoSession["currentUnitRouting"];
@@ -1463,7 +1536,6 @@ export async function autoLoop(
1463
1536
  unitType,
1464
1537
  unitId,
1465
1538
  finalPrompt,
1466
- prefs,
1467
1539
  );
1468
1540
  debugLog("autoLoop", {
1469
1541
  phase: "runUnit-end",
@@ -1473,6 +1545,23 @@ export async function autoLoop(
1473
1545
  status: unitResult.status,
1474
1546
  });
1475
1547
 
1548
+ // Tag the most recent window entry with error info for stuck detection
1549
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
1550
+ const lastEntry = recentUnits[recentUnits.length - 1];
1551
+ if (lastEntry) {
1552
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
1553
+ }
1554
+ } else if (unitResult.event?.messages?.length) {
1555
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
1556
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
1557
+ if (/error|fail|exception/i.test(msgStr)) {
1558
+ const lastEntry = recentUnits[recentUnits.length - 1];
1559
+ if (lastEntry) {
1560
+ lastEntry.error = msgStr.slice(0, 200);
1561
+ }
1562
+ }
1563
+ }
1564
+
1476
1565
  if (unitResult.status === "cancelled") {
1477
1566
  ctx.ui.notify(
1478
1567
  `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
@@ -1483,6 +1572,52 @@ export async function autoLoop(
1483
1572
  break;
1484
1573
  }
1485
1574
 
1575
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
1576
+ // Run right after runUnit() returns so telemetry is never lost to a
1577
+ // crash between iterations.
1578
+ await deps.closeoutUnit(
1579
+ ctx,
1580
+ s.basePath,
1581
+ unitType,
1582
+ unitId,
1583
+ s.currentUnit.startedAt,
1584
+ deps.buildSnapshotOpts(unitType, unitId),
1585
+ );
1586
+
1587
+ if (s.currentUnitRouting) {
1588
+ deps.recordOutcome(
1589
+ unitType,
1590
+ s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1591
+ true, // success assumed; dispatch will re-dispatch if artifact missing
1592
+ );
1593
+ }
1594
+
1595
+ const isHookUnit = unitType.startsWith("hook/");
1596
+ const artifactVerified =
1597
+ isHookUnit ||
1598
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
1599
+ if (artifactVerified) {
1600
+ s.completedUnits.push({
1601
+ type: unitType,
1602
+ id: unitId,
1603
+ startedAt: s.currentUnit.startedAt,
1604
+ finishedAt: Date.now(),
1605
+ });
1606
+ if (s.completedUnits.length > 200) {
1607
+ s.completedUnits = s.completedUnits.slice(-200);
1608
+ }
1609
+ // Flush completed-units to disk so the record survives crashes
1610
+ try {
1611
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
1612
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
1613
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
1614
+ } catch { /* non-fatal: disk flush failure */ }
1615
+
1616
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
1617
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
1618
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
1619
+ }
1620
+
1486
1621
  // ── Phase 5: Finalize ───────────────────────────────────────────────
1487
1622
 
1488
1623
  debugLog("autoLoop", { phase: "finalize", iteration });
@@ -1503,7 +1638,13 @@ export async function autoLoop(
1503
1638
  };
1504
1639
 
1505
1640
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
1506
- const preResult = await deps.postUnitPreVerification(postUnitCtx);
1641
+ // Sidecar items use lightweight pre-verification opts
1642
+ const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem
1643
+ ? sidecarItem.kind === "hook"
1644
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1645
+ : { skipSettleDelay: true, skipStateRebuild: true }
1646
+ : undefined;
1647
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
1507
1648
  if (preResult === "dispatched") {
1508
1649
  debugLog("autoLoop", {
1509
1650
  phase: "exit",
@@ -1522,22 +1663,32 @@ export async function autoLoop(
1522
1663
  break;
1523
1664
  }
1524
1665
 
1525
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
1526
- const verificationResult = await deps.runPostUnitVerification(
1527
- { s, ctx, pi },
1528
- deps.pauseAuto,
1529
- );
1666
+ // Verification gate
1667
+ // Hook sidecar items skip verification entirely.
1668
+ // Non-hook sidecar items run verification but skip retries (just continue).
1669
+ const skipVerification = sidecarItem?.kind === "hook";
1670
+ if (!skipVerification) {
1671
+ const verificationResult = await deps.runPostUnitVerification(
1672
+ { s, ctx, pi },
1673
+ deps.pauseAuto,
1674
+ );
1530
1675
 
1531
- if (verificationResult === "pause") {
1532
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1533
- break;
1534
- }
1676
+ if (verificationResult === "pause") {
1677
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1678
+ break;
1679
+ }
1535
1680
 
1536
- if (verificationResult === "retry") {
1537
- // s.pendingVerificationRetry was set by runPostUnitVerification.
1538
- // Continue the loop next iteration will inject the retry context into the prompt.
1539
- debugLog("autoLoop", { phase: "verification-retry", iteration });
1540
- continue;
1681
+ if (verificationResult === "retry") {
1682
+ if (sidecarItem) {
1683
+ // Sidecar verification retries are skipped just continue
1684
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration });
1685
+ } else {
1686
+ // s.pendingVerificationRetry was set by runPostUnitVerification.
1687
+ // Continue the loop — next iteration will inject the retry context into the prompt.
1688
+ debugLog("autoLoop", { phase: "verification-retry", iteration });
1689
+ continue;
1690
+ }
1691
+ }
1541
1692
  }
1542
1693
 
1543
1694
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
@@ -1557,150 +1708,6 @@ export async function autoLoop(
1557
1708
  break;
1558
1709
  }
1559
1710
 
1560
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
1561
- let sidecarBroke = false;
1562
- while (s.sidecarQueue.length > 0 && s.active) {
1563
- const item = s.sidecarQueue.shift()!;
1564
- debugLog("autoLoop", {
1565
- phase: "sidecar-dequeue",
1566
- kind: item.kind,
1567
- unitType: item.unitType,
1568
- unitId: item.unitId,
1569
- });
1570
-
1571
- // Set up as current unit
1572
- const sidecarStartedAt = Date.now();
1573
- s.currentUnit = {
1574
- type: item.unitType,
1575
- id: item.unitId,
1576
- startedAt: sidecarStartedAt,
1577
- };
1578
- deps.writeUnitRuntimeRecord(
1579
- s.basePath,
1580
- item.unitType,
1581
- item.unitId,
1582
- sidecarStartedAt,
1583
- {
1584
- phase: "dispatched",
1585
- wrapupWarningSent: false,
1586
- timeoutAt: null,
1587
- lastProgressAt: sidecarStartedAt,
1588
- progressCount: 0,
1589
- lastProgressKind: "dispatch",
1590
- },
1591
- );
1592
-
1593
- // Model selection (handles hook model override)
1594
- await deps.selectAndApplyModel(
1595
- ctx,
1596
- pi,
1597
- item.unitType,
1598
- item.unitId,
1599
- s.basePath,
1600
- prefs,
1601
- s.verbose,
1602
- s.autoModeStartModel,
1603
- );
1604
-
1605
- // Supervision
1606
- deps.clearUnitTimeout();
1607
- deps.startUnitSupervision({
1608
- s,
1609
- ctx,
1610
- pi,
1611
- unitType: item.unitType,
1612
- unitId: item.unitId,
1613
- prefs,
1614
- buildSnapshotOpts: () =>
1615
- deps.buildSnapshotOpts(item.unitType, item.unitId),
1616
- buildRecoveryContext: () => ({}),
1617
- pauseAuto: deps.pauseAuto,
1618
- });
1619
-
1620
- // Write lock
1621
- const sidecarSessionFile = deps.getSessionFile(ctx);
1622
- deps.writeLock(
1623
- deps.lockBase(),
1624
- item.unitType,
1625
- item.unitId,
1626
- s.completedUnits.length,
1627
- sidecarSessionFile,
1628
- );
1629
-
1630
- // Execute via standard runUnit
1631
- const sidecarResult = await runUnit(
1632
- ctx,
1633
- pi,
1634
- s,
1635
- item.unitType,
1636
- item.unitId,
1637
- item.prompt,
1638
- prefs,
1639
- );
1640
- deps.clearUnitTimeout();
1641
-
1642
- if (sidecarResult.status === "cancelled") {
1643
- ctx.ui.notify(
1644
- `Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`,
1645
- "warning",
1646
- );
1647
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
1648
- sidecarBroke = true;
1649
- break;
1650
- }
1651
-
1652
- // Run pre-verification for the sidecar unit
1653
- const sidecarPreResult =
1654
- await deps.postUnitPreVerification(postUnitCtx);
1655
- if (sidecarPreResult === "dispatched") {
1656
- // Pre-verification caused stop/pause
1657
- debugLog("autoLoop", {
1658
- phase: "exit",
1659
- reason: "sidecar-pre-verification-stop",
1660
- });
1661
- sidecarBroke = true;
1662
- break;
1663
- }
1664
-
1665
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
1666
- // Hook units are lightweight and don't need verification.
1667
- if (item.kind !== "hook") {
1668
- const sidecarVerification = await deps.runPostUnitVerification(
1669
- { s, ctx, pi },
1670
- deps.pauseAuto,
1671
- );
1672
- if (sidecarVerification === "pause") {
1673
- debugLog("autoLoop", {
1674
- phase: "exit",
1675
- reason: "sidecar-verification-pause",
1676
- });
1677
- sidecarBroke = true;
1678
- break;
1679
- }
1680
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
1681
- }
1682
-
1683
- // Post-verification (may enqueue more sidecar items)
1684
- const sidecarPostResult =
1685
- await deps.postUnitPostVerification(postUnitCtx);
1686
- if (sidecarPostResult === "stopped") {
1687
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
1688
- sidecarBroke = true;
1689
- break;
1690
- }
1691
- if (sidecarPostResult === "step-wizard") {
1692
- debugLog("autoLoop", {
1693
- phase: "exit",
1694
- reason: "sidecar-step-wizard",
1695
- });
1696
- sidecarBroke = true;
1697
- break;
1698
- }
1699
- // "continue" — loop checks sidecarQueue again
1700
- }
1701
-
1702
- if (sidecarBroke) break;
1703
-
1704
1711
  consecutiveErrors = 0; // Iteration completed successfully
1705
1712
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
1706
1713
  } catch (loopErr) {
@@ -1740,6 +1747,6 @@ export async function autoLoop(
1740
1747
  }
1741
1748
  }
1742
1749
 
1743
- _activeSession = null;
1750
+ _currentResolve = null;
1744
1751
  debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
1745
1752
  }