gsd-pi 2.38.0-dev.63ad7e5 → 2.38.0-dev.785052f

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 (143) hide show
  1. package/README.md +15 -11
  2. package/dist/resource-loader.js +34 -1
  3. package/dist/resources/extensions/browser-tools/index.js +3 -1
  4. package/dist/resources/extensions/browser-tools/tools/verify.js +97 -0
  5. package/dist/resources/extensions/github-sync/cli.js +284 -0
  6. package/dist/resources/extensions/github-sync/index.js +73 -0
  7. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  8. package/dist/resources/extensions/github-sync/sync.js +424 -0
  9. package/dist/resources/extensions/github-sync/templates.js +118 -0
  10. package/dist/resources/extensions/github-sync/types.js +7 -0
  11. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  12. package/dist/resources/extensions/gsd/auto-loop.js +593 -516
  13. package/dist/resources/extensions/gsd/auto-post-unit.js +28 -3
  14. package/dist/resources/extensions/gsd/auto-prompts.js +197 -19
  15. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  16. package/dist/resources/extensions/gsd/commands.js +2 -1
  17. package/dist/resources/extensions/gsd/doctor-providers.js +3 -0
  18. package/dist/resources/extensions/gsd/doctor.js +20 -1
  19. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  20. package/dist/resources/extensions/gsd/files.js +46 -7
  21. package/dist/resources/extensions/gsd/git-service.js +30 -12
  22. package/dist/resources/extensions/gsd/gitignore.js +16 -3
  23. package/dist/resources/extensions/gsd/guided-flow.js +149 -38
  24. package/dist/resources/extensions/gsd/health-widget-core.js +32 -70
  25. package/dist/resources/extensions/gsd/health-widget.js +3 -86
  26. package/dist/resources/extensions/gsd/index.js +22 -19
  27. package/dist/resources/extensions/gsd/migrate-external.js +18 -1
  28. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  29. package/dist/resources/extensions/gsd/paths.js +3 -0
  30. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  31. package/dist/resources/extensions/gsd/preferences-validation.js +58 -0
  32. package/dist/resources/extensions/gsd/preferences.js +20 -9
  33. package/dist/resources/extensions/gsd/prompt-loader.js +6 -2
  34. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  35. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -1
  37. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  38. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  39. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  40. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  41. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  42. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  43. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  44. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  45. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  46. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  47. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  48. package/dist/resources/extensions/gsd/prompts/run-uat.md +3 -1
  49. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  50. package/dist/resources/extensions/gsd/state.js +41 -22
  51. package/dist/resources/extensions/gsd/templates/runtime.md +21 -0
  52. package/dist/resources/extensions/gsd/templates/task-plan.md +3 -0
  53. package/dist/resources/extensions/mcp-client/index.js +14 -1
  54. package/dist/resources/extensions/remote-questions/status.js +4 -2
  55. package/dist/resources/extensions/remote-questions/store.js +4 -2
  56. package/dist/resources/extensions/shared/frontmatter.js +1 -1
  57. package/package.json +1 -1
  58. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  59. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  60. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  61. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  63. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/skills.d.ts +1 -0
  65. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/skills.js +6 -1
  67. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  69. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/index.js +1 -1
  71. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  72. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  73. package/packages/pi-coding-agent/src/core/skills.ts +9 -1
  74. package/packages/pi-coding-agent/src/index.ts +1 -0
  75. package/src/resources/extensions/browser-tools/index.ts +3 -0
  76. package/src/resources/extensions/browser-tools/tools/verify.ts +117 -0
  77. package/src/resources/extensions/github-sync/cli.ts +364 -0
  78. package/src/resources/extensions/github-sync/index.ts +93 -0
  79. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  80. package/src/resources/extensions/github-sync/sync.ts +556 -0
  81. package/src/resources/extensions/github-sync/templates.ts +183 -0
  82. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  83. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  84. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  85. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  86. package/src/resources/extensions/github-sync/types.ts +47 -0
  87. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  88. package/src/resources/extensions/gsd/auto-loop.ts +472 -434
  89. package/src/resources/extensions/gsd/auto-post-unit.ts +29 -3
  90. package/src/resources/extensions/gsd/auto-prompts.ts +242 -19
  91. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  92. package/src/resources/extensions/gsd/commands.ts +2 -2
  93. package/src/resources/extensions/gsd/doctor-providers.ts +4 -0
  94. package/src/resources/extensions/gsd/doctor.ts +22 -1
  95. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  96. package/src/resources/extensions/gsd/files.ts +49 -9
  97. package/src/resources/extensions/gsd/git-service.ts +44 -10
  98. package/src/resources/extensions/gsd/gitignore.ts +17 -3
  99. package/src/resources/extensions/gsd/guided-flow.ts +177 -44
  100. package/src/resources/extensions/gsd/health-widget-core.ts +28 -80
  101. package/src/resources/extensions/gsd/health-widget.ts +3 -89
  102. package/src/resources/extensions/gsd/index.ts +21 -16
  103. package/src/resources/extensions/gsd/migrate-external.ts +18 -1
  104. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  105. package/src/resources/extensions/gsd/paths.ts +4 -0
  106. package/src/resources/extensions/gsd/preferences-types.ts +4 -0
  107. package/src/resources/extensions/gsd/preferences-validation.ts +50 -0
  108. package/src/resources/extensions/gsd/preferences.ts +23 -9
  109. package/src/resources/extensions/gsd/prompt-loader.ts +7 -2
  110. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  111. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  112. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -1
  113. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  114. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  115. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  116. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  117. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +1 -1
  118. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  119. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  120. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  121. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  122. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  123. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  124. package/src/resources/extensions/gsd/prompts/run-uat.md +3 -1
  125. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  126. package/src/resources/extensions/gsd/state.ts +38 -20
  127. package/src/resources/extensions/gsd/templates/runtime.md +21 -0
  128. package/src/resources/extensions/gsd/templates/task-plan.md +3 -0
  129. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +111 -37
  130. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +4 -3
  131. package/src/resources/extensions/gsd/tests/derive-state.test.ts +43 -0
  132. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +50 -0
  133. package/src/resources/extensions/gsd/tests/health-widget.test.ts +16 -54
  134. package/src/resources/extensions/gsd/tests/parsers.test.ts +131 -14
  135. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +209 -0
  136. package/src/resources/extensions/gsd/tests/run-uat.test.ts +5 -1
  137. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +140 -0
  138. package/src/resources/extensions/gsd/types.ts +18 -0
  139. package/src/resources/extensions/gsd/verification-evidence.ts +16 -0
  140. package/src/resources/extensions/mcp-client/index.ts +17 -1
  141. package/src/resources/extensions/remote-questions/status.ts +4 -2
  142. package/src/resources/extensions/remote-questions/store.ts +4 -2
  143. package/src/resources/extensions/shared/frontmatter.ts +1 -1
@@ -9,8 +9,12 @@
9
9
  * (per-unit one-shot resolver) and `_sessionSwitchInFlight` (guard for
10
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,9 +22,13 @@ 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
- /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
22
- * handled inline because it requires break/pause/stop control flow. */
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. */
23
30
  const BUDGET_THRESHOLDS = [
31
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
24
32
  { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
25
33
  { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
26
34
  { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
@@ -79,6 +87,50 @@ export function _resetPendingResolve() {
79
87
  export function _setActiveSession(_session) {
80
88
  // No-op — kept for test backward compatibility
81
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;
133
+ }
82
134
  // ─── runUnit ─────────────────────────────────────────────────────────────────
83
135
  /**
84
136
  * Execute a single unit: create a new session, send the prompt, and await
@@ -88,7 +140,7 @@ export function _setActiveSession(_session) {
88
140
  * On session creation failure or timeout, returns { status: 'cancelled' }
89
141
  * without awaiting the promise.
90
142
  */
91
- export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
143
+ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
92
144
  debugLog("runUnit", { phase: "start", unitType, unitId });
93
145
  // ── Session creation with timeout ──
94
146
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
@@ -155,6 +207,60 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
155
207
  });
156
208
  return result;
157
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
+ }
158
264
  // ─── autoLoop ────────────────────────────────────────────────────────────────
159
265
  /**
160
266
  * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
@@ -167,8 +273,10 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
167
273
  export async function autoLoop(ctx, pi, s, deps) {
168
274
  debugLog("autoLoop", { phase: "enter" });
169
275
  let iteration = 0;
170
- let lastDerivedUnit = "";
171
- let sameUnitCount = 0;
276
+ // ── Sliding-window stuck detection ──
277
+ const recentUnits = [];
278
+ const STUCK_WINDOW_SIZE = 6;
279
+ let stuckRecoveryAttempts = 0;
172
280
  let consecutiveErrors = 0;
173
281
  while (s.active) {
174
282
  iteration++;
@@ -188,6 +296,18 @@ export async function autoLoop(ctx, pi, s, deps) {
188
296
  }
189
297
  try {
190
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
+ }
191
311
  const sessionLockBase = deps.lockBase();
192
312
  if (sessionLockBase) {
193
313
  const lockStatus = deps.validateSessionLock(sessionLockBase);
@@ -207,417 +327,436 @@ export async function autoLoop(ctx, pi, s, deps) {
207
327
  break;
208
328
  }
209
329
  }
210
- // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
211
- // Resource version guard
212
- const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
213
- if (staleMsg) {
214
- await deps.stopAuto(ctx, pi, staleMsg);
215
- debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
216
- break;
217
- }
218
- deps.invalidateAllCaches();
219
- s.lastPromptCharCount = undefined;
220
- s.lastBaselineCharCount = undefined;
221
- // Pre-dispatch health gate
222
- try {
223
- const healthGate = await deps.preDispatchHealthGate(s.basePath);
224
- if (healthGate.fixesApplied.length > 0) {
225
- ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
226
- }
227
- if (!healthGate.proceed) {
228
- ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
229
- await deps.pauseAuto(ctx, pi);
230
- 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" });
231
346
  break;
232
347
  }
233
- }
234
- catch {
235
- // Non-fatal
236
- }
237
- // Sync project root artifacts into worktree
238
- if (s.originalBasePath &&
239
- s.basePath !== s.originalBasePath &&
240
- s.currentMilestoneId) {
241
- deps.syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
242
- }
243
- // Derive state
244
- let state = await deps.deriveState(s.basePath);
245
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
246
- let mid = state.activeMilestone?.id;
247
- let midTitle = state.activeMilestone?.title;
248
- debugLog("autoLoop", {
249
- phase: "state-derived",
250
- iteration,
251
- mid,
252
- statePhase: state.phase,
253
- });
254
- // ── Milestone transition ────────────────────────────────────────────
255
- if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
256
- ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
257
- deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
258
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
259
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
260
- if (vizPrefs?.auto_visualize) {
261
- ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
262
- }
263
- if (vizPrefs?.auto_report !== false) {
264
- try {
265
- const { loadVisualizerData } = await import("./visualizer-data.js");
266
- const { generateHtmlReport } = await import("./export-html.js");
267
- const { writeReportSnapshot } = await import("./reports.js");
268
- const { basename } = await import("node:path");
269
- const snapData = await loadVisualizerData(s.basePath);
270
- const completedMs = snapData.milestones.find((m) => m.id === s.currentMilestoneId);
271
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
272
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
273
- const projName = basename(s.basePath);
274
- const doneSlices = snapData.milestones.reduce((acc, m) => acc +
275
- m.slices.filter((sl) => sl.done).length, 0);
276
- const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
277
- const outPath = writeReportSnapshot({
278
- basePath: s.basePath,
279
- html: generateHtmlReport(snapData, {
280
- projectName: projName,
281
- projectPath: s.basePath,
282
- gsdVersion,
283
- milestoneId: s.currentMilestoneId,
284
- indexRelPath: "index.html",
285
- }),
286
- milestoneId: s.currentMilestoneId,
287
- milestoneTitle: msTitle,
288
- kind: "milestone",
289
- projectName: projName,
290
- projectPath: s.basePath,
291
- gsdVersion,
292
- totalCost: snapData.totals?.cost ?? 0,
293
- totalTokens: snapData.totals?.tokens.total ?? 0,
294
- totalDuration: snapData.totals?.duration ?? 0,
295
- doneSlices,
296
- totalSlices,
297
- doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
298
- totalMilestones: snapData.milestones.length,
299
- phase: snapData.phase,
300
- });
301
- 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");
302
356
  }
303
- catch (err) {
304
- 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;
305
362
  }
306
363
  }
307
- // Reset dispatch counters for new milestone
308
- s.unitDispatchCount.clear();
309
- s.unitRecoveryCount.clear();
310
- s.unitLifetimeDispatches.clear();
311
- lastDerivedUnit = "";
312
- sameUnitCount = 0;
313
- // Worktree lifecycle on milestone transition — merge current, enter next
314
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
315
- 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
316
374
  state = await deps.deriveState(s.basePath);
375
+ deps.syncCmuxSidebar(prefs, state);
317
376
  mid = state.activeMilestone?.id;
318
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
+ }
319
442
  if (mid) {
320
- if (deps.getIsolationMode() !== "none") {
321
- deps.captureIntegrationBranch(s.basePath, mid, {
322
- commitDocs: deps.loadEffectiveGSDPreferences()?.preferences?.git
323
- ?.commit_docs,
324
- });
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`);
325
479
  }
326
- deps.resolver.enterMilestone(mid, ctx.ui);
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");
486
+ }
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;
327
495
  }
328
- else {
329
- // 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");
330
499
  }
331
- const pendingIds = state.registry
332
- .filter((m) => m.status !== "complete" && m.status !== "parked")
333
- .map((m) => m.id);
334
- deps.pruneQueueOrder(s.basePath, pendingIds);
335
- }
336
- if (mid) {
337
- s.currentMilestoneId = mid;
338
- deps.setActiveMilestoneId(s.basePath, mid);
339
- }
340
- // ── Terminal conditions ──────────────────────────────────────────────
341
- if (!mid) {
342
- if (s.currentUnit) {
343
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
344
- }
345
- const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
346
- if (incomplete.length === 0 && state.registry.length > 0) {
347
- // 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)
348
521
  if (s.currentMilestoneId) {
349
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
+ }
350
536
  }
351
- deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
352
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, "All milestones complete.", "success");
353
- await deps.stopAuto(ctx, pi, "All milestones complete");
354
- }
355
- else if (incomplete.length === 0 && state.registry.length === 0) {
356
- // Empty registry — no milestones visible, likely a path resolution bug
357
- const diag = `basePath=${s.basePath}, phase=${state.phase}`;
358
- ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
359
- 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;
360
542
  }
361
- else if (state.phase === "blocked") {
543
+ // Terminal: blocked
544
+ if (state.phase === "blocked") {
362
545
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
363
- await deps.stopAuto(ctx, pi, blockerMsg);
546
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
364
547
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
365
548
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
366
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
367
- }
368
- else {
369
- const ids = incomplete.map((m) => m.id).join(", ");
370
- const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
371
- ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
372
- await deps.stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
373
- }
374
- debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
375
- break;
376
- }
377
- if (!midTitle) {
378
- midTitle = mid;
379
- ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
380
- }
381
- // Mid-merge safety check
382
- if (deps.reconcileMergeState(s.basePath, ctx)) {
383
- deps.invalidateAllCaches();
384
- state = await deps.deriveState(s.basePath);
385
- mid = state.activeMilestone?.id;
386
- midTitle = state.activeMilestone?.title;
387
- }
388
- if (!mid || !midTitle) {
389
- if (s.currentUnit) {
390
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
391
- }
392
- const noMilestoneReason = !mid
393
- ? "No active milestone after merge reconciliation"
394
- : `Milestone ${mid} has no title after reconciliation`;
395
- await deps.stopAuto(ctx, pi, noMilestoneReason);
396
- debugLog("autoLoop", {
397
- phase: "exit",
398
- reason: "no-milestone-after-reconciliation",
399
- });
400
- break;
401
- }
402
- // Terminal: complete
403
- if (state.phase === "complete") {
404
- if (s.currentUnit) {
405
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
406
- }
407
- // Milestone merge on complete
408
- if (s.currentMilestoneId) {
409
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
410
- }
411
- deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
412
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${mid} complete.`, "success");
413
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
414
- debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
415
- break;
416
- }
417
- // Terminal: blocked
418
- if (state.phase === "blocked") {
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 blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
423
- await deps.stopAuto(ctx, pi, blockerMsg);
424
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
425
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
426
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
427
- debugLog("autoLoop", { phase: "exit", reason: "blocked" });
428
- break;
429
- }
430
- // ── Phase 2: Guards ─────────────────────────────────────────────────
431
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
432
- // Budget ceiling guard
433
- const budgetCeiling = prefs?.budget_ceiling;
434
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
435
- const currentLedger = deps.getLedger();
436
- const totalCost = currentLedger
437
- ? deps.getProjectTotals(currentLedger.units).cost
438
- : 0;
439
- const budgetPct = totalCost / budgetCeiling;
440
- const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
441
- const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
442
- const enforcement = prefs?.budget_enforcement ?? "pause";
443
- const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
444
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
445
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
446
- s.lastBudgetAlertLevel =
447
- newBudgetAlertLevel;
448
- if (budgetEnforcementAction === "halt") {
449
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
450
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
451
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
452
- break;
453
- }
454
- if (budgetEnforcementAction === "pause") {
455
- ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
456
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
457
- deps.logCmuxEvent(prefs, msg, "warning");
458
- await deps.pauseAuto(ctx, pi);
459
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
460
- break;
461
- }
462
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
463
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
464
- deps.logCmuxEvent(prefs, msg, "warning");
549
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
550
+ debugLog("autoLoop", { phase: "exit", reason: "blocked" });
551
+ break;
465
552
  }
466
- else {
467
- // Data-driven 75/80/90% threshold notifications
468
- const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel === t.pct);
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);
469
568
  if (threshold) {
470
569
  s.lastBudgetAlertLevel =
471
570
  newBudgetAlertLevel;
472
- const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
473
- ctx.ui.notify(msg, threshold.notifyLevel);
474
- deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
475
- deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
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
+ }
476
599
  }
477
600
  else if (budgetAlertLevel === 0) {
478
601
  s.lastBudgetAlertLevel = 0;
479
602
  }
480
603
  }
481
- }
482
- else {
483
- s.lastBudgetAlertLevel = 0;
484
- }
485
- // Context window guard
486
- const contextThreshold = prefs?.context_pause_threshold ?? 0;
487
- if (contextThreshold > 0 && s.cmdCtx) {
488
- const contextUsage = s.cmdCtx.getContextUsage();
489
- if (contextUsage &&
490
- contextUsage.percent !== null &&
491
- contextUsage.percent >= contextThreshold) {
492
- const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
493
- ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
494
- deps.sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention");
495
- await deps.pauseAuto(ctx, pi);
496
- debugLog("autoLoop", { phase: "exit", reason: "context-window" });
497
- break;
604
+ else {
605
+ s.lastBudgetAlertLevel = 0;
498
606
  }
499
- }
500
- // Secrets re-check gate
501
- try {
502
- const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
503
- if (manifestStatus && manifestStatus.pending.length > 0) {
504
- const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
505
- if (result &&
506
- result.applied &&
507
- result.skipped &&
508
- result.existingSkipped) {
509
- ctx.ui.notify(`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, "info");
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");
617
+ await deps.pauseAuto(ctx, pi);
618
+ debugLog("autoLoop", { phase: "exit", reason: "context-window" });
619
+ break;
510
620
  }
511
- else {
512
- ctx.ui.notify("Secrets collection skipped.", "info");
621
+ }
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
+ }
513
636
  }
514
637
  }
515
- }
516
- catch (err) {
517
- ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
518
- }
519
- // ── Phase 3: Dispatch resolution ────────────────────────────────────
520
- debugLog("autoLoop", { phase: "dispatch-resolve", iteration });
521
- const dispatchResult = await deps.resolveDispatch({
522
- basePath: s.basePath,
523
- mid,
524
- midTitle: midTitle,
525
- state,
526
- prefs,
527
- session: s,
528
- });
529
- if (dispatchResult.action === "stop") {
530
- if (s.currentUnit) {
531
- 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");
532
640
  }
533
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
534
- debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
535
- break;
536
- }
537
- if (dispatchResult.action !== "dispatch") {
538
- // Non-dispatch action (e.g. "skip") — re-derive state
539
- await new Promise((r) => setImmediate(r));
540
- continue;
541
- }
542
- let unitType = dispatchResult.unitType;
543
- let unitId = dispatchResult.unitId;
544
- let prompt = dispatchResult.prompt;
545
- const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
546
- // ── Same-unit stuck counter with graduated recovery ──
547
- const derivedKey = `${unitType}/${unitId}`;
548
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
549
- sameUnitCount++;
550
- debugLog("autoLoop", {
551
- phase: "stuck-check",
552
- unitType,
553
- unitId,
554
- 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,
555
650
  });
556
- if (sameUnitCount === 3) {
557
- // Level 1: try verifying the artifact — maybe it was written but not detected
558
- const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
559
- 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) {
560
673
  debugLog("autoLoop", {
561
- phase: "stuck-recovery",
562
- level: 1,
563
- action: "artifact-found",
674
+ phase: "stuck-check",
675
+ unitType,
676
+ unitId,
677
+ reason: stuckSignal.reason,
678
+ recoveryAttempts: stuckRecoveryAttempts,
564
679
  });
565
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
566
- deps.invalidateAllCaches();
567
- 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
+ }
568
720
  }
569
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`, "warning");
570
- deps.invalidateAllCaches();
571
721
  }
572
- else if (sameUnitCount === 5) {
573
- // Level 2: hard stop genuinely stuck
574
- debugLog("autoLoop", {
575
- phase: "stuck-detected",
576
- unitType,
577
- unitId,
578
- sameUnitCount,
579
- });
580
- await deps.stopAuto(ctx, pi, `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`);
581
- 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" });
582
744
  break;
583
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)
584
749
  }
585
750
  else {
586
- if (derivedKey !== lastDerivedUnit) {
587
- debugLog("autoLoop", {
588
- phase: "stuck-counter-reset",
589
- from: lastDerivedUnit,
590
- to: derivedKey,
591
- });
592
- }
593
- lastDerivedUnit = derivedKey;
594
- sameUnitCount = 0;
595
- }
596
- // Pre-dispatch hooks
597
- const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
598
- if (preDispatchResult.firedHooks.length > 0) {
599
- ctx.ui.notify(`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info");
600
- }
601
- if (preDispatchResult.action === "skip") {
602
- ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
603
- await new Promise((r) => setImmediate(r));
604
- continue;
605
- }
606
- if (preDispatchResult.action === "replace") {
607
- prompt = preDispatchResult.prompt ?? prompt;
608
- if (preDispatchResult.unitType)
609
- unitType = preDispatchResult.unitType;
610
- }
611
- else if (preDispatchResult.prompt) {
612
- prompt = preDispatchResult.prompt;
613
- }
614
- const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(s.basePath, deps.getMainBranch(s.basePath), unitType, unitId);
615
- if (priorSliceBlocker) {
616
- await deps.stopAuto(ctx, pi, priorSliceBlocker);
617
- debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
618
- 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;
619
759
  }
620
- const observabilityIssues = await deps.collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
621
760
  // ── Phase 4: Unit execution ─────────────────────────────────────────
622
761
  debugLog("autoLoop", {
623
762
  phase: "unit-execution",
@@ -630,33 +769,6 @@ export async function autoLoop(ctx, pi, s, deps) {
630
769
  s.currentUnit.type === unitType &&
631
770
  s.currentUnit.id === unitId);
632
771
  const previousTier = s.currentUnitRouting?.tier;
633
- // Closeout previous unit
634
- if (s.currentUnit) {
635
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
636
- if (s.currentUnitRouting) {
637
- const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
638
- deps.recordOutcome(s.currentUnit.type, s.currentUnitRouting.tier, !isRetry);
639
- }
640
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
641
- const incomingKey = `${unitType}/${unitId}`;
642
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
643
- const artifactVerified = isHookUnit ||
644
- deps.verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
645
- if (closeoutKey !== incomingKey && artifactVerified) {
646
- s.completedUnits.push({
647
- type: s.currentUnit.type,
648
- id: s.currentUnit.id,
649
- startedAt: s.currentUnit.startedAt,
650
- finishedAt: Date.now(),
651
- });
652
- if (s.completedUnits.length > 200) {
653
- s.completedUnits = s.completedUnits.slice(-200);
654
- }
655
- deps.clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
656
- s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
657
- s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
658
- }
659
- }
660
772
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
661
773
  deps.captureAvailableSkills();
662
774
  deps.writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
@@ -674,7 +786,6 @@ export async function autoLoop(ctx, pi, s, deps) {
674
786
  deps.updateProgressWidget(ctx, unitType, unitId, state);
675
787
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
676
788
  // Prompt injection
677
- const MAX_RECOVERY_CHARS = 50_000;
678
789
  let finalPrompt = prompt;
679
790
  if (s.pendingVerificationRetry) {
680
791
  const retryCtx = s.pendingVerificationRetry;
@@ -712,7 +823,7 @@ export async function autoLoop(ctx, pi, s, deps) {
712
823
  s.lastBaselineCharCount = undefined;
713
824
  if (deps.isDbAvailable()) {
714
825
  try {
715
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
826
+ const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js");
716
827
  const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
717
828
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
718
829
  inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
@@ -735,8 +846,8 @@ export async function autoLoop(ctx, pi, s, deps) {
735
846
  const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
736
847
  process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
737
848
  }
738
- // Select and apply model (with tier escalation on retry)
739
- 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 });
740
851
  s.currentUnitRouting =
741
852
  modelResult.routing;
742
853
  // Start unit supervision
@@ -762,7 +873,7 @@ export async function autoLoop(ctx, pi, s, deps) {
762
873
  unitType,
763
874
  unitId,
764
875
  });
765
- const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt, prefs);
876
+ const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt);
766
877
  debugLog("autoLoop", {
767
878
  phase: "runUnit-end",
768
879
  iteration,
@@ -770,12 +881,60 @@ export async function autoLoop(ctx, pi, s, deps) {
770
881
  unitId,
771
882
  status: unitResult.status,
772
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
+ }
773
901
  if (unitResult.status === "cancelled") {
774
902
  ctx.ui.notify(`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, "warning");
775
903
  await deps.stopAuto(ctx, pi, "Session creation failed");
776
904
  debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
777
905
  break;
778
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
+ }
779
938
  // ── Phase 5: Finalize ───────────────────────────────────────────────
780
939
  debugLog("autoLoop", { phase: "finalize", iteration });
781
940
  // Clear unit timeout (unit completed)
@@ -792,7 +951,13 @@ export async function autoLoop(ctx, pi, s, deps) {
792
951
  updateProgressWidget: deps.updateProgressWidget,
793
952
  };
794
953
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
795
- 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);
796
961
  if (preResult === "dispatched") {
797
962
  debugLog("autoLoop", {
798
963
  phase: "exit",
@@ -806,17 +971,28 @@ export async function autoLoop(ctx, pi, s, deps) {
806
971
  debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
807
972
  break;
808
973
  }
809
- // Verification gate — the loop handles retries via s.pendingVerificationRetry
810
- const verificationResult = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
811
- if (verificationResult === "pause") {
812
- debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
813
- break;
814
- }
815
- if (verificationResult === "retry") {
816
- // s.pendingVerificationRetry was set by runPostUnitVerification.
817
- // Continue the loop — next iteration will inject the retry context into the prompt.
818
- debugLog("autoLoop", { phase: "verification-retry", iteration });
819
- 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
+ }
820
996
  }
821
997
  // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
822
998
  const postResult = await deps.postUnitPostVerification(postUnitCtx);
@@ -832,105 +1008,6 @@ export async function autoLoop(ctx, pi, s, deps) {
832
1008
  debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
833
1009
  break;
834
1010
  }
835
- // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ──
836
- let sidecarBroke = false;
837
- while (s.sidecarQueue.length > 0 && s.active) {
838
- const item = s.sidecarQueue.shift();
839
- debugLog("autoLoop", {
840
- phase: "sidecar-dequeue",
841
- kind: item.kind,
842
- unitType: item.unitType,
843
- unitId: item.unitId,
844
- });
845
- // Set up as current unit
846
- const sidecarStartedAt = Date.now();
847
- s.currentUnit = {
848
- type: item.unitType,
849
- id: item.unitId,
850
- startedAt: sidecarStartedAt,
851
- };
852
- deps.writeUnitRuntimeRecord(s.basePath, item.unitType, item.unitId, sidecarStartedAt, {
853
- phase: "dispatched",
854
- wrapupWarningSent: false,
855
- timeoutAt: null,
856
- lastProgressAt: sidecarStartedAt,
857
- progressCount: 0,
858
- lastProgressKind: "dispatch",
859
- });
860
- // Model selection (handles hook model override)
861
- await deps.selectAndApplyModel(ctx, pi, item.unitType, item.unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel);
862
- // Supervision
863
- deps.clearUnitTimeout();
864
- deps.startUnitSupervision({
865
- s,
866
- ctx,
867
- pi,
868
- unitType: item.unitType,
869
- unitId: item.unitId,
870
- prefs,
871
- buildSnapshotOpts: () => deps.buildSnapshotOpts(item.unitType, item.unitId),
872
- buildRecoveryContext: () => ({}),
873
- pauseAuto: deps.pauseAuto,
874
- });
875
- // Write lock
876
- const sidecarSessionFile = deps.getSessionFile(ctx);
877
- deps.writeLock(deps.lockBase(), item.unitType, item.unitId, s.completedUnits.length, sidecarSessionFile);
878
- // Execute via standard runUnit
879
- const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt, prefs);
880
- deps.clearUnitTimeout();
881
- if (sidecarResult.status === "cancelled") {
882
- ctx.ui.notify(`Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`, "warning");
883
- await deps.stopAuto(ctx, pi, "Sidecar session creation failed");
884
- sidecarBroke = true;
885
- break;
886
- }
887
- // Run pre-verification for the sidecar unit (lightweight path)
888
- const sidecarPreOpts = item.kind === "hook"
889
- ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
890
- : { skipSettleDelay: true, skipStateRebuild: true };
891
- const sidecarPreResult = await deps.postUnitPreVerification(postUnitCtx, sidecarPreOpts);
892
- if (sidecarPreResult === "dispatched") {
893
- // Pre-verification caused stop/pause
894
- debugLog("autoLoop", {
895
- phase: "exit",
896
- reason: "sidecar-pre-verification-stop",
897
- });
898
- sidecarBroke = true;
899
- break;
900
- }
901
- // Verification gate for non-hook sidecar units (triage, quick-tasks)
902
- // Hook units are lightweight and don't need verification.
903
- if (item.kind !== "hook") {
904
- const sidecarVerification = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
905
- if (sidecarVerification === "pause") {
906
- debugLog("autoLoop", {
907
- phase: "exit",
908
- reason: "sidecar-verification-pause",
909
- });
910
- sidecarBroke = true;
911
- break;
912
- }
913
- // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity)
914
- }
915
- // Post-verification (may enqueue more sidecar items)
916
- const sidecarPostResult = await deps.postUnitPostVerification(postUnitCtx);
917
- if (sidecarPostResult === "stopped") {
918
- debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" });
919
- sidecarBroke = true;
920
- break;
921
- }
922
- if (sidecarPostResult === "step-wizard") {
923
- debugLog("autoLoop", {
924
- phase: "exit",
925
- reason: "sidecar-step-wizard",
926
- });
927
- sidecarBroke = true;
928
- break;
929
- }
930
- // "continue" — loop checks sidecarQueue again
931
- }
932
- if (sidecarBroke)
933
- break;
934
1011
  consecutiveErrors = 0; // Iteration completed successfully
935
1012
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
936
1013
  }