gsd-pi 2.38.0-dev.add4f78 → 2.38.0-dev.d533afb

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 (117) hide show
  1. package/dist/resource-loader.js +34 -1
  2. package/dist/resources/extensions/github-sync/cli.js +284 -0
  3. package/dist/resources/extensions/github-sync/index.js +73 -0
  4. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  5. package/dist/resources/extensions/github-sync/sync.js +424 -0
  6. package/dist/resources/extensions/github-sync/templates.js +118 -0
  7. package/dist/resources/extensions/github-sync/types.js +7 -0
  8. package/dist/resources/extensions/gsd/auto/session.js +3 -23
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  10. package/dist/resources/extensions/gsd/auto-loop.js +292 -263
  11. package/dist/resources/extensions/gsd/auto-post-unit.js +28 -3
  12. package/dist/resources/extensions/gsd/auto-prompts.js +23 -43
  13. package/dist/resources/extensions/gsd/auto-start.js +7 -1
  14. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  15. package/dist/resources/extensions/gsd/auto.js +143 -80
  16. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  17. package/dist/resources/extensions/gsd/commands.js +2 -1
  18. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  19. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  20. package/dist/resources/extensions/gsd/doctor-providers.js +27 -11
  21. package/dist/resources/extensions/gsd/doctor.js +20 -1
  22. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  23. package/dist/resources/extensions/gsd/files.js +4 -0
  24. package/dist/resources/extensions/gsd/git-service.js +15 -12
  25. package/dist/resources/extensions/gsd/guided-flow.js +82 -32
  26. package/dist/resources/extensions/gsd/index.js +22 -19
  27. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  28. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  29. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  30. package/dist/resources/extensions/gsd/preferences-validation.js +58 -10
  31. package/dist/resources/extensions/gsd/preferences.js +4 -2
  32. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  33. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -2
  34. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  35. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  36. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  37. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  38. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  39. package/dist/resources/extensions/gsd/prompts/run-uat.md +27 -10
  40. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  41. package/dist/resources/extensions/gsd/repo-identity.js +19 -3
  42. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  43. package/dist/resources/extensions/mcp-client/index.js +14 -1
  44. package/package.json +1 -1
  45. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  46. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  47. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  50. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  51. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  52. package/src/resources/extensions/github-sync/cli.ts +364 -0
  53. package/src/resources/extensions/github-sync/index.ts +93 -0
  54. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  55. package/src/resources/extensions/github-sync/sync.ts +556 -0
  56. package/src/resources/extensions/github-sync/templates.ts +183 -0
  57. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  58. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  59. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  60. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  61. package/src/resources/extensions/github-sync/types.ts +47 -0
  62. package/src/resources/extensions/gsd/auto/session.ts +3 -25
  63. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  64. package/src/resources/extensions/gsd/auto-loop.ts +382 -360
  65. package/src/resources/extensions/gsd/auto-post-unit.ts +29 -3
  66. package/src/resources/extensions/gsd/auto-prompts.ts +25 -45
  67. package/src/resources/extensions/gsd/auto-start.ts +11 -1
  68. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  69. package/src/resources/extensions/gsd/auto.ts +139 -86
  70. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  71. package/src/resources/extensions/gsd/commands.ts +2 -2
  72. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  73. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  74. package/src/resources/extensions/gsd/doctor-providers.ts +26 -9
  75. package/src/resources/extensions/gsd/doctor.ts +22 -1
  76. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  77. package/src/resources/extensions/gsd/files.ts +3 -1
  78. package/src/resources/extensions/gsd/git-service.ts +20 -10
  79. package/src/resources/extensions/gsd/guided-flow.ts +110 -38
  80. package/src/resources/extensions/gsd/index.ts +21 -16
  81. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  82. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  83. package/src/resources/extensions/gsd/preferences-types.ts +4 -4
  84. package/src/resources/extensions/gsd/preferences-validation.ts +50 -10
  85. package/src/resources/extensions/gsd/preferences.ts +3 -2
  86. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  87. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  88. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  89. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  90. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  91. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  92. package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  93. package/src/resources/extensions/gsd/prompts/run-uat.md +27 -10
  94. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  95. package/src/resources/extensions/gsd/repo-identity.ts +20 -3
  96. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  97. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  98. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +122 -68
  99. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
  100. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  101. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  102. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  103. package/src/resources/extensions/gsd/tests/run-uat.test.ts +11 -3
  104. package/src/resources/extensions/gsd/types.ts +0 -1
  105. package/src/resources/extensions/mcp-client/index.ts +17 -1
  106. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  107. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  108. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  109. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  110. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  111. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  112. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  113. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  114. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  115. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  116. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  117. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -5,12 +5,12 @@
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
15
  import type { AutoSession } from "./auto/session.js";
16
16
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
@@ -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,15 +38,19 @@ 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;
38
43
 
39
- /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
40
- * handled inline because it requires break/pause/stop control flow. */
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. */
41
47
  const BUDGET_THRESHOLDS: Array<{
42
48
  pct: number;
43
49
  label: string;
44
- notifyLevel: "info" | "warning";
45
- cmuxLevel: "progress" | "warning";
50
+ notifyLevel: "info" | "warning" | "error";
51
+ cmuxLevel: "progress" | "warning" | "error";
46
52
  }> = [
53
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
47
54
  { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
48
55
  { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
49
56
  { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
@@ -67,17 +74,15 @@ export interface UnitResult {
67
74
  event?: AgentEndEvent;
68
75
  }
69
76
 
70
- // ─── Session-scoped promise state ───────────────────────────────────────────
77
+ // ─── Per-unit one-shot promise state ────────────────────────────────────────
71
78
  //
72
- // pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level)
73
- // 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.
74
83
 
75
- /**
76
- * The singleton session reference used by resolveAgentEnd. Set by autoLoop
77
- * on entry so that the agent_end handler in index.ts can resolve the correct
78
- * session's promise without needing a direct reference to `s`.
79
- */
80
- let _activeSession: AutoSession | null = null;
84
+ let _currentResolve: ((result: UnitResult) => void) | null = null;
85
+ let _sessionSwitchInFlight = false;
81
86
 
82
87
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
83
88
 
@@ -86,61 +91,105 @@ let _activeSession: AutoSession | null = null;
86
91
  * in-flight unit promise. One-shot: the resolver is nulled before calling
87
92
  * to prevent double-resolution from model fallback retries.
88
93
  *
89
- * If no pendingResolve exists (event arrived between loop iterations),
90
- * 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.
91
96
  */
92
97
  export function resolveAgentEnd(event: AgentEndEvent): void {
93
- const s = _activeSession;
94
- if (!s) {
95
- debugLog("resolveAgentEnd", {
96
- status: "no-active-session",
97
- warning: "agent_end with no active loop session",
98
- });
98
+ if (_sessionSwitchInFlight) {
99
+ debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
99
100
  return;
100
101
  }
101
-
102
- if (s.pendingResolve) {
102
+ if (_currentResolve) {
103
103
  debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
104
- const r = s.pendingResolve;
105
- s.pendingResolve = null;
104
+ const r = _currentResolve;
105
+ _currentResolve = null;
106
106
  r({ status: "completed", event });
107
107
  } else {
108
- // Queue the event so the next runUnit picks it up immediately
109
108
  debugLog("resolveAgentEnd", {
110
- status: "queued",
111
- queueLength: s.pendingAgentEndQueue.length + 1,
112
- unitId: s.currentUnit?.id,
113
- warning:
114
- "agent_end arrived between loop iterations — queued for next runUnit",
109
+ status: "no-pending-resolve",
110
+ warning: "agent_end with no pending unit",
115
111
  });
116
- s.pendingAgentEndQueue.push({ ...event, unitId: s.currentUnit?.id });
117
112
  }
118
113
  }
119
114
 
120
115
  export function isSessionSwitchInFlight(): boolean {
121
- return _activeSession?.sessionSwitchInFlight ?? false;
116
+ return _sessionSwitchInFlight;
122
117
  }
123
118
 
124
119
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
125
120
 
126
121
  /**
127
- * Reset session promise state. Only exported for test cleanup — production code
128
- * should never call this.
122
+ * Reset module-level promise state. Only exported for test cleanup —
123
+ * production code should never call this.
129
124
  */
130
125
  export function _resetPendingResolve(): void {
131
- if (_activeSession) {
132
- _activeSession.pendingResolve = null;
133
- _activeSession.pendingAgentEndQueue = [];
134
- }
135
- _activeSession = null;
126
+ _currentResolve = null;
127
+ _sessionSwitchInFlight = false;
136
128
  }
137
129
 
138
130
  /**
139
- * Set the active session for resolveAgentEnd. Only exported for test setup —
140
- * 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.
141
133
  */
142
- export function _setActiveSession(session: AutoSession | null): void {
143
- _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;
144
193
  }
145
194
 
146
195
  // ─── runUnit ─────────────────────────────────────────────────────────────────
@@ -160,68 +209,18 @@ export async function runUnit(
160
209
  unitType: string,
161
210
  unitId: string,
162
211
  prompt: string,
163
- _prefs: GSDPreferences | undefined,
164
212
  ): Promise<UnitResult> {
165
213
  debugLog("runUnit", { phase: "start", unitType, unitId });
166
214
 
167
- // ── Drain queued events from error-recovery retries ──
168
- // If an agent_end arrived between iterations (e.g. from a model fallback
169
- // sendMessage retry), consume it immediately instead of creating a new promise.
170
- // Cap queue to 3 entries to prevent unbounded growth from stale events.
171
- if (s.pendingAgentEndQueue.length > 3) {
172
- debugLog("runUnit", {
173
- phase: "queue-overflow",
174
- dropped: s.pendingAgentEndQueue.length - 1,
175
- unitType,
176
- unitId,
177
- });
178
- s.pendingAgentEndQueue = [
179
- s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1]!,
180
- ];
181
- }
182
- if (s.pendingAgentEndQueue.length > 0) {
183
- // Find an event matching this unit; discard stale events from other units
184
- const matchIdx = s.pendingAgentEndQueue.findIndex(
185
- (e) => !e.unitId || e.unitId === unitId,
186
- );
187
- if (matchIdx >= 0) {
188
- // Discard any stale events before the match
189
- if (matchIdx > 0) {
190
- debugLog("runUnit", {
191
- phase: "discarded-stale-events",
192
- count: matchIdx,
193
- unitType,
194
- unitId,
195
- });
196
- }
197
- const queued = s.pendingAgentEndQueue.splice(0, matchIdx + 1).pop()!;
198
- debugLog("runUnit", {
199
- phase: "drained-queued-event",
200
- unitType,
201
- unitId,
202
- queueRemaining: s.pendingAgentEndQueue.length,
203
- });
204
- return { status: "completed", event: queued };
205
- }
206
- // No matching event — discard all stale events and proceed to new session
207
- debugLog("runUnit", {
208
- phase: "discarded-all-stale-events",
209
- count: s.pendingAgentEndQueue.length,
210
- unitType,
211
- unitId,
212
- });
213
- s.pendingAgentEndQueue = [];
214
- }
215
-
216
215
  // ── Session creation with timeout ──
217
216
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
218
217
 
219
218
  let sessionResult: { cancelled: boolean };
220
219
  let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
221
- s.sessionSwitchInFlight = true;
220
+ _sessionSwitchInFlight = true;
222
221
  try {
223
222
  const sessionPromise = s.cmdCtx!.newSession().finally(() => {
224
- s.sessionSwitchInFlight = false;
223
+ _sessionSwitchInFlight = false;
225
224
  });
226
225
  const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
227
226
  sessionTimeoutHandle = setTimeout(
@@ -253,11 +252,12 @@ export async function runUnit(
253
252
  return { status: "cancelled" };
254
253
  }
255
254
 
256
- // ── Create the agent_end promise (session-scoped) ──
255
+ // ── Create the agent_end promise (per-unit one-shot) ──
257
256
  // This happens after newSession completes so session-switch agent_end events
258
257
  // from the previous session cannot resolve the new unit.
258
+ _sessionSwitchInFlight = false;
259
259
  const unitPromise = new Promise<UnitResult>((resolve) => {
260
- s.pendingResolve = resolve;
260
+ _currentResolve = resolve;
261
261
  });
262
262
 
263
263
  // Ensure cwd matches basePath before dispatch (#1389).
@@ -552,6 +552,96 @@ export interface LoopDeps {
552
552
  getSessionFile: (ctx: ExtensionContext) => string;
553
553
  }
554
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
+
555
645
  // ─── autoLoop ────────────────────────────────────────────────────────────────
556
646
 
557
647
  /**
@@ -569,10 +659,11 @@ export async function autoLoop(
569
659
  deps: LoopDeps,
570
660
  ): Promise<void> {
571
661
  debugLog("autoLoop", { phase: "enter" });
572
- _activeSession = s;
573
662
  let iteration = 0;
574
- let lastDerivedUnit = "";
575
- 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;
576
667
 
577
668
  let consecutiveErrors = 0;
578
669
 
@@ -601,6 +692,7 @@ export async function autoLoop(
601
692
 
602
693
  try {
603
694
  // ── Blanket try/catch: one bad iteration must not kill the session
695
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
604
696
 
605
697
  const sessionLockBase = deps.lockBase();
606
698
  if (sessionLockBase) {
@@ -673,7 +765,7 @@ export async function autoLoop(
673
765
 
674
766
  // Derive state
675
767
  let state = await deps.deriveState(s.basePath);
676
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
768
+ deps.syncCmuxSidebar(prefs, state);
677
769
  let mid = state.activeMilestone?.id;
678
770
  let midTitle = state.activeMilestone?.title;
679
771
  debugLog("autoLoop", {
@@ -696,68 +788,18 @@ export async function autoLoop(
696
788
  "milestone",
697
789
  );
698
790
  deps.logCmuxEvent(
699
- deps.loadEffectiveGSDPreferences()?.preferences,
791
+ prefs,
700
792
  `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
701
793
  "success",
702
794
  );
703
795
 
704
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
796
+ const vizPrefs = prefs;
705
797
  if (vizPrefs?.auto_visualize) {
706
798
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
707
799
  }
708
800
  if (vizPrefs?.auto_report !== false) {
709
801
  try {
710
- const { loadVisualizerData } = await import("./visualizer-data.js");
711
- const { generateHtmlReport } = await import("./export-html.js");
712
- const { writeReportSnapshot } = await import("./reports.js");
713
- const { basename } = await import("node:path");
714
- const snapData = await loadVisualizerData(s.basePath);
715
- const completedMs = snapData.milestones.find(
716
- (m: { id: string }) => m.id === s.currentMilestoneId,
717
- );
718
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
719
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
720
- const projName = basename(s.basePath);
721
- const doneSlices = snapData.milestones.reduce(
722
- (acc: number, m: { slices: { done: boolean }[] }) =>
723
- acc +
724
- m.slices.filter((sl: { done: boolean }) => sl.done).length,
725
- 0,
726
- );
727
- const totalSlices = snapData.milestones.reduce(
728
- (acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
729
- 0,
730
- );
731
- const outPath = writeReportSnapshot({
732
- basePath: s.basePath,
733
- html: generateHtmlReport(snapData, {
734
- projectName: projName,
735
- projectPath: s.basePath,
736
- gsdVersion,
737
- milestoneId: s.currentMilestoneId,
738
- indexRelPath: "index.html",
739
- }),
740
- milestoneId: s.currentMilestoneId!,
741
- milestoneTitle: msTitle,
742
- kind: "milestone",
743
- projectName: projName,
744
- projectPath: s.basePath,
745
- gsdVersion,
746
- totalCost: snapData.totals?.cost ?? 0,
747
- totalTokens: snapData.totals?.tokens.total ?? 0,
748
- totalDuration: snapData.totals?.duration ?? 0,
749
- doneSlices,
750
- totalSlices,
751
- doneMilestones: snapData.milestones.filter(
752
- (m: { status: string }) => m.status === "complete",
753
- ).length,
754
- totalMilestones: snapData.milestones.length,
755
- phase: snapData.phase,
756
- });
757
- ctx.ui.notify(
758
- `Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`,
759
- "info",
760
- );
802
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
761
803
  } catch (err) {
762
804
  ctx.ui.notify(
763
805
  `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -770,8 +812,8 @@ export async function autoLoop(
770
812
  s.unitDispatchCount.clear();
771
813
  s.unitRecoveryCount.clear();
772
814
  s.unitLifetimeDispatches.clear();
773
- lastDerivedUnit = "";
774
- sameUnitCount = 0;
815
+ recentUnits.length = 0;
816
+ stuckRecoveryAttempts = 0;
775
817
 
776
818
  // Worktree lifecycle on milestone transition — merge current, enter next
777
819
  deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
@@ -784,9 +826,7 @@ export async function autoLoop(
784
826
  if (mid) {
785
827
  if (deps.getIsolationMode() !== "none") {
786
828
  deps.captureIntegrationBranch(s.basePath, mid, {
787
- commitDocs:
788
- deps.loadEffectiveGSDPreferences()?.preferences?.git
789
- ?.commit_docs,
829
+ commitDocs: prefs?.git?.commit_docs,
790
830
  });
791
831
  }
792
832
  deps.resolver.enterMilestone(mid, ctx.ui);
@@ -838,7 +878,7 @@ export async function autoLoop(
838
878
  "milestone",
839
879
  );
840
880
  deps.logCmuxEvent(
841
- deps.loadEffectiveGSDPreferences()?.preferences,
881
+ prefs,
842
882
  "All milestones complete.",
843
883
  "success",
844
884
  );
@@ -860,7 +900,7 @@ export async function autoLoop(
860
900
  await deps.stopAuto(ctx, pi, blockerMsg);
861
901
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
862
902
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
863
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
903
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
864
904
  } else {
865
905
  const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
866
906
  const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@@ -895,20 +935,10 @@ export async function autoLoop(
895
935
  }
896
936
 
897
937
  if (!mid || !midTitle) {
898
- if (s.currentUnit) {
899
- await deps.closeoutUnit(
900
- ctx,
901
- s.basePath,
902
- s.currentUnit.type,
903
- s.currentUnit.id,
904
- s.currentUnit.startedAt,
905
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
906
- );
907
- }
908
938
  const noMilestoneReason = !mid
909
939
  ? "No active milestone after merge reconciliation"
910
940
  : `Milestone ${mid} has no title after reconciliation`;
911
- await deps.stopAuto(ctx, pi, noMilestoneReason);
941
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
912
942
  debugLog("autoLoop", {
913
943
  phase: "exit",
914
944
  reason: "no-milestone-after-reconciliation",
@@ -918,17 +948,7 @@ export async function autoLoop(
918
948
 
919
949
  // Terminal: complete
920
950
  if (state.phase === "complete") {
921
- if (s.currentUnit) {
922
- await deps.closeoutUnit(
923
- ctx,
924
- s.basePath,
925
- s.currentUnit.type,
926
- s.currentUnit.id,
927
- s.currentUnit.startedAt,
928
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
929
- );
930
- }
931
- // Milestone merge on complete
951
+ // Milestone merge on complete (before closeout so branch state is clean)
932
952
  if (s.currentMilestoneId) {
933
953
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
934
954
  }
@@ -939,40 +959,28 @@ export async function autoLoop(
939
959
  "milestone",
940
960
  );
941
961
  deps.logCmuxEvent(
942
- deps.loadEffectiveGSDPreferences()?.preferences,
962
+ prefs,
943
963
  `Milestone ${mid} complete.`,
944
964
  "success",
945
965
  );
946
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
966
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
947
967
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
948
968
  break;
949
969
  }
950
970
 
951
971
  // Terminal: blocked
952
972
  if (state.phase === "blocked") {
953
- if (s.currentUnit) {
954
- await deps.closeoutUnit(
955
- ctx,
956
- s.basePath,
957
- s.currentUnit.type,
958
- s.currentUnit.id,
959
- s.currentUnit.startedAt,
960
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
961
- );
962
- }
963
973
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
964
- await deps.stopAuto(ctx, pi, blockerMsg);
974
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
965
975
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
966
976
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
967
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
977
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
968
978
  debugLog("autoLoop", { phase: "exit", reason: "blocked" });
969
979
  break;
970
980
  }
971
981
 
972
982
  // ── Phase 2: Guards ─────────────────────────────────────────────────
973
983
 
974
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
975
-
976
984
  // Budget ceiling guard
977
985
  const budgetCeiling = prefs?.budget_ceiling;
978
986
  if (budgetCeiling !== undefined && budgetCeiling > 0) {
@@ -992,38 +1000,39 @@ export async function autoLoop(
992
1000
  budgetPct,
993
1001
  );
994
1002
 
995
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
996
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1003
+ // Data-driven threshold check loop descending, fire first match
1004
+ const threshold = BUDGET_THRESHOLDS.find(
1005
+ (t) => newBudgetAlertLevel >= t.pct,
1006
+ );
1007
+ if (threshold) {
997
1008
  s.lastBudgetAlertLevel =
998
1009
  newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
999
- if (budgetEnforcementAction === "halt") {
1000
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
1001
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1002
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1003
- break;
1004
- }
1005
- if (budgetEnforcementAction === "pause") {
1006
- ctx.ui.notify(
1007
- `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1008
- "warning",
1009
- );
1010
+
1011
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
1012
+ // 100% special enforcement logic (halt/pause/warn)
1013
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
1014
+ if (budgetEnforcementAction === "halt") {
1015
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
1016
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
1017
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
1018
+ break;
1019
+ }
1020
+ if (budgetEnforcementAction === "pause") {
1021
+ ctx.ui.notify(
1022
+ `${msg} Pausing auto-mode — /gsd auto to override and continue.`,
1023
+ "warning",
1024
+ );
1025
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1026
+ deps.logCmuxEvent(prefs, msg, "warning");
1027
+ await deps.pauseAuto(ctx, pi);
1028
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1029
+ break;
1030
+ }
1031
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
1010
1032
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1011
1033
  deps.logCmuxEvent(prefs, msg, "warning");
1012
- await deps.pauseAuto(ctx, pi);
1013
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
1014
- break;
1015
- }
1016
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
1017
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
1018
- deps.logCmuxEvent(prefs, msg, "warning");
1019
- } else {
1020
- // Data-driven 75/80/90% threshold notifications
1021
- const threshold = BUDGET_THRESHOLDS.find(
1022
- (t) => newBudgetAlertLevel === t.pct,
1023
- );
1024
- if (threshold) {
1025
- s.lastBudgetAlertLevel =
1026
- newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
1034
+ } else if (threshold.pct < 100) {
1035
+ // Sub-100% simple notification
1027
1036
  const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
1028
1037
  ctx.ui.notify(msg, threshold.notifyLevel);
1029
1038
  deps.sendDesktopNotification(
@@ -1033,9 +1042,9 @@ export async function autoLoop(
1033
1042
  "budget",
1034
1043
  );
1035
1044
  deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
1036
- } else if (budgetAlertLevel === 0) {
1037
- s.lastBudgetAlertLevel = 0;
1038
1045
  }
1046
+ } else if (budgetAlertLevel === 0) {
1047
+ s.lastBudgetAlertLevel = 0;
1039
1048
  }
1040
1049
  } else {
1041
1050
  s.lastBudgetAlertLevel = 0;
@@ -1110,17 +1119,7 @@ export async function autoLoop(
1110
1119
  });
1111
1120
 
1112
1121
  if (dispatchResult.action === "stop") {
1113
- if (s.currentUnit) {
1114
- await deps.closeoutUnit(
1115
- ctx,
1116
- s.basePath,
1117
- s.currentUnit.type,
1118
- s.currentUnit.id,
1119
- s.currentUnit.startedAt,
1120
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1121
- );
1122
- }
1123
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
1122
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
1124
1123
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1125
1124
  break;
1126
1125
  }
@@ -1136,71 +1135,79 @@ export async function autoLoop(
1136
1135
  let prompt = dispatchResult.prompt;
1137
1136
  const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1138
1137
 
1139
- // ── Same-unit stuck counter with graduated recovery ──
1138
+ // ── Sliding-window stuck detection with graduated recovery ──
1140
1139
  const derivedKey = `${unitType}/${unitId}`;
1141
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
1142
- sameUnitCount++;
1143
- debugLog("autoLoop", {
1144
- phase: "stuck-check",
1145
- unitType,
1146
- unitId,
1147
- sameUnitCount,
1148
- });
1149
1140
 
1150
- if (sameUnitCount === 3) {
1151
- // Level 1: try verifying the artifact — maybe it was written but not detected
1152
- const artifactExists = deps.verifyExpectedArtifact(
1141
+ if (!s.pendingVerificationRetry) {
1142
+ recentUnits.push({ key: derivedKey });
1143
+ if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift();
1144
+
1145
+ const stuckSignal = detectStuck(recentUnits);
1146
+ if (stuckSignal) {
1147
+ debugLog("autoLoop", {
1148
+ phase: "stuck-check",
1153
1149
  unitType,
1154
1150
  unitId,
1155
- s.basePath,
1156
- );
1157
- if (artifactExists) {
1151
+ reason: stuckSignal.reason,
1152
+ recoveryAttempts: stuckRecoveryAttempts,
1153
+ });
1154
+
1155
+ if (stuckRecoveryAttempts === 0) {
1156
+ // Level 1: try verifying the artifact, then cache invalidation + retry
1157
+ stuckRecoveryAttempts++;
1158
+ const artifactExists = deps.verifyExpectedArtifact(
1159
+ unitType,
1160
+ unitId,
1161
+ s.basePath,
1162
+ );
1163
+ if (artifactExists) {
1164
+ debugLog("autoLoop", {
1165
+ phase: "stuck-recovery",
1166
+ level: 1,
1167
+ action: "artifact-found",
1168
+ });
1169
+ ctx.ui.notify(
1170
+ `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1171
+ "info",
1172
+ );
1173
+ deps.invalidateAllCaches();
1174
+ continue;
1175
+ }
1176
+ ctx.ui.notify(
1177
+ `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
1178
+ "warning",
1179
+ );
1180
+ deps.invalidateAllCaches();
1181
+ } else {
1182
+ // Level 2: hard stop — genuinely stuck
1158
1183
  debugLog("autoLoop", {
1159
- phase: "stuck-recovery",
1160
- level: 1,
1161
- action: "artifact-found",
1184
+ phase: "stuck-detected",
1185
+ unitType,
1186
+ unitId,
1187
+ reason: stuckSignal.reason,
1162
1188
  });
1189
+ await deps.stopAuto(
1190
+ ctx,
1191
+ pi,
1192
+ `Stuck: ${stuckSignal.reason}`,
1193
+ );
1163
1194
  ctx.ui.notify(
1164
- `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
1165
- "info",
1195
+ `Stuck on ${unitType} ${unitId} ${stuckSignal.reason}. The expected artifact was not written.`,
1196
+ "error",
1166
1197
  );
1167
- deps.invalidateAllCaches();
1168
- continue;
1198
+ break;
1199
+ }
1200
+ } else {
1201
+ // Progress detected — reset recovery counter
1202
+ if (stuckRecoveryAttempts > 0) {
1203
+ debugLog("autoLoop", {
1204
+ phase: "stuck-counter-reset",
1205
+ from: recentUnits[recentUnits.length - 2]?.key ?? "",
1206
+ to: derivedKey,
1207
+ });
1208
+ stuckRecoveryAttempts = 0;
1169
1209
  }
1170
- ctx.ui.notify(
1171
- `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
1172
- "warning",
1173
- );
1174
- deps.invalidateAllCaches();
1175
- } else if (sameUnitCount === 5) {
1176
- // Level 2: hard stop — genuinely stuck
1177
- debugLog("autoLoop", {
1178
- phase: "stuck-detected",
1179
- unitType,
1180
- unitId,
1181
- sameUnitCount,
1182
- });
1183
- await deps.stopAuto(
1184
- ctx,
1185
- pi,
1186
- `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
1187
- );
1188
- ctx.ui.notify(
1189
- `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
1190
- "error",
1191
- );
1192
- break;
1193
- }
1194
- } else {
1195
- if (derivedKey !== lastDerivedUnit) {
1196
- debugLog("autoLoop", {
1197
- phase: "stuck-counter-reset",
1198
- from: lastDerivedUnit,
1199
- to: derivedKey,
1200
- });
1201
1210
  }
1202
- lastDerivedUnit = derivedKey;
1203
- sameUnitCount = 0;
1204
1211
  }
1205
1212
 
1206
1213
  // Pre-dispatch hooks
@@ -1267,61 +1274,6 @@ export async function autoLoop(
1267
1274
  );
1268
1275
  const previousTier = s.currentUnitRouting?.tier;
1269
1276
 
1270
- // Closeout previous unit
1271
- if (s.currentUnit) {
1272
- await deps.closeoutUnit(
1273
- ctx,
1274
- s.basePath,
1275
- s.currentUnit.type,
1276
- s.currentUnit.id,
1277
- s.currentUnit.startedAt,
1278
- deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
1279
- );
1280
-
1281
- if (s.currentUnitRouting) {
1282
- const isRetry =
1283
- s.currentUnit.type === unitType && s.currentUnit.id === unitId;
1284
- deps.recordOutcome(
1285
- s.currentUnit.type,
1286
- s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1287
- !isRetry,
1288
- );
1289
- }
1290
-
1291
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
1292
- const incomingKey = `${unitType}/${unitId}`;
1293
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
1294
- const artifactVerified =
1295
- isHookUnit ||
1296
- deps.verifyExpectedArtifact(
1297
- s.currentUnit.type,
1298
- s.currentUnit.id,
1299
- s.basePath,
1300
- );
1301
- if (closeoutKey !== incomingKey && artifactVerified) {
1302
- s.completedUnits.push({
1303
- type: s.currentUnit.type,
1304
- id: s.currentUnit.id,
1305
- startedAt: s.currentUnit.startedAt,
1306
- finishedAt: Date.now(),
1307
- });
1308
- if (s.completedUnits.length > 200) {
1309
- s.completedUnits = s.completedUnits.slice(-200);
1310
- }
1311
- deps.clearUnitRuntimeRecord(
1312
- s.basePath,
1313
- s.currentUnit.type,
1314
- s.currentUnit.id,
1315
- );
1316
- s.unitDispatchCount.delete(
1317
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1318
- );
1319
- s.unitRecoveryCount.delete(
1320
- `${s.currentUnit.type}/${s.currentUnit.id}`,
1321
- );
1322
- }
1323
- }
1324
-
1325
1277
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1326
1278
  deps.captureAvailableSkills();
1327
1279
  deps.writeUnitRuntimeRecord(
@@ -1348,7 +1300,6 @@ export async function autoLoop(
1348
1300
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
1349
1301
 
1350
1302
  // Prompt injection
1351
- const MAX_RECOVERY_CHARS = 50_000;
1352
1303
  let finalPrompt = prompt;
1353
1304
 
1354
1305
  if (s.pendingVerificationRetry) {
@@ -1393,7 +1344,7 @@ export async function autoLoop(
1393
1344
  s.lastBaselineCharCount = undefined;
1394
1345
  if (deps.isDbAvailable()) {
1395
1346
  try {
1396
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
1347
+ const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
1397
1348
  const [decisionsContent, requirementsContent, projectContent] =
1398
1349
  await Promise.all([
1399
1350
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
@@ -1479,7 +1430,6 @@ export async function autoLoop(
1479
1430
  unitType,
1480
1431
  unitId,
1481
1432
  finalPrompt,
1482
- prefs,
1483
1433
  );
1484
1434
  debugLog("autoLoop", {
1485
1435
  phase: "runUnit-end",
@@ -1489,6 +1439,23 @@ export async function autoLoop(
1489
1439
  status: unitResult.status,
1490
1440
  });
1491
1441
 
1442
+ // Tag the most recent window entry with error info for stuck detection
1443
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
1444
+ const lastEntry = recentUnits[recentUnits.length - 1];
1445
+ if (lastEntry) {
1446
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
1447
+ }
1448
+ } else if (unitResult.event?.messages?.length) {
1449
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
1450
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
1451
+ if (/error|fail|exception/i.test(msgStr)) {
1452
+ const lastEntry = recentUnits[recentUnits.length - 1];
1453
+ if (lastEntry) {
1454
+ lastEntry.error = msgStr.slice(0, 200);
1455
+ }
1456
+ }
1457
+ }
1458
+
1492
1459
  if (unitResult.status === "cancelled") {
1493
1460
  ctx.ui.notify(
1494
1461
  `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
@@ -1499,6 +1466,52 @@ export async function autoLoop(
1499
1466
  break;
1500
1467
  }
1501
1468
 
1469
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
1470
+ // Run right after runUnit() returns so telemetry is never lost to a
1471
+ // crash between iterations.
1472
+ await deps.closeoutUnit(
1473
+ ctx,
1474
+ s.basePath,
1475
+ unitType,
1476
+ unitId,
1477
+ s.currentUnit.startedAt,
1478
+ deps.buildSnapshotOpts(unitType, unitId),
1479
+ );
1480
+
1481
+ if (s.currentUnitRouting) {
1482
+ deps.recordOutcome(
1483
+ unitType,
1484
+ s.currentUnitRouting.tier as "light" | "standard" | "heavy",
1485
+ true, // success assumed; dispatch will re-dispatch if artifact missing
1486
+ );
1487
+ }
1488
+
1489
+ const isHookUnit = unitType.startsWith("hook/");
1490
+ const artifactVerified =
1491
+ isHookUnit ||
1492
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
1493
+ if (artifactVerified) {
1494
+ s.completedUnits.push({
1495
+ type: unitType,
1496
+ id: unitId,
1497
+ startedAt: s.currentUnit.startedAt,
1498
+ finishedAt: Date.now(),
1499
+ });
1500
+ if (s.completedUnits.length > 200) {
1501
+ s.completedUnits = s.completedUnits.slice(-200);
1502
+ }
1503
+ // Flush completed-units to disk so the record survives crashes
1504
+ try {
1505
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
1506
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
1507
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
1508
+ } catch { /* non-fatal: disk flush failure */ }
1509
+
1510
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
1511
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
1512
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
1513
+ }
1514
+
1502
1515
  // ── Phase 5: Finalize ───────────────────────────────────────────────
1503
1516
 
1504
1517
  debugLog("autoLoop", { phase: "finalize", iteration });
@@ -1651,7 +1664,6 @@ export async function autoLoop(
1651
1664
  item.unitType,
1652
1665
  item.unitId,
1653
1666
  item.prompt,
1654
- prefs,
1655
1667
  );
1656
1668
  deps.clearUnitTimeout();
1657
1669
 
@@ -1665,6 +1677,16 @@ export async function autoLoop(
1665
1677
  break;
1666
1678
  }
1667
1679
 
1680
+ // Immediate closeout for sidecar unit
1681
+ await deps.closeoutUnit(
1682
+ ctx,
1683
+ s.basePath,
1684
+ item.unitType,
1685
+ item.unitId,
1686
+ sidecarStartedAt,
1687
+ deps.buildSnapshotOpts(item.unitType, item.unitId),
1688
+ );
1689
+
1668
1690
  // Run pre-verification for the sidecar unit (lightweight path)
1669
1691
  const sidecarPreOpts: PreVerificationOpts = item.kind === "hook"
1670
1692
  ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
@@ -1759,6 +1781,6 @@ export async function autoLoop(
1759
1781
  }
1760
1782
  }
1761
1783
 
1762
- _activeSession = null;
1784
+ _currentResolve = null;
1763
1785
  debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
1764
1786
  }