gsd-pi 2.38.0-dev.96dc7fb → 2.38.0-dev.98b44dc

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 (217) hide show
  1. package/README.md +15 -11
  2. package/dist/app-paths.js +1 -1
  3. package/dist/extension-registry.js +2 -2
  4. package/dist/remote-questions-config.js +2 -2
  5. package/dist/resource-loader.js +34 -1
  6. package/dist/resources/extensions/browser-tools/index.js +3 -1
  7. package/dist/resources/extensions/browser-tools/tools/verify.js +97 -0
  8. package/dist/resources/extensions/env-utils.js +29 -0
  9. package/dist/resources/extensions/get-secrets-from-user.js +5 -24
  10. package/dist/resources/extensions/github-sync/cli.js +284 -0
  11. package/dist/resources/extensions/github-sync/index.js +73 -0
  12. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  13. package/dist/resources/extensions/github-sync/sync.js +424 -0
  14. package/dist/resources/extensions/github-sync/templates.js +118 -0
  15. package/dist/resources/extensions/github-sync/types.js +7 -0
  16. package/dist/resources/extensions/gsd/auto/session.js +6 -23
  17. package/dist/resources/extensions/gsd/auto-dispatch.js +8 -9
  18. package/dist/resources/extensions/gsd/auto-loop.js +636 -594
  19. package/dist/resources/extensions/gsd/auto-post-unit.js +99 -70
  20. package/dist/resources/extensions/gsd/auto-prompts.js +202 -48
  21. package/dist/resources/extensions/gsd/auto-start.js +7 -1
  22. package/dist/resources/extensions/gsd/auto-worktree-sync.js +2 -1
  23. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  24. package/dist/resources/extensions/gsd/auto.js +143 -96
  25. package/dist/resources/extensions/gsd/commands-extensions.js +3 -2
  26. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  27. package/dist/resources/extensions/gsd/commands.js +4 -2
  28. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  29. package/dist/resources/extensions/gsd/detection.js +1 -2
  30. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  31. package/dist/resources/extensions/gsd/doctor-providers.js +30 -11
  32. package/dist/resources/extensions/gsd/doctor.js +20 -1
  33. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  34. package/dist/resources/extensions/gsd/export.js +1 -1
  35. package/dist/resources/extensions/gsd/files.js +48 -9
  36. package/dist/resources/extensions/gsd/forensics.js +1 -1
  37. package/dist/resources/extensions/gsd/git-service.js +30 -12
  38. package/dist/resources/extensions/gsd/gitignore.js +16 -3
  39. package/dist/resources/extensions/gsd/guided-flow.js +149 -38
  40. package/dist/resources/extensions/gsd/health-widget-core.js +32 -70
  41. package/dist/resources/extensions/gsd/health-widget.js +3 -86
  42. package/dist/resources/extensions/gsd/index.js +24 -20
  43. package/dist/resources/extensions/gsd/migrate/parsers.js +1 -1
  44. package/dist/resources/extensions/gsd/migrate-external.js +18 -1
  45. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  46. package/dist/resources/extensions/gsd/paths.js +3 -0
  47. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  48. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  49. package/dist/resources/extensions/gsd/preferences-validation.js +59 -11
  50. package/dist/resources/extensions/gsd/preferences.js +22 -11
  51. package/dist/resources/extensions/gsd/prompt-loader.js +6 -2
  52. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  53. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  54. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  55. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -3
  56. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  57. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  58. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  59. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  60. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  61. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  62. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  63. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  64. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  65. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  66. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  67. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  68. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  69. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  70. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  71. package/dist/resources/extensions/gsd/prompts/run-uat.md +28 -11
  72. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  73. package/dist/resources/extensions/gsd/repo-identity.js +21 -4
  74. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  75. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  76. package/dist/resources/extensions/gsd/state.js +42 -23
  77. package/dist/resources/extensions/gsd/templates/runtime.md +21 -0
  78. package/dist/resources/extensions/gsd/templates/task-plan.md +3 -0
  79. package/dist/resources/extensions/gsd/visualizer-data.js +1 -1
  80. package/dist/resources/extensions/mcp-client/index.js +14 -1
  81. package/dist/resources/extensions/remote-questions/status.js +4 -1
  82. package/dist/resources/extensions/remote-questions/store.js +4 -1
  83. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  84. package/dist/resources/extensions/shared/frontmatter.js +1 -1
  85. package/dist/resources/extensions/subagent/isolation.js +2 -1
  86. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  87. package/package.json +1 -1
  88. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  89. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  90. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  91. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  93. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/skills.d.ts +1 -0
  95. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  96. package/packages/pi-coding-agent/dist/core/skills.js +6 -1
  97. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  98. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  99. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  100. package/packages/pi-coding-agent/dist/index.js +1 -1
  101. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  102. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  103. package/packages/pi-coding-agent/src/core/skills.ts +9 -1
  104. package/packages/pi-coding-agent/src/index.ts +1 -0
  105. package/src/resources/extensions/browser-tools/index.ts +3 -0
  106. package/src/resources/extensions/browser-tools/tools/verify.ts +117 -0
  107. package/src/resources/extensions/env-utils.ts +31 -0
  108. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  109. package/src/resources/extensions/github-sync/cli.ts +364 -0
  110. package/src/resources/extensions/github-sync/index.ts +93 -0
  111. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  112. package/src/resources/extensions/github-sync/sync.ts +556 -0
  113. package/src/resources/extensions/github-sync/templates.ts +183 -0
  114. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  115. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  116. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  117. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  118. package/src/resources/extensions/github-sync/types.ts +47 -0
  119. package/src/resources/extensions/gsd/auto/session.ts +7 -25
  120. package/src/resources/extensions/gsd/auto-dispatch.ts +7 -9
  121. package/src/resources/extensions/gsd/auto-loop.ts +526 -545
  122. package/src/resources/extensions/gsd/auto-post-unit.ts +80 -44
  123. package/src/resources/extensions/gsd/auto-prompts.ts +247 -50
  124. package/src/resources/extensions/gsd/auto-start.ts +11 -1
  125. package/src/resources/extensions/gsd/auto-worktree-sync.ts +3 -1
  126. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  127. package/src/resources/extensions/gsd/auto.ts +139 -101
  128. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  129. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  130. package/src/resources/extensions/gsd/commands.ts +5 -3
  131. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  132. package/src/resources/extensions/gsd/detection.ts +2 -2
  133. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  134. package/src/resources/extensions/gsd/doctor-providers.ts +30 -9
  135. package/src/resources/extensions/gsd/doctor.ts +22 -1
  136. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  137. package/src/resources/extensions/gsd/export.ts +1 -1
  138. package/src/resources/extensions/gsd/files.ts +51 -11
  139. package/src/resources/extensions/gsd/forensics.ts +1 -1
  140. package/src/resources/extensions/gsd/git-service.ts +44 -10
  141. package/src/resources/extensions/gsd/gitignore.ts +17 -3
  142. package/src/resources/extensions/gsd/guided-flow.ts +177 -44
  143. package/src/resources/extensions/gsd/health-widget-core.ts +28 -80
  144. package/src/resources/extensions/gsd/health-widget.ts +3 -89
  145. package/src/resources/extensions/gsd/index.ts +24 -17
  146. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  147. package/src/resources/extensions/gsd/migrate-external.ts +18 -1
  148. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  149. package/src/resources/extensions/gsd/paths.ts +4 -0
  150. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  151. package/src/resources/extensions/gsd/preferences-types.ts +4 -4
  152. package/src/resources/extensions/gsd/preferences-validation.ts +51 -11
  153. package/src/resources/extensions/gsd/preferences.ts +25 -11
  154. package/src/resources/extensions/gsd/prompt-loader.ts +7 -2
  155. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  156. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  157. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  158. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -3
  159. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  160. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  161. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  162. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  163. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  164. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  165. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  166. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  167. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  168. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  169. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  170. package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  171. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  172. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  173. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  174. package/src/resources/extensions/gsd/prompts/run-uat.md +28 -11
  175. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  176. package/src/resources/extensions/gsd/repo-identity.ts +23 -4
  177. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  178. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  179. package/src/resources/extensions/gsd/state.ts +39 -21
  180. package/src/resources/extensions/gsd/templates/runtime.md +21 -0
  181. package/src/resources/extensions/gsd/templates/task-plan.md +3 -0
  182. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  183. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +122 -68
  184. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +4 -3
  185. package/src/resources/extensions/gsd/tests/derive-state.test.ts +43 -0
  186. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
  187. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +50 -0
  188. package/src/resources/extensions/gsd/tests/health-widget.test.ts +16 -54
  189. package/src/resources/extensions/gsd/tests/parsers.test.ts +131 -14
  190. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +209 -0
  191. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  192. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  193. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  194. package/src/resources/extensions/gsd/tests/run-uat.test.ts +16 -4
  195. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +140 -0
  196. package/src/resources/extensions/gsd/types.ts +18 -1
  197. package/src/resources/extensions/gsd/verification-evidence.ts +16 -0
  198. package/src/resources/extensions/gsd/visualizer-data.ts +1 -1
  199. package/src/resources/extensions/mcp-client/index.ts +17 -1
  200. package/src/resources/extensions/remote-questions/status.ts +5 -1
  201. package/src/resources/extensions/remote-questions/store.ts +5 -1
  202. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  203. package/src/resources/extensions/shared/frontmatter.ts +1 -1
  204. package/src/resources/extensions/subagent/isolation.ts +3 -1
  205. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
  206. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  207. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  208. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  209. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  210. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  211. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  212. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  213. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  214. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  215. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  216. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  217. 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).
@@ -383,6 +420,7 @@ export interface LoopDeps {
383
420
  midTitle: string;
384
421
  state: GSDState;
385
422
  prefs: GSDPreferences | undefined;
423
+ session?: AutoSession;
386
424
  }) => Promise<DispatchAction>;
387
425
  runPreDispatchHooks: (
388
426
  unitType: string,
@@ -500,6 +538,7 @@ export interface LoopDeps {
500
538
  // Post-unit processing
501
539
  postUnitPreVerification: (
502
540
  pctx: PostUnitContext,
541
+ opts?: PreVerificationOpts,
503
542
  ) => Promise<"dispatched" | "continue">;
504
543
  runPostUnitVerification: (
505
544
  vctx: VerificationContext,
@@ -513,6 +552,96 @@ export interface LoopDeps {
513
552
  getSessionFile: (ctx: ExtensionContext) => string;
514
553
  }
515
554
 
555
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
556
+
557
+ /**
558
+ * Generate and write an HTML milestone report snapshot.
559
+ * Extracted from the milestone-transition block in autoLoop.
560
+ */
561
+ async function generateMilestoneReport(
562
+ s: AutoSession,
563
+ ctx: ExtensionContext,
564
+ milestoneId: string,
565
+ ): Promise<void> {
566
+ const { loadVisualizerData } = await importExtensionModule<typeof import("./visualizer-data.js")>(import.meta.url, "./visualizer-data.js");
567
+ const { generateHtmlReport } = await importExtensionModule<typeof import("./export-html.js")>(import.meta.url, "./export-html.js");
568
+ const { writeReportSnapshot } = await importExtensionModule<typeof import("./reports.js")>(import.meta.url, "./reports.js");
569
+ const { basename } = await import("node:path");
570
+
571
+ const snapData = await loadVisualizerData(s.basePath);
572
+ const completedMs = snapData.milestones.find(
573
+ (m: { id: string }) => m.id === milestoneId,
574
+ );
575
+ const msTitle = completedMs?.title ?? milestoneId;
576
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
577
+ const projName = basename(s.basePath);
578
+ const doneSlices = snapData.milestones.reduce(
579
+ (acc: number, m: { slices: { done: boolean }[] }) =>
580
+ acc + m.slices.filter((sl: { done: boolean }) => sl.done).length,
581
+ 0,
582
+ );
583
+ const totalSlices = snapData.milestones.reduce(
584
+ (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
585
+ 0,
586
+ );
587
+ const outPath = writeReportSnapshot({
588
+ basePath: s.basePath,
589
+ html: generateHtmlReport(snapData, {
590
+ projectName: projName,
591
+ projectPath: s.basePath,
592
+ gsdVersion,
593
+ milestoneId,
594
+ indexRelPath: "index.html",
595
+ }),
596
+ milestoneId,
597
+ milestoneTitle: msTitle,
598
+ kind: "milestone",
599
+ projectName: projName,
600
+ projectPath: s.basePath,
601
+ gsdVersion,
602
+ totalCost: snapData.totals?.cost ?? 0,
603
+ totalTokens: snapData.totals?.tokens.total ?? 0,
604
+ totalDuration: snapData.totals?.duration ?? 0,
605
+ doneSlices,
606
+ totalSlices,
607
+ doneMilestones: snapData.milestones.filter(
608
+ (m: { status: string }) => m.status === "complete",
609
+ ).length,
610
+ totalMilestones: snapData.milestones.length,
611
+ phase: snapData.phase,
612
+ });
613
+ ctx.ui.notify(
614
+ `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
615
+ "info",
616
+ );
617
+ }
618
+
619
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
620
+
621
+ /**
622
+ * If a unit is in-flight, close it out, then stop auto-mode.
623
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
624
+ */
625
+ async function closeoutAndStop(
626
+ ctx: ExtensionContext,
627
+ pi: ExtensionAPI,
628
+ s: AutoSession,
629
+ deps: LoopDeps,
630
+ reason: string,
631
+ ): Promise<void> {
632
+ if (s.currentUnit) {
633
+ await deps.closeoutUnit(
634
+ ctx,
635
+ s.basePath,
636
+ s.currentUnit.type,
637
+ s.currentUnit.id,
638
+ s.currentUnit.startedAt,
639
+ deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
640
+ );
641
+ }
642
+ await deps.stopAuto(ctx, pi, reason);
643
+ }
644
+
516
645
  // ─── autoLoop ────────────────────────────────────────────────────────────────
517
646
 
518
647
  /**
@@ -530,10 +659,11 @@ export async function autoLoop(
530
659
  deps: LoopDeps,
531
660
  ): Promise<void> {
532
661
  debugLog("autoLoop", { phase: "enter" });
533
- _activeSession = s;
534
662
  let iteration = 0;
535
- let lastDerivedUnit = "";
536
- let sameUnitCount = 0;
663
+ // ── Sliding-window stuck detection ──
664
+ const recentUnits: Array<{ key: string; error?: string }> = [];
665
+ const STUCK_WINDOW_SIZE = 6;
666
+ let stuckRecoveryAttempts = 0;
537
667
 
538
668
  let consecutiveErrors = 0;
539
669
 
@@ -562,6 +692,19 @@ export async function autoLoop(
562
692
 
563
693
  try {
564
694
  // ── Blanket try/catch: one bad iteration must not kill the session
695
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
696
+
697
+ // ── Check sidecar queue before deriveState ──
698
+ let sidecarItem: SidecarItem | undefined;
699
+ if (s.sidecarQueue.length > 0) {
700
+ sidecarItem = s.sidecarQueue.shift()!;
701
+ debugLog("autoLoop", {
702
+ phase: "sidecar-dequeue",
703
+ kind: sidecarItem.kind,
704
+ unitType: sidecarItem.unitType,
705
+ unitId: sidecarItem.unitId,
706
+ });
707
+ }
565
708
 
566
709
  const sessionLockBase = deps.lockBase();
567
710
  if (sessionLockBase) {
@@ -583,6 +726,17 @@ export async function autoLoop(
583
726
  }
584
727
  }
585
728
 
729
+ // Variables shared between the sidecar and normal paths
730
+ let unitType: string;
731
+ let unitId: string;
732
+ let prompt: string;
733
+ let pauseAfterUatDispatch = false;
734
+ let state: GSDState;
735
+ let mid: string | undefined;
736
+ let midTitle: string | undefined;
737
+ let observabilityIssues: unknown[] = [];
738
+
739
+ if (!sidecarItem) {
586
740
  // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
587
741
 
588
742
  // Resource version guard
@@ -633,10 +787,10 @@ export async function autoLoop(
633
787
  }
634
788
 
635
789
  // 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;
790
+ state = await deps.deriveState(s.basePath);
791
+ deps.syncCmuxSidebar(prefs, state);
792
+ mid = state.activeMilestone?.id;
793
+ midTitle = state.activeMilestone?.title;
640
794
  debugLog("autoLoop", {
641
795
  phase: "state-derived",
642
796
  iteration,
@@ -657,68 +811,18 @@ export async function autoLoop(
657
811
  "milestone",
658
812
  );
659
813
  deps.logCmuxEvent(
660
- deps.loadEffectiveGSDPreferences()?.preferences,
814
+ prefs,
661
815
  `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
662
816
  "success",
663
817
  );
664
818
 
665
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
819
+ const vizPrefs = prefs;
666
820
  if (vizPrefs?.auto_visualize) {
667
821
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
668
822
  }
669
823
  if (vizPrefs?.auto_report !== false) {
670
824
  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
- );
825
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
722
826
  } catch (err) {
723
827
  ctx.ui.notify(
724
828
  `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -731,11 +835,30 @@ export async function autoLoop(
731
835
  s.unitDispatchCount.clear();
732
836
  s.unitRecoveryCount.clear();
733
837
  s.unitLifetimeDispatches.clear();
734
- lastDerivedUnit = "";
735
- sameUnitCount = 0;
838
+ recentUnits.length = 0;
839
+ stuckRecoveryAttempts = 0;
736
840
 
737
841
  // Worktree lifecycle on milestone transition — merge current, enter next
738
842
  deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
843
+
844
+ // Opt-in: create draft PR on milestone completion
845
+ if (prefs?.git?.auto_pr) {
846
+ try {
847
+ const { createDraftPR } = await import("./git-service.js");
848
+ const prUrl = createDraftPR(
849
+ s.basePath,
850
+ s.currentMilestoneId!,
851
+ `[GSD] ${s.currentMilestoneId} complete`,
852
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
853
+ );
854
+ if (prUrl) {
855
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
856
+ }
857
+ } catch {
858
+ // Non-fatal — PR creation is best-effort
859
+ }
860
+ }
861
+
739
862
  deps.invalidateAllCaches();
740
863
 
741
864
  state = await deps.deriveState(s.basePath);
@@ -745,9 +868,7 @@ export async function autoLoop(
745
868
  if (mid) {
746
869
  if (deps.getIsolationMode() !== "none") {
747
870
  deps.captureIntegrationBranch(s.basePath, mid, {
748
- commitDocs:
749
- deps.loadEffectiveGSDPreferences()?.preferences?.git
750
- ?.commit_docs,
871
+ commitDocs: prefs?.git?.commit_docs,
751
872
  });
752
873
  }
753
874
  deps.resolver.enterMilestone(mid, ctx.ui);
@@ -791,6 +912,24 @@ export async function autoLoop(
791
912
  // All milestones complete — merge milestone branch before stopping
792
913
  if (s.currentMilestoneId) {
793
914
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
915
+
916
+ // Opt-in: create draft PR on milestone completion
917
+ if (prefs?.git?.auto_pr) {
918
+ try {
919
+ const { createDraftPR } = await import("./git-service.js");
920
+ const prUrl = createDraftPR(
921
+ s.basePath,
922
+ s.currentMilestoneId,
923
+ `[GSD] ${s.currentMilestoneId} complete`,
924
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
925
+ );
926
+ if (prUrl) {
927
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
928
+ }
929
+ } catch {
930
+ // Non-fatal — PR creation is best-effort
931
+ }
932
+ }
794
933
  }
795
934
  deps.sendDesktopNotification(
796
935
  "GSD",
@@ -799,7 +938,7 @@ export async function autoLoop(
799
938
  "milestone",
800
939
  );
801
940
  deps.logCmuxEvent(
802
- deps.loadEffectiveGSDPreferences()?.preferences,
941
+ prefs,
803
942
  "All milestones complete.",
804
943
  "success",
805
944
  );
@@ -821,7 +960,7 @@ export async function autoLoop(
821
960
  await deps.stopAuto(ctx, pi, blockerMsg);
822
961
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
823
962
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
824
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
963
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
825
964
  } else {
826
965
  const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
827
966
  const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@@ -856,20 +995,10 @@ export async function autoLoop(
856
995
  }
857
996
 
858
997
  if (!mid || !midTitle) {
859
- if (s.currentUnit) {
860
- await deps.closeoutUnit(
861
- ctx,
862
- s.basePath,
863
- s.currentUnit.type,
864
- s.currentUnit.id,
865
- s.currentUnit.startedAt,
866
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
867
- );
868
- }
869
998
  const noMilestoneReason = !mid
870
999
  ? "No active milestone after merge reconciliation"
871
1000
  : `Milestone ${mid} has no title after reconciliation`;
872
- await deps.stopAuto(ctx, pi, noMilestoneReason);
1001
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
873
1002
  debugLog("autoLoop", {
874
1003
  phase: "exit",
875
1004
  reason: "no-milestone-after-reconciliation",
@@ -879,19 +1008,27 @@ export async function autoLoop(
879
1008
 
880
1009
  // Terminal: complete
881
1010
  if (state.phase === "complete") {
882
- if (s.currentUnit) {
883
- await deps.closeoutUnit(
884
- ctx,
885
- s.basePath,
886
- s.currentUnit.type,
887
- s.currentUnit.id,
888
- s.currentUnit.startedAt,
889
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
890
- );
891
- }
892
- // Milestone merge on complete
1011
+ // Milestone merge on complete (before closeout so branch state is clean)
893
1012
  if (s.currentMilestoneId) {
894
1013
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
1014
+
1015
+ // Opt-in: create draft PR on milestone completion
1016
+ if (prefs?.git?.auto_pr) {
1017
+ try {
1018
+ const { createDraftPR } = await import("./git-service.js");
1019
+ const prUrl = createDraftPR(
1020
+ s.basePath,
1021
+ s.currentMilestoneId,
1022
+ `[GSD] ${s.currentMilestoneId} complete`,
1023
+ `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
1024
+ );
1025
+ if (prUrl) {
1026
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
1027
+ }
1028
+ } catch {
1029
+ // Non-fatal — PR creation is best-effort
1030
+ }
1031
+ }
895
1032
  }
896
1033
  deps.sendDesktopNotification(
897
1034
  "GSD",
@@ -900,40 +1037,28 @@ export async function autoLoop(
900
1037
  "milestone",
901
1038
  );
902
1039
  deps.logCmuxEvent(
903
- deps.loadEffectiveGSDPreferences()?.preferences,
1040
+ prefs,
904
1041
  `Milestone ${mid} complete.`,
905
1042
  "success",
906
1043
  );
907
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
1044
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
908
1045
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
909
1046
  break;
910
1047
  }
911
1048
 
912
1049
  // Terminal: blocked
913
1050
  if (state.phase === "blocked") {
914
- if (s.currentUnit) {
915
- await deps.closeoutUnit(
916
- ctx,
917
- s.basePath,
918
- s.currentUnit.type,
919
- s.currentUnit.id,
920
- s.currentUnit.startedAt,
921
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
922
- );
923
- }
924
1051
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
925
- await deps.stopAuto(ctx, pi, blockerMsg);
1052
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
926
1053
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
927
1054
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
928
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
1055
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
929
1056
  debugLog("autoLoop", { phase: "exit", reason: "blocked" });
930
1057
  break;
931
1058
  }
932
1059
 
933
1060
  // ── Phase 2: Guards ─────────────────────────────────────────────────
934
1061
 
935
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
936
-
937
1062
  // Budget ceiling guard
938
1063
  const budgetCeiling = prefs?.budget_ceiling;
939
1064
  if (budgetCeiling !== undefined && budgetCeiling > 0) {
@@ -953,84 +1078,49 @@ export async function autoLoop(
953
1078
  budgetPct,
954
1079
  );
955
1080
 
956
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
957
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1081
+ // Data-driven threshold check loop descending, fire first match
1082
+ const threshold = BUDGET_THRESHOLDS.find(
1083
+ (t) => newBudgetAlertLevel >= t.pct,
1084
+ );
1085
+ if (threshold) {
958
1086
  s.lastBudgetAlertLevel =
959
1087
  newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
960
- if (budgetEnforcementAction === "halt") {
961
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
962
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
963
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
964
- break;
965
- }
966
- if (budgetEnforcementAction === "pause") {
967
- ctx.ui.notify(
968
- `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
969
- "warning",
970
- );
1088
+
1089
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
1090
+ // 100% special enforcement logic (halt/pause/warn)
1091
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1092
+ if (budgetEnforcementAction === "halt") {
1093
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
1094
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1095
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1096
+ break;
1097
+ }
1098
+ if (budgetEnforcementAction === "pause") {
1099
+ ctx.ui.notify(
1100
+ `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1101
+ "warning",
1102
+ );
1103
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1104
+ deps.logCmuxEvent(prefs, msg, "warning");
1105
+ await deps.pauseAuto(ctx, pi);
1106
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1107
+ break;
1108
+ }
1109
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
971
1110
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
972
1111
  deps.logCmuxEvent(prefs, msg, "warning");
973
- await deps.pauseAuto(ctx, pi);
974
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
975
- break;
1112
+ } else if (threshold.pct < 100) {
1113
+ // Sub-100% simple notification
1114
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1115
+ ctx.ui.notify(msg, threshold.notifyLevel);
1116
+ deps.sendDesktopNotification(
1117
+ "GSD",
1118
+ msg,
1119
+ threshold.notifyLevel,
1120
+ "budget",
1121
+ );
1122
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
976
1123
  }
977
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
978
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
979
- deps.logCmuxEvent(prefs, msg, "warning");
980
- } else if (newBudgetAlertLevel === 90) {
981
- s.lastBudgetAlertLevel =
982
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
983
- ctx.ui.notify(
984
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
985
- "warning",
986
- );
987
- deps.sendDesktopNotification(
988
- "GSD",
989
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
990
- "warning",
991
- "budget",
992
- );
993
- deps.logCmuxEvent(
994
- prefs,
995
- `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
996
- "warning",
997
- );
998
- } else if (newBudgetAlertLevel === 80) {
999
- s.lastBudgetAlertLevel =
1000
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1001
- ctx.ui.notify(
1002
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1003
- "warning",
1004
- );
1005
- deps.sendDesktopNotification(
1006
- "GSD",
1007
- `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1008
- "warning",
1009
- "budget",
1010
- );
1011
- deps.logCmuxEvent(
1012
- prefs,
1013
- `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1014
- "warning",
1015
- );
1016
- } else if (newBudgetAlertLevel === 75) {
1017
- s.lastBudgetAlertLevel =
1018
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1019
- ctx.ui.notify(
1020
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1021
- "info",
1022
- );
1023
- deps.sendDesktopNotification(
1024
- "GSD",
1025
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1026
- "info",
1027
- "budget",
1028
- );
1029
- deps.logCmuxEvent(
1030
- prefs,
1031
- `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`,
1032
- "progress",
1033
- );
1034
1124
  } else if (budgetAlertLevel === 0) {
1035
1125
  s.lastBudgetAlertLevel = 0;
1036
1126
  }
@@ -1103,20 +1193,11 @@ export async function autoLoop(
1103
1193
  midTitle: midTitle!,
1104
1194
  state,
1105
1195
  prefs,
1196
+ session: s,
1106
1197
  });
1107
1198
 
1108
1199
  if (dispatchResult.action === "stop") {
1109
- if (s.currentUnit) {
1110
- await deps.closeoutUnit(
1111
- ctx,
1112
- s.basePath,
1113
- s.currentUnit.type,
1114
- s.currentUnit.id,
1115
- s.currentUnit.startedAt,
1116
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1117
- );
1118
- }
1119
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
1200
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1120
1201
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1121
1202
  break;
1122
1203
  }
@@ -1127,76 +1208,84 @@ export async function autoLoop(
1127
1208
  continue;
1128
1209
  }
1129
1210
 
1130
- let unitType = dispatchResult.unitType;
1131
- let unitId = dispatchResult.unitId;
1132
- let prompt = dispatchResult.prompt;
1133
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1211
+ unitType = dispatchResult.unitType;
1212
+ unitId = dispatchResult.unitId;
1213
+ prompt = dispatchResult.prompt;
1214
+ pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1134
1215
 
1135
- // ── Same-unit stuck counter with graduated recovery ──
1216
+ // ── Sliding-window stuck detection with graduated recovery ──
1136
1217
  const derivedKey = `${unitType}/${unitId}`;
1137
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
1138
- sameUnitCount++;
1139
- debugLog("autoLoop", {
1140
- phase: "stuck-check",
1141
- unitType,
1142
- unitId,
1143
- sameUnitCount,
1144
- });
1145
1218
 
1146
- if (sameUnitCount === 3) {
1147
- // Level 1: try verifying the artifact — maybe it was written but not detected
1148
- const artifactExists = deps.verifyExpectedArtifact(
1219
+ if (!s.pendingVerificationRetry) {
1220
+ recentUnits.push({ key: derivedKey });
1221
+ if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift();
1222
+
1223
+ const stuckSignal = detectStuck(recentUnits);
1224
+ if (stuckSignal) {
1225
+ debugLog("autoLoop", {
1226
+ phase: "stuck-check",
1149
1227
  unitType,
1150
1228
  unitId,
1151
- s.basePath,
1152
- );
1153
- if (artifactExists) {
1229
+ reason: stuckSignal.reason,
1230
+ recoveryAttempts: stuckRecoveryAttempts,
1231
+ });
1232
+
1233
+ if (stuckRecoveryAttempts === 0) {
1234
+ // Level 1: try verifying the artifact, then cache invalidation + retry
1235
+ stuckRecoveryAttempts++;
1236
+ const artifactExists = deps.verifyExpectedArtifact(
1237
+ unitType,
1238
+ unitId,
1239
+ s.basePath,
1240
+ );
1241
+ if (artifactExists) {
1242
+ debugLog("autoLoop", {
1243
+ phase: "stuck-recovery",
1244
+ level: 1,
1245
+ action: "artifact-found",
1246
+ });
1247
+ ctx.ui.notify(
1248
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1249
+ "info",
1250
+ );
1251
+ deps.invalidateAllCaches();
1252
+ continue;
1253
+ }
1254
+ ctx.ui.notify(
1255
+ `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
1256
+ "warning",
1257
+ );
1258
+ deps.invalidateAllCaches();
1259
+ } else {
1260
+ // Level 2: hard stop — genuinely stuck
1154
1261
  debugLog("autoLoop", {
1155
- phase: "stuck-recovery",
1156
- level: 1,
1157
- action: "artifact-found",
1262
+ phase: "stuck-detected",
1263
+ unitType,
1264
+ unitId,
1265
+ reason: stuckSignal.reason,
1158
1266
  });
1267
+ await deps.stopAuto(
1268
+ ctx,
1269
+ pi,
1270
+ `Stuck: ${stuckSignal.reason}`,
1271
+ );
1159
1272
  ctx.ui.notify(
1160
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1161
- "info",
1273
+ `Stuck on ${unitType} ${unitId} ${stuckSignal.reason}. The expected artifact was not written.`,
1274
+ "error",
1162
1275
  );
1163
- deps.invalidateAllCaches();
1164
- continue;
1276
+ break;
1277
+ }
1278
+ } else {
1279
+ // Progress detected — reset recovery counter
1280
+ if (stuckRecoveryAttempts > 0) {
1281
+ debugLog("autoLoop", {
1282
+ phase: "stuck-counter-reset",
1283
+ from: recentUnits[recentUnits.length - 2]?.key ?? "",
1284
+ to: derivedKey,
1285
+ });
1286
+ stuckRecoveryAttempts = 0;
1165
1287
  }
1166
- ctx.ui.notify(
1167
- `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
1168
- "warning",
1169
- );
1170
- deps.invalidateAllCaches();
1171
- } else if (sameUnitCount === 5) {
1172
- // Level 2: hard stop — genuinely stuck
1173
- debugLog("autoLoop", {
1174
- phase: "stuck-detected",
1175
- unitType,
1176
- unitId,
1177
- sameUnitCount,
1178
- });
1179
- await deps.stopAuto(
1180
- ctx,
1181
- pi,
1182
- `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
1183
- );
1184
- ctx.ui.notify(
1185
- `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
1186
- "error",
1187
- );
1188
- break;
1189
- }
1190
- } else {
1191
- if (derivedKey !== lastDerivedUnit) {
1192
- debugLog("autoLoop", {
1193
- phase: "stuck-counter-reset",
1194
- from: lastDerivedUnit,
1195
- to: derivedKey,
1196
- });
1197
1288
  }
1198
- lastDerivedUnit = derivedKey;
1199
- sameUnitCount = 0;
1200
1289
  }
1201
1290
 
1202
1291
  // Pre-dispatch hooks
@@ -1239,13 +1328,27 @@ export async function autoLoop(
1239
1328
  break;
1240
1329
  }
1241
1330
 
1242
- const observabilityIssues = await deps.collectObservabilityWarnings(
1331
+ observabilityIssues = await deps.collectObservabilityWarnings(
1243
1332
  ctx,
1244
1333
  s.basePath,
1245
1334
  unitType,
1246
1335
  unitId,
1247
1336
  );
1248
1337
 
1338
+ // Derive state for shared use in execution phase
1339
+ // (state, mid, midTitle already set above)
1340
+
1341
+ } else {
1342
+ // ── Sidecar path: use values from the sidecar item directly ──
1343
+ unitType = sidecarItem.unitType;
1344
+ unitId = sidecarItem.unitId;
1345
+ prompt = sidecarItem.prompt;
1346
+ // Derive minimal state for progress widget / execution context
1347
+ state = await deps.deriveState(s.basePath);
1348
+ mid = state.activeMilestone?.id;
1349
+ midTitle = state.activeMilestone?.title;
1350
+ }
1351
+
1249
1352
  // ── Phase 4: Unit execution ─────────────────────────────────────────
1250
1353
 
1251
1354
  debugLog("autoLoop", {
@@ -1263,61 +1366,6 @@ export async function autoLoop(
1263
1366
  );
1264
1367
  const previousTier = s.currentUnitRouting?.tier;
1265
1368
 
1266
- // Closeout previous unit
1267
- if (s.currentUnit) {
1268
- await deps.closeoutUnit(
1269
- ctx,
1270
- s.basePath,
1271
- s.currentUnit.type,
1272
- s.currentUnit.id,
1273
- s.currentUnit.startedAt,
1274
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1275
- );
1276
-
1277
- if (s.currentUnitRouting) {
1278
- const isRetry =
1279
- s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1280
- deps.recordOutcome(
1281
- s.currentUnit.type,
1282
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1283
- !isRetry,
1284
- );
1285
- }
1286
-
1287
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1288
- const incomingKey = `${unitType}/${unitId}`;
1289
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
1290
- const artifactVerified =
1291
- isHookUnit ||
1292
- deps.verifyExpectedArtifact(
1293
- s.currentUnit.type,
1294
- s.currentUnit.id,
1295
- s.basePath,
1296
- );
1297
- if (closeoutKey !== incomingKey && artifactVerified) {
1298
- s.completedUnits.push({
1299
- type: s.currentUnit.type,
1300
- id: s.currentUnit.id,
1301
- startedAt: s.currentUnit.startedAt,
1302
- finishedAt: Date.now(),
1303
- });
1304
- if (s.completedUnits.length > 200) {
1305
- s.completedUnits = s.completedUnits.slice(-200);
1306
- }
1307
- deps.clearUnitRuntimeRecord(
1308
- s.basePath,
1309
- s.currentUnit.type,
1310
- s.currentUnit.id,
1311
- );
1312
- s.unitDispatchCount.delete(
1313
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1314
- );
1315
- s.unitRecoveryCount.delete(
1316
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1317
- );
1318
- }
1319
- }
1320
-
1321
1369
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1322
1370
  deps.captureAvailableSkills();
1323
1371
  deps.writeUnitRuntimeRecord(
@@ -1344,7 +1392,6 @@ export async function autoLoop(
1344
1392
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1345
1393
 
1346
1394
  // Prompt injection
1347
- const MAX_RECOVERY_CHARS = 50_000;
1348
1395
  let finalPrompt = prompt;
1349
1396
 
1350
1397
  if (s.pendingVerificationRetry) {
@@ -1389,7 +1436,7 @@ export async function autoLoop(
1389
1436
  s.lastBaselineCharCount = undefined;
1390
1437
  if (deps.isDbAvailable()) {
1391
1438
  try {
1392
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
1439
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1393
1440
  const [decisionsContent, requirementsContent, projectContent] =
1394
1441
  await Promise.all([
1395
1442
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
@@ -1416,7 +1463,7 @@ export async function autoLoop(
1416
1463
  );
1417
1464
  }
1418
1465
 
1419
- // Select and apply model (with tier escalation on retry)
1466
+ // Select and apply model (with tier escalation on retry — normal units only)
1420
1467
  const modelResult = await deps.selectAndApplyModel(
1421
1468
  ctx,
1422
1469
  pi,
@@ -1426,7 +1473,7 @@ export async function autoLoop(
1426
1473
  prefs,
1427
1474
  s.verbose,
1428
1475
  s.autoModeStartModel,
1429
- { isRetry, previousTier },
1476
+ sidecarItem ? undefined : { isRetry, previousTier },
1430
1477
  );
1431
1478
  s.currentUnitRouting =
1432
1479
  modelResult.routing as AutoSession["currentUnitRouting"];
@@ -1475,7 +1522,6 @@ export async function autoLoop(
1475
1522
  unitType,
1476
1523
  unitId,
1477
1524
  finalPrompt,
1478
- prefs,
1479
1525
  );
1480
1526
  debugLog("autoLoop", {
1481
1527
  phase: "runUnit-end",
@@ -1485,6 +1531,23 @@ export async function autoLoop(
1485
1531
  status: unitResult.status,
1486
1532
  });
1487
1533
 
1534
+ // Tag the most recent window entry with error info for stuck detection
1535
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
1536
+ const lastEntry = recentUnits[recentUnits.length - 1];
1537
+ if (lastEntry) {
1538
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
1539
+ }
1540
+ } else if (unitResult.event?.messages?.length) {
1541
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
1542
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
1543
+ if (/error|fail|exception/i.test(msgStr)) {
1544
+ const lastEntry = recentUnits[recentUnits.length - 1];
1545
+ if (lastEntry) {
1546
+ lastEntry.error = msgStr.slice(0, 200);
1547
+ }
1548
+ }
1549
+ }
1550
+
1488
1551
  if (unitResult.status === "cancelled") {
1489
1552
  ctx.ui.notify(
1490
1553
  `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
@@ -1495,6 +1558,52 @@ export async function autoLoop(
1495
1558
  break;
1496
1559
  }
1497
1560
 
1561
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
1562
+ // Run right after runUnit() returns so telemetry is never lost to a
1563
+ // crash between iterations.
1564
+ await deps.closeoutUnit(
1565
+ ctx,
1566
+ s.basePath,
1567
+ unitType,
1568
+ unitId,
1569
+ s.currentUnit.startedAt,
1570
+ deps.buildSnapshotOpts(unitType, unitId),
1571
+ );
1572
+
1573
+ if (s.currentUnitRouting) {
1574
+ deps.recordOutcome(
1575
+ unitType,
1576
+ s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1577
+ true, // success assumed; dispatch will re-dispatch if artifact missing
1578
+ );
1579
+ }
1580
+
1581
+ const isHookUnit = unitType.startsWith("hook/");
1582
+ const artifactVerified =
1583
+ isHookUnit ||
1584
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
1585
+ if (artifactVerified) {
1586
+ s.completedUnits.push({
1587
+ type: unitType,
1588
+ id: unitId,
1589
+ startedAt: s.currentUnit.startedAt,
1590
+ finishedAt: Date.now(),
1591
+ });
1592
+ if (s.completedUnits.length > 200) {
1593
+ s.completedUnits = s.completedUnits.slice(-200);
1594
+ }
1595
+ // Flush completed-units to disk so the record survives crashes
1596
+ try {
1597
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
1598
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
1599
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
1600
+ } catch { /* non-fatal: disk flush failure */ }
1601
+
1602
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
1603
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
1604
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
1605
+ }
1606
+
1498
1607
  // ── Phase 5: Finalize ───────────────────────────────────────────────
1499
1608
 
1500
1609
  debugLog("autoLoop", { phase: "finalize", iteration });
@@ -1515,7 +1624,13 @@ export async function autoLoop(
1515
1624
  };
1516
1625
 
1517
1626
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
1518
- const preResult = await deps.postUnitPreVerification(postUnitCtx);
1627
+ // Sidecar items use lightweight pre-verification opts
1628
+ const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem
1629
+ ? sidecarItem.kind === "hook"
1630
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
1631
+ : { skipSettleDelay: true, skipStateRebuild: true }
1632
+ : undefined;
1633
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
1519
1634
  if (preResult === "dispatched") {
1520
1635
  debugLog("autoLoop", {
1521
1636
  phase: "exit",
@@ -1534,22 +1649,32 @@ export async function autoLoop(
1534
1649
  break;
1535
1650
  }
1536
1651
 
1537
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
1538
- const verificationResult = await deps.runPostUnitVerification(
1539
- { s, ctx, pi },
1540
- deps.pauseAuto,
1541
- );
1652
+ // Verification gate
1653
+ // Hook sidecar items skip verification entirely.
1654
+ // Non-hook sidecar items run verification but skip retries (just continue).
1655
+ const skipVerification = sidecarItem?.kind === "hook";
1656
+ if (!skipVerification) {
1657
+ const verificationResult = await deps.runPostUnitVerification(
1658
+ { s, ctx, pi },
1659
+ deps.pauseAuto,
1660
+ );
1542
1661
 
1543
- if (verificationResult === "pause") {
1544
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1545
- break;
1546
- }
1662
+ if (verificationResult === "pause") {
1663
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
1664
+ break;
1665
+ }
1547
1666
 
1548
- if (verificationResult === "retry") {
1549
- // s.pendingVerificationRetry was set by runPostUnitVerification.
1550
- // Continue the loop next iteration will inject the retry context into the prompt.
1551
- debugLog("autoLoop", { phase: "verification-retry", iteration });
1552
- continue;
1667
+ if (verificationResult === "retry") {
1668
+ if (sidecarItem) {
1669
+ // Sidecar verification retries are skipped just continue
1670
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration });
1671
+ } else {
1672
+ // s.pendingVerificationRetry was set by runPostUnitVerification.
1673
+ // Continue the loop — next iteration will inject the retry context into the prompt.
1674
+ debugLog("autoLoop", { phase: "verification-retry", iteration });
1675
+ continue;
1676
+ }
1677
+ }
1553
1678
  }
1554
1679
 
1555
1680
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
@@ -1569,150 +1694,6 @@ export async function autoLoop(
1569
1694
  break;
1570
1695
  }
1571
1696
 
1572
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
1573
- let sidecarBroke = false;
1574
- while (s.sidecarQueue.length > 0 && s.active) {
1575
- const item = s.sidecarQueue.shift()!;
1576
- debugLog("autoLoop", {
1577
- phase: "sidecar-dequeue",
1578
- kind: item.kind,
1579
- unitType: item.unitType,
1580
- unitId: item.unitId,
1581
- });
1582
-
1583
- // Set up as current unit
1584
- const sidecarStartedAt = Date.now();
1585
- s.currentUnit = {
1586
- type: item.unitType,
1587
- id: item.unitId,
1588
- startedAt: sidecarStartedAt,
1589
- };
1590
- deps.writeUnitRuntimeRecord(
1591
- s.basePath,
1592
- item.unitType,
1593
- item.unitId,
1594
- sidecarStartedAt,
1595
- {
1596
- phase: "dispatched",
1597
- wrapupWarningSent: false,
1598
- timeoutAt: null,
1599
- lastProgressAt: sidecarStartedAt,
1600
- progressCount: 0,
1601
- lastProgressKind: "dispatch",
1602
- },
1603
- );
1604
-
1605
- // Model selection (handles hook model override)
1606
- await deps.selectAndApplyModel(
1607
- ctx,
1608
- pi,
1609
- item.unitType,
1610
- item.unitId,
1611
- s.basePath,
1612
- prefs,
1613
- s.verbose,
1614
- s.autoModeStartModel,
1615
- );
1616
-
1617
- // Supervision
1618
- deps.clearUnitTimeout();
1619
- deps.startUnitSupervision({
1620
- s,
1621
- ctx,
1622
- pi,
1623
- unitType: item.unitType,
1624
- unitId: item.unitId,
1625
- prefs,
1626
- buildSnapshotOpts: () =>
1627
- deps.buildSnapshotOpts(item.unitType, item.unitId),
1628
- buildRecoveryContext: () => ({}),
1629
- pauseAuto: deps.pauseAuto,
1630
- });
1631
-
1632
- // Write lock
1633
- const sidecarSessionFile = deps.getSessionFile(ctx);
1634
- deps.writeLock(
1635
- deps.lockBase(),
1636
- item.unitType,
1637
- item.unitId,
1638
- s.completedUnits.length,
1639
- sidecarSessionFile,
1640
- );
1641
-
1642
- // Execute via standard runUnit
1643
- const sidecarResult = await runUnit(
1644
- ctx,
1645
- pi,
1646
- s,
1647
- item.unitType,
1648
- item.unitId,
1649
- item.prompt,
1650
- prefs,
1651
- );
1652
- deps.clearUnitTimeout();
1653
-
1654
- if (sidecarResult.status === "cancelled") {
1655
- ctx.ui.notify(
1656
- `Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`,
1657
- "warning",
1658
- );
1659
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
1660
- sidecarBroke = true;
1661
- break;
1662
- }
1663
-
1664
- // Run pre-verification for the sidecar unit
1665
- const sidecarPreResult =
1666
- await deps.postUnitPreVerification(postUnitCtx);
1667
- if (sidecarPreResult === "dispatched") {
1668
- // Pre-verification caused stop/pause
1669
- debugLog("autoLoop", {
1670
- phase: "exit",
1671
- reason: "sidecar-pre-verification-stop",
1672
- });
1673
- sidecarBroke = true;
1674
- break;
1675
- }
1676
-
1677
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
1678
- // Hook units are lightweight and don't need verification.
1679
- if (item.kind !== "hook") {
1680
- const sidecarVerification = await deps.runPostUnitVerification(
1681
- { s, ctx, pi },
1682
- deps.pauseAuto,
1683
- );
1684
- if (sidecarVerification === "pause") {
1685
- debugLog("autoLoop", {
1686
- phase: "exit",
1687
- reason: "sidecar-verification-pause",
1688
- });
1689
- sidecarBroke = true;
1690
- break;
1691
- }
1692
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
1693
- }
1694
-
1695
- // Post-verification (may enqueue more sidecar items)
1696
- const sidecarPostResult =
1697
- await deps.postUnitPostVerification(postUnitCtx);
1698
- if (sidecarPostResult === "stopped") {
1699
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
1700
- sidecarBroke = true;
1701
- break;
1702
- }
1703
- if (sidecarPostResult === "step-wizard") {
1704
- debugLog("autoLoop", {
1705
- phase: "exit",
1706
- reason: "sidecar-step-wizard",
1707
- });
1708
- sidecarBroke = true;
1709
- break;
1710
- }
1711
- // "continue" — loop checks sidecarQueue again
1712
- }
1713
-
1714
- if (sidecarBroke) break;
1715
-
1716
1697
  consecutiveErrors = 0; // Iteration completed successfully
1717
1698
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
1718
1699
  } catch (loopErr) {
@@ -1752,6 +1733,6 @@ export async function autoLoop(
1752
1733
  }
1753
1734
  }
1754
1735
 
1755
- _activeSession = null;
1736
+ _currentResolve = null;
1756
1737
  debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
1757
1738
  }