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,12 +5,16 @@
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
+ import { importExtensionModule } from "@gsd/pi-coding-agent";
12
13
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
13
14
  import { debugLog } from "./debug-logger.js";
15
+ import { gsdRoot } from "./paths.js";
16
+ import { atomicWriteSync } from "./atomic-write.js";
17
+ import { join } from "node:path";
14
18
  /**
15
19
  * Maximum total loop iterations before forced stop. Prevents runaway loops
16
20
  * when units alternate IDs (bypassing the same-unit stuck detector).
@@ -18,71 +22,114 @@ import { debugLog } from "./debug-logger.js";
18
22
  * generous headroom including retries and sidecar work.
19
23
  */
20
24
  const MAX_LOOP_ITERATIONS = 500;
21
- // ─── Session-scoped promise state ───────────────────────────────────────────
25
+ /** Maximum characters of failure/crash context included in recovery prompts. */
26
+ const MAX_RECOVERY_CHARS = 50_000;
27
+ /** Data-driven budget threshold notifications (descending). The 100% entry
28
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
29
+ * a simple notification. */
30
+ const BUDGET_THRESHOLDS = [
31
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
32
+ { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
33
+ { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
34
+ { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
35
+ ];
36
+ // ─── Per-unit one-shot promise state ────────────────────────────────────────
22
37
  //
23
- // pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level)
24
- // so concurrent sessions cannot corrupt each other's promises.
25
- /**
26
- * The singleton session reference used by resolveAgentEnd. Set by autoLoop
27
- * on entry so that the agent_end handler in index.ts can resolve the correct
28
- * session's promise without needing a direct reference to `s`.
29
- */
30
- let _activeSession = null;
38
+ // A single module-level resolve function scoped to the current unit execution.
39
+ // No queue if an agent_end arrives with no pending resolver, it is dropped
40
+ // (logged as warning). This is simpler and safer than the previous session-
41
+ // scoped pendingResolve + pendingAgentEndQueue pattern.
42
+ let _currentResolve = null;
43
+ let _sessionSwitchInFlight = false;
31
44
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
32
45
  /**
33
46
  * Called from the agent_end event handler in index.ts to resolve the
34
47
  * in-flight unit promise. One-shot: the resolver is nulled before calling
35
48
  * to prevent double-resolution from model fallback retries.
36
49
  *
37
- * If no pendingResolve exists (event arrived between loop iterations),
38
- * the event is queued on the session so the next runUnit can drain it.
50
+ * If no resolver exists (event arrived between loop iterations or during
51
+ * session switch), the event is dropped with a debug warning.
39
52
  */
40
53
  export function resolveAgentEnd(event) {
41
- const s = _activeSession;
42
- if (!s) {
43
- debugLog("resolveAgentEnd", {
44
- status: "no-active-session",
45
- warning: "agent_end with no active loop session",
46
- });
54
+ if (_sessionSwitchInFlight) {
55
+ debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
47
56
  return;
48
57
  }
49
- if (s.pendingResolve) {
58
+ if (_currentResolve) {
50
59
  debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
51
- const r = s.pendingResolve;
52
- s.pendingResolve = null;
60
+ const r = _currentResolve;
61
+ _currentResolve = null;
53
62
  r({ status: "completed", event });
54
63
  }
55
64
  else {
56
- // Queue the event so the next runUnit picks it up immediately
57
65
  debugLog("resolveAgentEnd", {
58
- status: "queued",
59
- queueLength: s.pendingAgentEndQueue.length + 1,
60
- warning: "agent_end arrived between loop iterations — queued for next runUnit",
66
+ status: "no-pending-resolve",
67
+ warning: "agent_end with no pending unit",
61
68
  });
62
- s.pendingAgentEndQueue.push(event);
63
69
  }
64
70
  }
65
71
  export function isSessionSwitchInFlight() {
66
- return _activeSession?.sessionSwitchInFlight ?? false;
72
+ return _sessionSwitchInFlight;
67
73
  }
68
74
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
69
75
  /**
70
- * Reset session promise state. Only exported for test cleanup — production code
71
- * should never call this.
76
+ * Reset module-level promise state. Only exported for test cleanup —
77
+ * production code should never call this.
72
78
  */
73
79
  export function _resetPendingResolve() {
74
- if (_activeSession) {
75
- _activeSession.pendingResolve = null;
76
- _activeSession.pendingAgentEndQueue = [];
77
- }
78
- _activeSession = null;
80
+ _currentResolve = null;
81
+ _sessionSwitchInFlight = false;
79
82
  }
80
83
  /**
81
- * Set the active session for resolveAgentEnd. Only exported for test setup —
82
- * production code sets this via autoLoop entry.
84
+ * No-op for backward compatibility with tests that previously set the
85
+ * active session. The module no longer holds a session reference.
83
86
  */
84
- export function _setActiveSession(session) {
85
- _activeSession = session;
87
+ export function _setActiveSession(_session) {
88
+ // No-op — kept for test backward compatibility
89
+ }
90
+ /**
91
+ * Analyze a sliding window of recent unit dispatches for stuck patterns.
92
+ * Returns a signal with reason if stuck, null otherwise.
93
+ *
94
+ * Rule 1: Same error string twice in a row → stuck immediately.
95
+ * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
96
+ * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
97
+ */
98
+ export function detectStuck(window) {
99
+ if (window.length < 2)
100
+ return null;
101
+ const last = window[window.length - 1];
102
+ const prev = window[window.length - 2];
103
+ // Rule 1: Same error repeated consecutively
104
+ if (last.error && prev.error && last.error === prev.error) {
105
+ return {
106
+ stuck: true,
107
+ reason: `Same error repeated: ${last.error.slice(0, 200)}`,
108
+ };
109
+ }
110
+ // Rule 2: Same unit 3+ consecutive times
111
+ if (window.length >= 3) {
112
+ const lastThree = window.slice(-3);
113
+ if (lastThree.every((u) => u.key === last.key)) {
114
+ return {
115
+ stuck: true,
116
+ reason: `${last.key} derived 3 consecutive times without progress`,
117
+ };
118
+ }
119
+ }
120
+ // Rule 3: Oscillation (A→B→A→B in last 4)
121
+ if (window.length >= 4) {
122
+ const w = window.slice(-4);
123
+ if (w[0].key === w[2].key &&
124
+ w[1].key === w[3].key &&
125
+ w[0].key !== w[1].key) {
126
+ return {
127
+ stuck: true,
128
+ reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
129
+ };
130
+ }
131
+ }
132
+ return null;
86
133
  }
87
134
  // ─── runUnit ─────────────────────────────────────────────────────────────────
88
135
  /**
@@ -93,41 +140,16 @@ export function _setActiveSession(session) {
93
140
  * On session creation failure or timeout, returns { status: 'cancelled' }
94
141
  * without awaiting the promise.
95
142
  */
96
- export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
143
+ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
97
144
  debugLog("runUnit", { phase: "start", unitType, unitId });
98
- // ── Drain queued events from error-recovery retries ──
99
- // If an agent_end arrived between iterations (e.g. from a model fallback
100
- // sendMessage retry), consume it immediately instead of creating a new promise.
101
- // Cap queue to 3 entries to prevent unbounded growth from stale events.
102
- if (s.pendingAgentEndQueue.length > 3) {
103
- debugLog("runUnit", {
104
- phase: "queue-overflow",
105
- dropped: s.pendingAgentEndQueue.length - 1,
106
- unitType,
107
- unitId,
108
- });
109
- s.pendingAgentEndQueue = [
110
- s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1],
111
- ];
112
- }
113
- if (s.pendingAgentEndQueue.length > 0) {
114
- const queued = s.pendingAgentEndQueue.shift();
115
- debugLog("runUnit", {
116
- phase: "drained-queued-event",
117
- unitType,
118
- unitId,
119
- queueRemaining: s.pendingAgentEndQueue.length,
120
- });
121
- return { status: "completed", event: queued };
122
- }
123
145
  // ── Session creation with timeout ──
124
146
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
125
147
  let sessionResult;
126
148
  let sessionTimeoutHandle;
127
- s.sessionSwitchInFlight = true;
149
+ _sessionSwitchInFlight = true;
128
150
  try {
129
151
  const sessionPromise = s.cmdCtx.newSession().finally(() => {
130
- s.sessionSwitchInFlight = false;
152
+ _sessionSwitchInFlight = false;
131
153
  });
132
154
  const timeoutPromise = new Promise((resolve) => {
133
155
  sessionTimeoutHandle = setTimeout(() => resolve({ cancelled: true }), NEW_SESSION_TIMEOUT_MS);
@@ -155,11 +177,12 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
155
177
  if (!s.active) {
156
178
  return { status: "cancelled" };
157
179
  }
158
- // ── Create the agent_end promise (session-scoped) ──
180
+ // ── Create the agent_end promise (per-unit one-shot) ──
159
181
  // This happens after newSession completes so session-switch agent_end events
160
182
  // from the previous session cannot resolve the new unit.
183
+ _sessionSwitchInFlight = false;
161
184
  const unitPromise = new Promise((resolve) => {
162
- s.pendingResolve = resolve;
185
+ _currentResolve = resolve;
163
186
  });
164
187
  // Ensure cwd matches basePath before dispatch (#1389).
165
188
  // async_bash and background jobs can drift cwd away from the worktree.
@@ -184,6 +207,60 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
184
207
  });
185
208
  return result;
186
209
  }
210
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
211
+ /**
212
+ * Generate and write an HTML milestone report snapshot.
213
+ * Extracted from the milestone-transition block in autoLoop.
214
+ */
215
+ async function generateMilestoneReport(s, ctx, milestoneId) {
216
+ const { loadVisualizerData } = await importExtensionModule(import.meta.url, "./visualizer-data.js");
217
+ const { generateHtmlReport } = await importExtensionModule(import.meta.url, "./export-html.js");
218
+ const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "./reports.js");
219
+ const { basename } = await import("node:path");
220
+ const snapData = await loadVisualizerData(s.basePath);
221
+ const completedMs = snapData.milestones.find((m) => m.id === milestoneId);
222
+ const msTitle = completedMs?.title ?? milestoneId;
223
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
224
+ const projName = basename(s.basePath);
225
+ const doneSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.filter((sl) => sl.done).length, 0);
226
+ const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
227
+ const outPath = writeReportSnapshot({
228
+ basePath: s.basePath,
229
+ html: generateHtmlReport(snapData, {
230
+ projectName: projName,
231
+ projectPath: s.basePath,
232
+ gsdVersion,
233
+ milestoneId,
234
+ indexRelPath: "index.html",
235
+ }),
236
+ milestoneId,
237
+ milestoneTitle: msTitle,
238
+ kind: "milestone",
239
+ projectName: projName,
240
+ projectPath: s.basePath,
241
+ gsdVersion,
242
+ totalCost: snapData.totals?.cost ?? 0,
243
+ totalTokens: snapData.totals?.tokens.total ?? 0,
244
+ totalDuration: snapData.totals?.duration ?? 0,
245
+ doneSlices,
246
+ totalSlices,
247
+ doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
248
+ totalMilestones: snapData.milestones.length,
249
+ phase: snapData.phase,
250
+ });
251
+ ctx.ui.notify(`Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, "info");
252
+ }
253
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
254
+ /**
255
+ * If a unit is in-flight, close it out, then stop auto-mode.
256
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
257
+ */
258
+ async function closeoutAndStop(ctx, pi, s, deps, reason) {
259
+ if (s.currentUnit) {
260
+ await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
261
+ }
262
+ await deps.stopAuto(ctx, pi, reason);
263
+ }
187
264
  // ─── autoLoop ────────────────────────────────────────────────────────────────
188
265
  /**
189
266
  * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
@@ -195,10 +272,11 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
195
272
  */
196
273
  export async function autoLoop(ctx, pi, s, deps) {
197
274
  debugLog("autoLoop", { phase: "enter" });
198
- _activeSession = s;
199
275
  let iteration = 0;
200
- let lastDerivedUnit = "";
201
- let sameUnitCount = 0;
276
+ // ── Sliding-window stuck detection ──
277
+ const recentUnits = [];
278
+ const STUCK_WINDOW_SIZE = 6;
279
+ let stuckRecoveryAttempts = 0;
202
280
  let consecutiveErrors = 0;
203
281
  while (s.active) {
204
282
  iteration++;
@@ -218,6 +296,18 @@ export async function autoLoop(ctx, pi, s, deps) {
218
296
  }
219
297
  try {
220
298
  // ── Blanket try/catch: one bad iteration must not kill the session
299
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
300
+ // ── Check sidecar queue before deriveState ──
301
+ let sidecarItem;
302
+ if (s.sidecarQueue.length > 0) {
303
+ sidecarItem = s.sidecarQueue.shift();
304
+ debugLog("autoLoop", {
305
+ phase: "sidecar-dequeue",
306
+ kind: sidecarItem.kind,
307
+ unitType: sidecarItem.unitType,
308
+ unitId: sidecarItem.unitId,
309
+ });
310
+ }
221
311
  const sessionLockBase = deps.lockBase();
222
312
  if (sessionLockBase) {
223
313
  const lockStatus = deps.validateSessionLock(sessionLockBase);
@@ -237,425 +327,436 @@ export async function autoLoop(ctx, pi, s, deps) {
237
327
  break;
238
328
  }
239
329
  }
240
- // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
241
- // Resource version guard
242
- const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
243
- if (staleMsg) {
244
- await deps.stopAuto(ctx, pi, staleMsg);
245
- debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
246
- break;
247
- }
248
- deps.invalidateAllCaches();
249
- s.lastPromptCharCount = undefined;
250
- s.lastBaselineCharCount = undefined;
251
- // Pre-dispatch health gate
252
- try {
253
- const healthGate = await deps.preDispatchHealthGate(s.basePath);
254
- if (healthGate.fixesApplied.length > 0) {
255
- ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
256
- }
257
- if (!healthGate.proceed) {
258
- ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
259
- await deps.pauseAuto(ctx, pi);
260
- debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
330
+ // Variables shared between the sidecar and normal paths
331
+ let unitType;
332
+ let unitId;
333
+ let prompt;
334
+ let pauseAfterUatDispatch = false;
335
+ let state;
336
+ let mid;
337
+ let midTitle;
338
+ let observabilityIssues = [];
339
+ if (!sidecarItem) {
340
+ // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
341
+ // Resource version guard
342
+ const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
343
+ if (staleMsg) {
344
+ await deps.stopAuto(ctx, pi, staleMsg);
345
+ debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
261
346
  break;
262
347
  }
263
- }
264
- catch {
265
- // Non-fatal
266
- }
267
- // Sync project root artifacts into worktree
268
- if (s.originalBasePath &&
269
- s.basePath !== s.originalBasePath &&
270
- s.currentMilestoneId) {
271
- deps.syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
272
- }
273
- // Derive state
274
- let state = await deps.deriveState(s.basePath);
275
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
276
- let mid = state.activeMilestone?.id;
277
- let midTitle = state.activeMilestone?.title;
278
- debugLog("autoLoop", {
279
- phase: "state-derived",
280
- iteration,
281
- mid,
282
- statePhase: state.phase,
283
- });
284
- // ── Milestone transition ────────────────────────────────────────────
285
- if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
286
- ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
287
- deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
288
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
289
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
290
- if (vizPrefs?.auto_visualize) {
291
- ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
292
- }
293
- if (vizPrefs?.auto_report !== false) {
294
- try {
295
- const { loadVisualizerData } = await import("./visualizer-data.js");
296
- const { generateHtmlReport } = await import("./export-html.js");
297
- const { writeReportSnapshot } = await import("./reports.js");
298
- const { basename } = await import("node:path");
299
- const snapData = await loadVisualizerData(s.basePath);
300
- const completedMs = snapData.milestones.find((m) => m.id === s.currentMilestoneId);
301
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
302
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
303
- const projName = basename(s.basePath);
304
- const doneSlices = snapData.milestones.reduce((acc, m) => acc +
305
- m.slices.filter((sl) => sl.done).length, 0);
306
- const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
307
- const outPath = writeReportSnapshot({
308
- basePath: s.basePath,
309
- html: generateHtmlReport(snapData, {
310
- projectName: projName,
311
- projectPath: s.basePath,
312
- gsdVersion,
313
- milestoneId: s.currentMilestoneId,
314
- indexRelPath: "index.html",
315
- }),
316
- milestoneId: s.currentMilestoneId,
317
- milestoneTitle: msTitle,
318
- kind: "milestone",
319
- projectName: projName,
320
- projectPath: s.basePath,
321
- gsdVersion,
322
- totalCost: snapData.totals?.cost ?? 0,
323
- totalTokens: snapData.totals?.tokens.total ?? 0,
324
- totalDuration: snapData.totals?.duration ?? 0,
325
- doneSlices,
326
- totalSlices,
327
- doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
328
- totalMilestones: snapData.milestones.length,
329
- phase: snapData.phase,
330
- });
331
- ctx.ui.notify(`Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`, "info");
348
+ deps.invalidateAllCaches();
349
+ s.lastPromptCharCount = undefined;
350
+ s.lastBaselineCharCount = undefined;
351
+ // Pre-dispatch health gate
352
+ try {
353
+ const healthGate = await deps.preDispatchHealthGate(s.basePath);
354
+ if (healthGate.fixesApplied.length > 0) {
355
+ ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
332
356
  }
333
- catch (err) {
334
- ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
357
+ if (!healthGate.proceed) {
358
+ ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
359
+ await deps.pauseAuto(ctx, pi);
360
+ debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
361
+ break;
335
362
  }
336
363
  }
337
- // Reset dispatch counters for new milestone
338
- s.unitDispatchCount.clear();
339
- s.unitRecoveryCount.clear();
340
- s.unitLifetimeDispatches.clear();
341
- lastDerivedUnit = "";
342
- sameUnitCount = 0;
343
- // Worktree lifecycle on milestone transition — merge current, enter next
344
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
345
- deps.invalidateAllCaches();
364
+ catch {
365
+ // Non-fatal
366
+ }
367
+ // Sync project root artifacts into worktree
368
+ if (s.originalBasePath &&
369
+ s.basePath !== s.originalBasePath &&
370
+ s.currentMilestoneId) {
371
+ deps.syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
372
+ }
373
+ // Derive state
346
374
  state = await deps.deriveState(s.basePath);
375
+ deps.syncCmuxSidebar(prefs, state);
347
376
  mid = state.activeMilestone?.id;
348
377
  midTitle = state.activeMilestone?.title;
378
+ debugLog("autoLoop", {
379
+ phase: "state-derived",
380
+ iteration,
381
+ mid,
382
+ statePhase: state.phase,
383
+ });
384
+ // ── Milestone transition ────────────────────────────────────────────
385
+ if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
386
+ ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
387
+ deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
388
+ deps.logCmuxEvent(prefs, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
389
+ const vizPrefs = prefs;
390
+ if (vizPrefs?.auto_visualize) {
391
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
392
+ }
393
+ if (vizPrefs?.auto_report !== false) {
394
+ try {
395
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId);
396
+ }
397
+ catch (err) {
398
+ ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
399
+ }
400
+ }
401
+ // Reset dispatch counters for new milestone
402
+ s.unitDispatchCount.clear();
403
+ s.unitRecoveryCount.clear();
404
+ s.unitLifetimeDispatches.clear();
405
+ recentUnits.length = 0;
406
+ stuckRecoveryAttempts = 0;
407
+ // Worktree lifecycle on milestone transition — merge current, enter next
408
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
409
+ // Opt-in: create draft PR on milestone completion
410
+ if (prefs?.git?.auto_pr) {
411
+ try {
412
+ const { createDraftPR } = await import("./git-service.js");
413
+ const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
414
+ if (prUrl) {
415
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
416
+ }
417
+ }
418
+ catch {
419
+ // Non-fatal — PR creation is best-effort
420
+ }
421
+ }
422
+ deps.invalidateAllCaches();
423
+ state = await deps.deriveState(s.basePath);
424
+ mid = state.activeMilestone?.id;
425
+ midTitle = state.activeMilestone?.title;
426
+ if (mid) {
427
+ if (deps.getIsolationMode() !== "none") {
428
+ deps.captureIntegrationBranch(s.basePath, mid, {
429
+ commitDocs: prefs?.git?.commit_docs,
430
+ });
431
+ }
432
+ deps.resolver.enterMilestone(mid, ctx.ui);
433
+ }
434
+ else {
435
+ // mid is undefined — no milestone to capture integration branch for
436
+ }
437
+ const pendingIds = state.registry
438
+ .filter((m) => m.status !== "complete" && m.status !== "parked")
439
+ .map((m) => m.id);
440
+ deps.pruneQueueOrder(s.basePath, pendingIds);
441
+ }
349
442
  if (mid) {
350
- if (deps.getIsolationMode() !== "none") {
351
- deps.captureIntegrationBranch(s.basePath, mid, {
352
- commitDocs: deps.loadEffectiveGSDPreferences()?.preferences?.git
353
- ?.commit_docs,
354
- });
443
+ s.currentMilestoneId = mid;
444
+ deps.setActiveMilestoneId(s.basePath, mid);
445
+ }
446
+ // ── Terminal conditions ──────────────────────────────────────────────
447
+ if (!mid) {
448
+ if (s.currentUnit) {
449
+ await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
450
+ }
451
+ const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
452
+ if (incomplete.length === 0 && state.registry.length > 0) {
453
+ // All milestones complete — merge milestone branch before stopping
454
+ if (s.currentMilestoneId) {
455
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
456
+ // Opt-in: create draft PR on milestone completion
457
+ if (prefs?.git?.auto_pr) {
458
+ try {
459
+ const { createDraftPR } = await import("./git-service.js");
460
+ const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
461
+ if (prUrl) {
462
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
463
+ }
464
+ }
465
+ catch {
466
+ // Non-fatal — PR creation is best-effort
467
+ }
468
+ }
469
+ }
470
+ deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
471
+ deps.logCmuxEvent(prefs, "All milestones complete.", "success");
472
+ await deps.stopAuto(ctx, pi, "All milestones complete");
473
+ }
474
+ else if (incomplete.length === 0 && state.registry.length === 0) {
475
+ // Empty registry — no milestones visible, likely a path resolution bug
476
+ const diag = `basePath=${s.basePath}, phase=${state.phase}`;
477
+ ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
478
+ await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
479
+ }
480
+ else if (state.phase === "blocked") {
481
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
482
+ await deps.stopAuto(ctx, pi, blockerMsg);
483
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
484
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
485
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
355
486
  }
356
- deps.resolver.enterMilestone(mid, ctx.ui);
487
+ else {
488
+ const ids = incomplete.map((m) => m.id).join(", ");
489
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
490
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
491
+ await deps.stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
492
+ }
493
+ debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
494
+ break;
357
495
  }
358
- else {
359
- // mid is undefined — no milestone to capture integration branch for
496
+ if (!midTitle) {
497
+ midTitle = mid;
498
+ ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
360
499
  }
361
- const pendingIds = state.registry
362
- .filter((m) => m.status !== "complete" && m.status !== "parked")
363
- .map((m) => m.id);
364
- deps.pruneQueueOrder(s.basePath, pendingIds);
365
- }
366
- if (mid) {
367
- s.currentMilestoneId = mid;
368
- deps.setActiveMilestoneId(s.basePath, mid);
369
- }
370
- // ── Terminal conditions ──────────────────────────────────────────────
371
- if (!mid) {
372
- if (s.currentUnit) {
373
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
374
- }
375
- const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
376
- if (incomplete.length === 0 && state.registry.length > 0) {
377
- // All milestones complete — merge milestone branch before stopping
500
+ // Mid-merge safety check
501
+ if (deps.reconcileMergeState(s.basePath, ctx)) {
502
+ deps.invalidateAllCaches();
503
+ state = await deps.deriveState(s.basePath);
504
+ mid = state.activeMilestone?.id;
505
+ midTitle = state.activeMilestone?.title;
506
+ }
507
+ if (!mid || !midTitle) {
508
+ const noMilestoneReason = !mid
509
+ ? "No active milestone after merge reconciliation"
510
+ : `Milestone ${mid} has no title after reconciliation`;
511
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
512
+ debugLog("autoLoop", {
513
+ phase: "exit",
514
+ reason: "no-milestone-after-reconciliation",
515
+ });
516
+ break;
517
+ }
518
+ // Terminal: complete
519
+ if (state.phase === "complete") {
520
+ // Milestone merge on complete (before closeout so branch state is clean)
378
521
  if (s.currentMilestoneId) {
379
522
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
523
+ // Opt-in: create draft PR on milestone completion
524
+ if (prefs?.git?.auto_pr) {
525
+ try {
526
+ const { createDraftPR } = await import("./git-service.js");
527
+ const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
528
+ if (prUrl) {
529
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
530
+ }
531
+ }
532
+ catch {
533
+ // Non-fatal — PR creation is best-effort
534
+ }
535
+ }
380
536
  }
381
- deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
382
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, "All milestones complete.", "success");
383
- await deps.stopAuto(ctx, pi, "All milestones complete");
384
- }
385
- else if (incomplete.length === 0 && state.registry.length === 0) {
386
- // Empty registry — no milestones visible, likely a path resolution bug
387
- const diag = `basePath=${s.basePath}, phase=${state.phase}`;
388
- ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
389
- await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
537
+ deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
538
+ deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success");
539
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
540
+ debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
541
+ break;
390
542
  }
391
- else if (state.phase === "blocked") {
543
+ // Terminal: blocked
544
+ if (state.phase === "blocked") {
392
545
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
393
- await deps.stopAuto(ctx, pi, blockerMsg);
546
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
394
547
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
395
548
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
396
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
397
- }
398
- else {
399
- const ids = incomplete.map((m) => m.id).join(", ");
400
- const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
401
- ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
402
- await deps.stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
549
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
550
+ debugLog("autoLoop", { phase: "exit", reason: "blocked" });
551
+ break;
403
552
  }
404
- debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
405
- break;
406
- }
407
- if (!midTitle) {
408
- midTitle = mid;
409
- ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
410
- }
411
- // Mid-merge safety check
412
- if (deps.reconcileMergeState(s.basePath, ctx)) {
413
- deps.invalidateAllCaches();
414
- state = await deps.deriveState(s.basePath);
415
- mid = state.activeMilestone?.id;
416
- midTitle = state.activeMilestone?.title;
417
- }
418
- if (!mid || !midTitle) {
419
- if (s.currentUnit) {
420
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
421
- }
422
- const noMilestoneReason = !mid
423
- ? "No active milestone after merge reconciliation"
424
- : `Milestone ${mid} has no title after reconciliation`;
425
- await deps.stopAuto(ctx, pi, noMilestoneReason);
426
- debugLog("autoLoop", {
427
- phase: "exit",
428
- reason: "no-milestone-after-reconciliation",
429
- });
430
- break;
431
- }
432
- // Terminal: complete
433
- if (state.phase === "complete") {
434
- if (s.currentUnit) {
435
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
553
+ // ── Phase 2: Guards ─────────────────────────────────────────────────
554
+ // Budget ceiling guard
555
+ const budgetCeiling = prefs?.budget_ceiling;
556
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
557
+ const currentLedger = deps.getLedger();
558
+ const totalCost = currentLedger
559
+ ? deps.getProjectTotals(currentLedger.units).cost
560
+ : 0;
561
+ const budgetPct = totalCost / budgetCeiling;
562
+ const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
563
+ const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
564
+ const enforcement = prefs?.budget_enforcement ?? "pause";
565
+ const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
566
+ // Data-driven threshold check — loop descending, fire first match
567
+ const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
568
+ if (threshold) {
569
+ s.lastBudgetAlertLevel =
570
+ newBudgetAlertLevel;
571
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
572
+ // 100% special enforcement logic (halt/pause/warn)
573
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
574
+ if (budgetEnforcementAction === "halt") {
575
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
576
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
577
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
578
+ break;
579
+ }
580
+ if (budgetEnforcementAction === "pause") {
581
+ ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
582
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
583
+ deps.logCmuxEvent(prefs, msg, "warning");
584
+ await deps.pauseAuto(ctx, pi);
585
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
586
+ break;
587
+ }
588
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
589
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
590
+ deps.logCmuxEvent(prefs, msg, "warning");
591
+ }
592
+ else if (threshold.pct < 100) {
593
+ // Sub-100% — simple notification
594
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
595
+ ctx.ui.notify(msg, threshold.notifyLevel);
596
+ deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
597
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
598
+ }
599
+ }
600
+ else if (budgetAlertLevel === 0) {
601
+ s.lastBudgetAlertLevel = 0;
602
+ }
436
603
  }
437
- // Milestone merge on complete
438
- if (s.currentMilestoneId) {
439
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
604
+ else {
605
+ s.lastBudgetAlertLevel = 0;
440
606
  }
441
- deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
442
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${mid} complete.`, "success");
443
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
444
- debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
445
- break;
446
- }
447
- // Terminal: blocked
448
- if (state.phase === "blocked") {
449
- if (s.currentUnit) {
450
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
451
- }
452
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
453
- await deps.stopAuto(ctx, pi, blockerMsg);
454
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
455
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
456
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
457
- debugLog("autoLoop", { phase: "exit", reason: "blocked" });
458
- break;
459
- }
460
- // ── Phase 2: Guards ─────────────────────────────────────────────────
461
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
462
- // Budget ceiling guard
463
- const budgetCeiling = prefs?.budget_ceiling;
464
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
465
- const currentLedger = deps.getLedger();
466
- const totalCost = currentLedger
467
- ? deps.getProjectTotals(currentLedger.units).cost
468
- : 0;
469
- const budgetPct = totalCost / budgetCeiling;
470
- const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
471
- const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
472
- const enforcement = prefs?.budget_enforcement ?? "pause";
473
- const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
474
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
475
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
476
- s.lastBudgetAlertLevel =
477
- newBudgetAlertLevel;
478
- if (budgetEnforcementAction === "halt") {
479
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
480
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
481
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
482
- break;
483
- }
484
- if (budgetEnforcementAction === "pause") {
485
- ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
486
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
487
- deps.logCmuxEvent(prefs, msg, "warning");
607
+ // Context window guard
608
+ const contextThreshold = prefs?.context_pause_threshold ?? 0;
609
+ if (contextThreshold > 0 && s.cmdCtx) {
610
+ const contextUsage = s.cmdCtx.getContextUsage();
611
+ if (contextUsage &&
612
+ contextUsage.percent !== null &&
613
+ contextUsage.percent >= contextThreshold) {
614
+ const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
615
+ ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
616
+ deps.sendDesktopNotification("GSD", `Context ${contextUsage.percent}% paused`, "warning", "attention");
488
617
  await deps.pauseAuto(ctx, pi);
489
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
618
+ debugLog("autoLoop", { phase: "exit", reason: "context-window" });
490
619
  break;
491
620
  }
492
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
493
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
494
- deps.logCmuxEvent(prefs, msg, "warning");
495
- }
496
- else if (newBudgetAlertLevel === 90) {
497
- s.lastBudgetAlertLevel =
498
- newBudgetAlertLevel;
499
- ctx.ui.notify(`Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
500
- deps.sendDesktopNotification("GSD", `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning", "budget");
501
- deps.logCmuxEvent(prefs, `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
502
- }
503
- else if (newBudgetAlertLevel === 80) {
504
- s.lastBudgetAlertLevel =
505
- newBudgetAlertLevel;
506
- ctx.ui.notify(`Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
507
- deps.sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning", "budget");
508
- deps.logCmuxEvent(prefs, `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "warning");
509
- }
510
- else if (newBudgetAlertLevel === 75) {
511
- s.lastBudgetAlertLevel =
512
- newBudgetAlertLevel;
513
- ctx.ui.notify(`Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "info");
514
- deps.sendDesktopNotification("GSD", `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "info", "budget");
515
- deps.logCmuxEvent(prefs, `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, "progress");
516
- }
517
- else if (budgetAlertLevel === 0) {
518
- s.lastBudgetAlertLevel = 0;
519
621
  }
520
- }
521
- else {
522
- s.lastBudgetAlertLevel = 0;
523
- }
524
- // Context window guard
525
- const contextThreshold = prefs?.context_pause_threshold ?? 0;
526
- if (contextThreshold > 0 && s.cmdCtx) {
527
- const contextUsage = s.cmdCtx.getContextUsage();
528
- if (contextUsage &&
529
- contextUsage.percent !== null &&
530
- contextUsage.percent >= contextThreshold) {
531
- const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
532
- ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
533
- deps.sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention");
534
- await deps.pauseAuto(ctx, pi);
535
- debugLog("autoLoop", { phase: "exit", reason: "context-window" });
536
- break;
537
- }
538
- }
539
- // Secrets re-check gate
540
- try {
541
- const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
542
- if (manifestStatus && manifestStatus.pending.length > 0) {
543
- const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
544
- if (result &&
545
- result.applied &&
546
- result.skipped &&
547
- result.existingSkipped) {
548
- ctx.ui.notify(`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, "info");
549
- }
550
- else {
551
- ctx.ui.notify("Secrets collection skipped.", "info");
622
+ // Secrets re-check gate
623
+ try {
624
+ const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
625
+ if (manifestStatus && manifestStatus.pending.length > 0) {
626
+ const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
627
+ if (result &&
628
+ result.applied &&
629
+ result.skipped &&
630
+ result.existingSkipped) {
631
+ ctx.ui.notify(`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, "info");
632
+ }
633
+ else {
634
+ ctx.ui.notify("Secrets collection skipped.", "info");
635
+ }
552
636
  }
553
637
  }
554
- }
555
- catch (err) {
556
- ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
557
- }
558
- // ── Phase 3: Dispatch resolution ────────────────────────────────────
559
- debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
560
- const dispatchResult = await deps.resolveDispatch({
561
- basePath: s.basePath,
562
- mid,
563
- midTitle: midTitle,
564
- state,
565
- prefs,
566
- });
567
- if (dispatchResult.action === "stop") {
568
- if (s.currentUnit) {
569
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
638
+ catch (err) {
639
+ ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
570
640
  }
571
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
572
- debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
573
- break;
574
- }
575
- if (dispatchResult.action !== "dispatch") {
576
- // Non-dispatch action (e.g. "skip") — re-derive state
577
- await new Promise((r) => setImmediate(r));
578
- continue;
579
- }
580
- let unitType = dispatchResult.unitType;
581
- let unitId = dispatchResult.unitId;
582
- let prompt = dispatchResult.prompt;
583
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
584
- // ── Same-unit stuck counter with graduated recovery ──
585
- const derivedKey = `${unitType}/${unitId}`;
586
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
587
- sameUnitCount++;
588
- debugLog("autoLoop", {
589
- phase: "stuck-check",
590
- unitType,
591
- unitId,
592
- sameUnitCount,
641
+ // ── Phase 3: Dispatch resolution ────────────────────────────────────
642
+ debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
643
+ const dispatchResult = await deps.resolveDispatch({
644
+ basePath: s.basePath,
645
+ mid,
646
+ midTitle: midTitle,
647
+ state,
648
+ prefs,
649
+ session: s,
593
650
  });
594
- if (sameUnitCount === 3) {
595
- // Level 1: try verifying the artifact — maybe it was written but not detected
596
- const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
597
- if (artifactExists) {
651
+ if (dispatchResult.action === "stop") {
652
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
653
+ debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
654
+ break;
655
+ }
656
+ if (dispatchResult.action !== "dispatch") {
657
+ // Non-dispatch action (e.g. "skip") — re-derive state
658
+ await new Promise((r) => setImmediate(r));
659
+ continue;
660
+ }
661
+ unitType = dispatchResult.unitType;
662
+ unitId = dispatchResult.unitId;
663
+ prompt = dispatchResult.prompt;
664
+ pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
665
+ // ── Sliding-window stuck detection with graduated recovery ──
666
+ const derivedKey = `${unitType}/${unitId}`;
667
+ if (!s.pendingVerificationRetry) {
668
+ recentUnits.push({ key: derivedKey });
669
+ if (recentUnits.length > STUCK_WINDOW_SIZE)
670
+ recentUnits.shift();
671
+ const stuckSignal = detectStuck(recentUnits);
672
+ if (stuckSignal) {
598
673
  debugLog("autoLoop", {
599
- phase: "stuck-recovery",
600
- level: 1,
601
- action: "artifact-found",
674
+ phase: "stuck-check",
675
+ unitType,
676
+ unitId,
677
+ reason: stuckSignal.reason,
678
+ recoveryAttempts: stuckRecoveryAttempts,
602
679
  });
603
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
604
- deps.invalidateAllCaches();
605
- continue;
680
+ if (stuckRecoveryAttempts === 0) {
681
+ // Level 1: try verifying the artifact, then cache invalidation + retry
682
+ stuckRecoveryAttempts++;
683
+ const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
684
+ if (artifactExists) {
685
+ debugLog("autoLoop", {
686
+ phase: "stuck-recovery",
687
+ level: 1,
688
+ action: "artifact-found",
689
+ });
690
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
691
+ deps.invalidateAllCaches();
692
+ continue;
693
+ }
694
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, "warning");
695
+ deps.invalidateAllCaches();
696
+ }
697
+ else {
698
+ // Level 2: hard stop — genuinely stuck
699
+ debugLog("autoLoop", {
700
+ phase: "stuck-detected",
701
+ unitType,
702
+ unitId,
703
+ reason: stuckSignal.reason,
704
+ });
705
+ await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
706
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, "error");
707
+ break;
708
+ }
709
+ }
710
+ else {
711
+ // Progress detected — reset recovery counter
712
+ if (stuckRecoveryAttempts > 0) {
713
+ debugLog("autoLoop", {
714
+ phase: "stuck-counter-reset",
715
+ from: recentUnits[recentUnits.length - 2]?.key ?? "",
716
+ to: derivedKey,
717
+ });
718
+ stuckRecoveryAttempts = 0;
719
+ }
606
720
  }
607
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`, "warning");
608
- deps.invalidateAllCaches();
609
721
  }
610
- else if (sameUnitCount === 5) {
611
- // Level 2: hard stop genuinely stuck
612
- debugLog("autoLoop", {
613
- phase: "stuck-detected",
614
- unitType,
615
- unitId,
616
- sameUnitCount,
617
- });
618
- await deps.stopAuto(ctx, pi, `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`);
619
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`, "error");
722
+ // Pre-dispatch hooks
723
+ const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
724
+ if (preDispatchResult.firedHooks.length > 0) {
725
+ ctx.ui.notify(`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info");
726
+ }
727
+ if (preDispatchResult.action === "skip") {
728
+ ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
729
+ await new Promise((r) => setImmediate(r));
730
+ continue;
731
+ }
732
+ if (preDispatchResult.action === "replace") {
733
+ prompt = preDispatchResult.prompt ?? prompt;
734
+ if (preDispatchResult.unitType)
735
+ unitType = preDispatchResult.unitType;
736
+ }
737
+ else if (preDispatchResult.prompt) {
738
+ prompt = preDispatchResult.prompt;
739
+ }
740
+ const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(s.basePath, deps.getMainBranch(s.basePath), unitType, unitId);
741
+ if (priorSliceBlocker) {
742
+ await deps.stopAuto(ctx, pi, priorSliceBlocker);
743
+ debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
620
744
  break;
621
745
  }
746
+ observabilityIssues = await deps.collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
747
+ // Derive state for shared use in execution phase
748
+ // (state, mid, midTitle already set above)
622
749
  }
623
750
  else {
624
- if (derivedKey !== lastDerivedUnit) {
625
- debugLog("autoLoop", {
626
- phase: "stuck-counter-reset",
627
- from: lastDerivedUnit,
628
- to: derivedKey,
629
- });
630
- }
631
- lastDerivedUnit = derivedKey;
632
- sameUnitCount = 0;
633
- }
634
- // Pre-dispatch hooks
635
- const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
636
- if (preDispatchResult.firedHooks.length > 0) {
637
- ctx.ui.notify(`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info");
638
- }
639
- if (preDispatchResult.action === "skip") {
640
- ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
641
- await new Promise((r) => setImmediate(r));
642
- continue;
643
- }
644
- if (preDispatchResult.action === "replace") {
645
- prompt = preDispatchResult.prompt ?? prompt;
646
- if (preDispatchResult.unitType)
647
- unitType = preDispatchResult.unitType;
648
- }
649
- else if (preDispatchResult.prompt) {
650
- prompt = preDispatchResult.prompt;
651
- }
652
- const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(s.basePath, deps.getMainBranch(s.basePath), unitType, unitId);
653
- if (priorSliceBlocker) {
654
- await deps.stopAuto(ctx, pi, priorSliceBlocker);
655
- debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
656
- break;
751
+ // ── Sidecar path: use values from the sidecar item directly ──
752
+ unitType = sidecarItem.unitType;
753
+ unitId = sidecarItem.unitId;
754
+ prompt = sidecarItem.prompt;
755
+ // Derive minimal state for progress widget / execution context
756
+ state = await deps.deriveState(s.basePath);
757
+ mid = state.activeMilestone?.id;
758
+ midTitle = state.activeMilestone?.title;
657
759
  }
658
- const observabilityIssues = await deps.collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
659
760
  // ── Phase 4: Unit execution ─────────────────────────────────────────
660
761
  debugLog("autoLoop", {
661
762
  phase: "unit-execution",
@@ -668,33 +769,6 @@ export async function autoLoop(ctx, pi, s, deps) {
668
769
  s.currentUnit.type === unitType &&
669
770
  s.currentUnit.id === unitId);
670
771
  const previousTier = s.currentUnitRouting?.tier;
671
- // Closeout previous unit
672
- if (s.currentUnit) {
673
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
674
- if (s.currentUnitRouting) {
675
- const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
676
- deps.recordOutcome(s.currentUnit.type, s.currentUnitRouting.tier, !isRetry);
677
- }
678
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
679
- const incomingKey = `${unitType}/${unitId}`;
680
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
681
- const artifactVerified = isHookUnit ||
682
- deps.verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
683
- if (closeoutKey !== incomingKey && artifactVerified) {
684
- s.completedUnits.push({
685
- type: s.currentUnit.type,
686
- id: s.currentUnit.id,
687
- startedAt: s.currentUnit.startedAt,
688
- finishedAt: Date.now(),
689
- });
690
- if (s.completedUnits.length > 200) {
691
- s.completedUnits = s.completedUnits.slice(-200);
692
- }
693
- deps.clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
694
- s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
695
- s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
696
- }
697
- }
698
772
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
699
773
  deps.captureAvailableSkills();
700
774
  deps.writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
@@ -712,7 +786,6 @@ export async function autoLoop(ctx, pi, s, deps) {
712
786
  deps.updateProgressWidget(ctx, unitType, unitId, state);
713
787
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
714
788
  // Prompt injection
715
- const MAX_RECOVERY_CHARS = 50_000;
716
789
  let finalPrompt = prompt;
717
790
  if (s.pendingVerificationRetry) {
718
791
  const retryCtx = s.pendingVerificationRetry;
@@ -750,7 +823,7 @@ export async function autoLoop(ctx, pi, s, deps) {
750
823
  s.lastBaselineCharCount = undefined;
751
824
  if (deps.isDbAvailable()) {
752
825
  try {
753
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
826
+ const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js");
754
827
  const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
755
828
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
756
829
  inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
@@ -773,8 +846,8 @@ export async function autoLoop(ctx, pi, s, deps) {
773
846
  const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
774
847
  process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
775
848
  }
776
- // Select and apply model (with tier escalation on retry)
777
- const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel, { isRetry, previousTier });
849
+ // Select and apply model (with tier escalation on retry — normal units only)
850
+ const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel, sidecarItem ? undefined : { isRetry, previousTier });
778
851
  s.currentUnitRouting =
779
852
  modelResult.routing;
780
853
  // Start unit supervision
@@ -800,7 +873,7 @@ export async function autoLoop(ctx, pi, s, deps) {
800
873
  unitType,
801
874
  unitId,
802
875
  });
803
- const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt, prefs);
876
+ const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt);
804
877
  debugLog("autoLoop", {
805
878
  phase: "runUnit-end",
806
879
  iteration,
@@ -808,12 +881,60 @@ export async function autoLoop(ctx, pi, s, deps) {
808
881
  unitId,
809
882
  status: unitResult.status,
810
883
  });
884
+ // Tag the most recent window entry with error info for stuck detection
885
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
886
+ const lastEntry = recentUnits[recentUnits.length - 1];
887
+ if (lastEntry) {
888
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
889
+ }
890
+ }
891
+ else if (unitResult.event?.messages?.length) {
892
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
893
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
894
+ if (/error|fail|exception/i.test(msgStr)) {
895
+ const lastEntry = recentUnits[recentUnits.length - 1];
896
+ if (lastEntry) {
897
+ lastEntry.error = msgStr.slice(0, 200);
898
+ }
899
+ }
900
+ }
811
901
  if (unitResult.status === "cancelled") {
812
902
  ctx.ui.notify(`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, "warning");
813
903
  await deps.stopAuto(ctx, pi, "Session creation failed");
814
904
  debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
815
905
  break;
816
906
  }
907
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
908
+ // Run right after runUnit() returns so telemetry is never lost to a
909
+ // crash between iterations.
910
+ await deps.closeoutUnit(ctx, s.basePath, unitType, unitId, s.currentUnit.startedAt, deps.buildSnapshotOpts(unitType, unitId));
911
+ if (s.currentUnitRouting) {
912
+ deps.recordOutcome(unitType, s.currentUnitRouting.tier, true);
913
+ }
914
+ const isHookUnit = unitType.startsWith("hook/");
915
+ const artifactVerified = isHookUnit ||
916
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
917
+ if (artifactVerified) {
918
+ s.completedUnits.push({
919
+ type: unitType,
920
+ id: unitId,
921
+ startedAt: s.currentUnit.startedAt,
922
+ finishedAt: Date.now(),
923
+ });
924
+ if (s.completedUnits.length > 200) {
925
+ s.completedUnits = s.completedUnits.slice(-200);
926
+ }
927
+ // Flush completed-units to disk so the record survives crashes
928
+ try {
929
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
930
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
931
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
932
+ }
933
+ catch { /* non-fatal: disk flush failure */ }
934
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
935
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
936
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
937
+ }
817
938
  // ── Phase 5: Finalize ───────────────────────────────────────────────
818
939
  debugLog("autoLoop", { phase: "finalize", iteration });
819
940
  // Clear unit timeout (unit completed)
@@ -830,7 +951,13 @@ export async function autoLoop(ctx, pi, s, deps) {
830
951
  updateProgressWidget: deps.updateProgressWidget,
831
952
  };
832
953
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
833
- const preResult = await deps.postUnitPreVerification(postUnitCtx);
954
+ // Sidecar items use lightweight pre-verification opts
955
+ const preVerificationOpts = sidecarItem
956
+ ? sidecarItem.kind === "hook"
957
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
958
+ : { skipSettleDelay: true, skipStateRebuild: true }
959
+ : undefined;
960
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
834
961
  if (preResult === "dispatched") {
835
962
  debugLog("autoLoop", {
836
963
  phase: "exit",
@@ -844,17 +971,28 @@ export async function autoLoop(ctx, pi, s, deps) {
844
971
  debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
845
972
  break;
846
973
  }
847
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
848
- const verificationResult = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
849
- if (verificationResult === "pause") {
850
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
851
- break;
852
- }
853
- if (verificationResult === "retry") {
854
- // s.pendingVerificationRetry was set by runPostUnitVerification.
855
- // Continue the loop — next iteration will inject the retry context into the prompt.
856
- debugLog("autoLoop", { phase: "verification-retry", iteration });
857
- continue;
974
+ // Verification gate
975
+ // Hook sidecar items skip verification entirely.
976
+ // Non-hook sidecar items run verification but skip retries (just continue).
977
+ const skipVerification = sidecarItem?.kind === "hook";
978
+ if (!skipVerification) {
979
+ const verificationResult = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
980
+ if (verificationResult === "pause") {
981
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
982
+ break;
983
+ }
984
+ if (verificationResult === "retry") {
985
+ if (sidecarItem) {
986
+ // Sidecar verification retries are skipped — just continue
987
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration });
988
+ }
989
+ else {
990
+ // s.pendingVerificationRetry was set by runPostUnitVerification.
991
+ // Continue the loop — next iteration will inject the retry context into the prompt.
992
+ debugLog("autoLoop", { phase: "verification-retry", iteration });
993
+ continue;
994
+ }
995
+ }
858
996
  }
859
997
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
860
998
  const postResult = await deps.postUnitPostVerification(postUnitCtx);
@@ -870,102 +1008,6 @@ export async function autoLoop(ctx, pi, s, deps) {
870
1008
  debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
871
1009
  break;
872
1010
  }
873
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
874
- let sidecarBroke = false;
875
- while (s.sidecarQueue.length > 0 && s.active) {
876
- const item = s.sidecarQueue.shift();
877
- debugLog("autoLoop", {
878
- phase: "sidecar-dequeue",
879
- kind: item.kind,
880
- unitType: item.unitType,
881
- unitId: item.unitId,
882
- });
883
- // Set up as current unit
884
- const sidecarStartedAt = Date.now();
885
- s.currentUnit = {
886
- type: item.unitType,
887
- id: item.unitId,
888
- startedAt: sidecarStartedAt,
889
- };
890
- deps.writeUnitRuntimeRecord(s.basePath, item.unitType, item.unitId, sidecarStartedAt, {
891
- phase: "dispatched",
892
- wrapupWarningSent: false,
893
- timeoutAt: null,
894
- lastProgressAt: sidecarStartedAt,
895
- progressCount: 0,
896
- lastProgressKind: "dispatch",
897
- });
898
- // Model selection (handles hook model override)
899
- await deps.selectAndApplyModel(ctx, pi, item.unitType, item.unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel);
900
- // Supervision
901
- deps.clearUnitTimeout();
902
- deps.startUnitSupervision({
903
- s,
904
- ctx,
905
- pi,
906
- unitType: item.unitType,
907
- unitId: item.unitId,
908
- prefs,
909
- buildSnapshotOpts: () => deps.buildSnapshotOpts(item.unitType, item.unitId),
910
- buildRecoveryContext: () => ({}),
911
- pauseAuto: deps.pauseAuto,
912
- });
913
- // Write lock
914
- const sidecarSessionFile = deps.getSessionFile(ctx);
915
- deps.writeLock(deps.lockBase(), item.unitType, item.unitId, s.completedUnits.length, sidecarSessionFile);
916
- // Execute via standard runUnit
917
- const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt, prefs);
918
- deps.clearUnitTimeout();
919
- if (sidecarResult.status === "cancelled") {
920
- ctx.ui.notify(`Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`, "warning");
921
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
922
- sidecarBroke = true;
923
- break;
924
- }
925
- // Run pre-verification for the sidecar unit
926
- const sidecarPreResult = await deps.postUnitPreVerification(postUnitCtx);
927
- if (sidecarPreResult === "dispatched") {
928
- // Pre-verification caused stop/pause
929
- debugLog("autoLoop", {
930
- phase: "exit",
931
- reason: "sidecar-pre-verification-stop",
932
- });
933
- sidecarBroke = true;
934
- break;
935
- }
936
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
937
- // Hook units are lightweight and don't need verification.
938
- if (item.kind !== "hook") {
939
- const sidecarVerification = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
940
- if (sidecarVerification === "pause") {
941
- debugLog("autoLoop", {
942
- phase: "exit",
943
- reason: "sidecar-verification-pause",
944
- });
945
- sidecarBroke = true;
946
- break;
947
- }
948
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
949
- }
950
- // Post-verification (may enqueue more sidecar items)
951
- const sidecarPostResult = await deps.postUnitPostVerification(postUnitCtx);
952
- if (sidecarPostResult === "stopped") {
953
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
954
- sidecarBroke = true;
955
- break;
956
- }
957
- if (sidecarPostResult === "step-wizard") {
958
- debugLog("autoLoop", {
959
- phase: "exit",
960
- reason: "sidecar-step-wizard",
961
- });
962
- sidecarBroke = true;
963
- break;
964
- }
965
- // "continue" — loop checks sidecarQueue again
966
- }
967
- if (sidecarBroke)
968
- break;
969
1011
  consecutiveErrors = 0; // Iteration completed successfully
970
1012
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
971
1013
  }
@@ -996,6 +1038,6 @@ export async function autoLoop(ctx, pi, s, deps) {
996
1038
  }
997
1039
  }
998
1040
  }
999
- _activeSession = null;
1041
+ _currentResolve = null;
1000
1042
  debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
1001
1043
  }