gsd-pi 2.39.0 → 2.40.0-dev.4a93031

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 (133) hide show
  1. package/dist/resource-loader.js +66 -2
  2. package/dist/resources/extensions/async-jobs/index.js +10 -0
  3. package/dist/resources/extensions/get-secrets-from-user.js +1 -1
  4. package/dist/resources/extensions/gsd/auto-dashboard.js +7 -0
  5. package/dist/resources/extensions/gsd/auto-loop.js +761 -673
  6. package/dist/resources/extensions/gsd/auto-post-unit.js +10 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
  8. package/dist/resources/extensions/gsd/auto-start.js +6 -1
  9. package/dist/resources/extensions/gsd/auto.js +6 -4
  10. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +126 -0
  11. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +233 -0
  12. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +59 -0
  13. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +38 -0
  14. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +156 -0
  15. package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +46 -0
  16. package/dist/resources/extensions/gsd/bootstrap/system-context.js +300 -0
  17. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +38 -0
  18. package/dist/resources/extensions/gsd/commands/catalog.js +278 -0
  19. package/dist/resources/extensions/gsd/commands/context.js +84 -0
  20. package/dist/resources/extensions/gsd/commands/dispatcher.js +21 -0
  21. package/dist/resources/extensions/gsd/commands/handlers/auto.js +72 -0
  22. package/dist/resources/extensions/gsd/commands/handlers/core.js +246 -0
  23. package/dist/resources/extensions/gsd/commands/handlers/ops.js +166 -0
  24. package/dist/resources/extensions/gsd/commands/handlers/parallel.js +94 -0
  25. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +102 -0
  26. package/dist/resources/extensions/gsd/commands/index.js +11 -0
  27. package/dist/resources/extensions/gsd/commands-handlers.js +1 -1
  28. package/dist/resources/extensions/gsd/commands.js +8 -1190
  29. package/dist/resources/extensions/gsd/dashboard-overlay.js +9 -0
  30. package/dist/resources/extensions/gsd/doctor-proactive.js +80 -10
  31. package/dist/resources/extensions/gsd/doctor.js +32 -2
  32. package/dist/resources/extensions/gsd/export-html.js +46 -0
  33. package/dist/resources/extensions/gsd/files.js +1 -1
  34. package/dist/resources/extensions/gsd/health-widget.js +1 -1
  35. package/dist/resources/extensions/gsd/index.js +4 -1115
  36. package/dist/resources/extensions/gsd/progress-score.js +20 -1
  37. package/dist/resources/extensions/gsd/prompts/forensics.md +121 -46
  38. package/dist/resources/extensions/gsd/visualizer-data.js +26 -1
  39. package/dist/resources/extensions/gsd/visualizer-views.js +52 -0
  40. package/dist/welcome-screen.d.ts +3 -2
  41. package/dist/welcome-screen.js +66 -22
  42. package/package.json +1 -1
  43. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +12 -0
  44. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  45. package/packages/pi-coding-agent/dist/core/agent-session.js +107 -24
  46. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/skill-tool.test.d.ts +2 -0
  48. package/packages/pi-coding-agent/dist/core/skill-tool.test.d.ts.map +1 -0
  49. package/packages/pi-coding-agent/dist/core/skill-tool.test.js +70 -0
  50. package/packages/pi-coding-agent/dist/core/skill-tool.test.js.map +1 -0
  51. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/skills.js +2 -1
  53. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts +17 -0
  55. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -0
  56. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +244 -0
  57. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -0
  58. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts +3 -0
  59. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -0
  60. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +58 -0
  61. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -0
  62. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts +12 -0
  63. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts.map +1 -0
  64. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js +54 -0
  65. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js.map +1 -0
  66. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.d.ts +6 -0
  67. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.d.ts.map +1 -0
  68. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js +63 -0
  69. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js.map +1 -0
  70. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +38 -0
  71. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -0
  72. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js +2 -0
  73. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -0
  74. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -1
  75. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  76. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +15 -457
  77. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  78. package/packages/pi-coding-agent/package.json +1 -1
  79. package/packages/pi-coding-agent/src/core/agent-session.ts +122 -23
  80. package/packages/pi-coding-agent/src/core/skill-tool.test.ts +89 -0
  81. package/packages/pi-coding-agent/src/core/skills.ts +2 -1
  82. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +302 -0
  83. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +59 -0
  84. package/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +68 -0
  85. package/packages/pi-coding-agent/src/modes/interactive/controllers/model-controller.ts +71 -0
  86. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +37 -0
  87. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +18 -510
  88. package/pkg/package.json +1 -1
  89. package/src/resources/extensions/async-jobs/index.ts +11 -0
  90. package/src/resources/extensions/get-secrets-from-user.ts +1 -1
  91. package/src/resources/extensions/gsd/auto-dashboard.ts +10 -0
  92. package/src/resources/extensions/gsd/auto-loop.ts +1075 -921
  93. package/src/resources/extensions/gsd/auto-post-unit.ts +10 -2
  94. package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
  95. package/src/resources/extensions/gsd/auto-start.ts +6 -1
  96. package/src/resources/extensions/gsd/auto.ts +13 -10
  97. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +142 -0
  98. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +238 -0
  99. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +90 -0
  100. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +46 -0
  101. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +167 -0
  102. package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +55 -0
  103. package/src/resources/extensions/gsd/bootstrap/system-context.ts +340 -0
  104. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +51 -0
  105. package/src/resources/extensions/gsd/commands/catalog.ts +301 -0
  106. package/src/resources/extensions/gsd/commands/context.ts +101 -0
  107. package/src/resources/extensions/gsd/commands/dispatcher.ts +32 -0
  108. package/src/resources/extensions/gsd/commands/handlers/auto.ts +74 -0
  109. package/src/resources/extensions/gsd/commands/handlers/core.ts +274 -0
  110. package/src/resources/extensions/gsd/commands/handlers/ops.ts +169 -0
  111. package/src/resources/extensions/gsd/commands/handlers/parallel.ts +118 -0
  112. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +109 -0
  113. package/src/resources/extensions/gsd/commands/index.ts +14 -0
  114. package/src/resources/extensions/gsd/commands-handlers.ts +1 -1
  115. package/src/resources/extensions/gsd/commands.ts +10 -1329
  116. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  117. package/src/resources/extensions/gsd/doctor-proactive.ts +106 -10
  118. package/src/resources/extensions/gsd/doctor.ts +47 -3
  119. package/src/resources/extensions/gsd/export-html.ts +51 -0
  120. package/src/resources/extensions/gsd/files.ts +1 -1
  121. package/src/resources/extensions/gsd/health-widget.ts +2 -1
  122. package/src/resources/extensions/gsd/index.ts +12 -1314
  123. package/src/resources/extensions/gsd/progress-score.ts +23 -0
  124. package/src/resources/extensions/gsd/prompts/forensics.md +121 -46
  125. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +13 -9
  126. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +3 -3
  127. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +16 -16
  128. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +4 -4
  129. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +10 -10
  130. package/src/resources/extensions/gsd/visualizer-data.ts +51 -1
  131. package/src/resources/extensions/gsd/visualizer-views.ts +58 -0
  132. /package/dist/resources/extensions/{env-utils.js → gsd/env-utils.js} +0 -0
  133. /package/src/resources/extensions/{env-utils.ts → gsd/env-utils.ts} +0 -0
@@ -205,6 +205,20 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
205
205
  unitId,
206
206
  status: result.status,
207
207
  });
208
+ // Discard trailing follow-up messages (e.g. async_job_result notifications)
209
+ // from the completed unit. Without this, queued follow-ups trigger wasteful
210
+ // LLM turns before the next session can start (#1642).
211
+ // clearQueue() lives on AgentSession but isn't part of the typed
212
+ // ExtensionCommandContext interface — call it via runtime check.
213
+ try {
214
+ const cmdCtxAny = s.cmdCtx;
215
+ if (typeof cmdCtxAny?.clearQueue === "function") {
216
+ cmdCtxAny.clearQueue();
217
+ }
218
+ }
219
+ catch {
220
+ // Non-fatal — clearQueue may not be available in all contexts
221
+ }
208
222
  return result;
209
223
  }
210
224
  // ─── generateMilestoneReport ──────────────────────────────────────────────────
@@ -261,6 +275,717 @@ async function closeoutAndStop(ctx, pi, s, deps, reason) {
261
275
  }
262
276
  await deps.stopAuto(ctx, pi, reason);
263
277
  }
278
+ // ─── runPreDispatch ───────────────────────────────────────────────────────────
279
+ /**
280
+ * Phase 1: Pre-dispatch — resource guard, health gate, state derivation,
281
+ * milestone transition, terminal conditions.
282
+ * Returns break to exit the loop, or next with PreDispatchData on success.
283
+ */
284
+ async function runPreDispatch(ic, loopState) {
285
+ const { ctx, pi, s, deps, prefs } = ic;
286
+ // Resource version guard
287
+ const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
288
+ if (staleMsg) {
289
+ await deps.stopAuto(ctx, pi, staleMsg);
290
+ debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
291
+ return { action: "break", reason: "resources-stale" };
292
+ }
293
+ deps.invalidateAllCaches();
294
+ s.lastPromptCharCount = undefined;
295
+ s.lastBaselineCharCount = undefined;
296
+ // Pre-dispatch health gate
297
+ try {
298
+ const healthGate = await deps.preDispatchHealthGate(s.basePath);
299
+ if (healthGate.fixesApplied.length > 0) {
300
+ ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
301
+ }
302
+ if (!healthGate.proceed) {
303
+ ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
304
+ await deps.pauseAuto(ctx, pi);
305
+ debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
306
+ return { action: "break", reason: "health-gate-failed" };
307
+ }
308
+ }
309
+ catch {
310
+ // Non-fatal
311
+ }
312
+ // Sync project root artifacts into worktree
313
+ if (s.originalBasePath &&
314
+ s.basePath !== s.originalBasePath &&
315
+ s.currentMilestoneId) {
316
+ deps.syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
317
+ }
318
+ // Derive state
319
+ let state = await deps.deriveState(s.basePath);
320
+ deps.syncCmuxSidebar(prefs, state);
321
+ let mid = state.activeMilestone?.id;
322
+ let midTitle = state.activeMilestone?.title;
323
+ debugLog("autoLoop", {
324
+ phase: "state-derived",
325
+ iteration: ic.iteration,
326
+ mid,
327
+ statePhase: state.phase,
328
+ });
329
+ // ── Milestone transition ────────────────────────────────────────────
330
+ if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
331
+ ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
332
+ deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
333
+ deps.logCmuxEvent(prefs, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
334
+ const vizPrefs = prefs;
335
+ if (vizPrefs?.auto_visualize) {
336
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
337
+ }
338
+ if (vizPrefs?.auto_report !== false) {
339
+ try {
340
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId);
341
+ }
342
+ catch (err) {
343
+ ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
344
+ }
345
+ }
346
+ // Reset dispatch counters for new milestone
347
+ s.unitDispatchCount.clear();
348
+ s.unitRecoveryCount.clear();
349
+ s.unitLifetimeDispatches.clear();
350
+ loopState.recentUnits.length = 0;
351
+ loopState.stuckRecoveryAttempts = 0;
352
+ // Worktree lifecycle on milestone transition — merge current, enter next
353
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
354
+ // Opt-in: create draft PR on milestone completion
355
+ if (prefs?.git?.auto_pr) {
356
+ try {
357
+ const { createDraftPR } = await import("./git-service.js");
358
+ 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.`);
359
+ if (prUrl) {
360
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
361
+ }
362
+ }
363
+ catch {
364
+ // Non-fatal — PR creation is best-effort
365
+ }
366
+ }
367
+ deps.invalidateAllCaches();
368
+ state = await deps.deriveState(s.basePath);
369
+ mid = state.activeMilestone?.id;
370
+ midTitle = state.activeMilestone?.title;
371
+ if (mid) {
372
+ if (deps.getIsolationMode() !== "none") {
373
+ deps.captureIntegrationBranch(s.basePath, mid, {
374
+ commitDocs: prefs?.git?.commit_docs,
375
+ });
376
+ }
377
+ deps.resolver.enterMilestone(mid, ctx.ui);
378
+ }
379
+ else {
380
+ // mid is undefined — no milestone to capture integration branch for
381
+ }
382
+ const pendingIds = state.registry
383
+ .filter((m) => m.status !== "complete" && m.status !== "parked")
384
+ .map((m) => m.id);
385
+ deps.pruneQueueOrder(s.basePath, pendingIds);
386
+ }
387
+ if (mid) {
388
+ s.currentMilestoneId = mid;
389
+ deps.setActiveMilestoneId(s.basePath, mid);
390
+ }
391
+ // ── Terminal conditions ──────────────────────────────────────────────
392
+ if (!mid) {
393
+ if (s.currentUnit) {
394
+ await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
395
+ }
396
+ const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
397
+ if (incomplete.length === 0 && state.registry.length > 0) {
398
+ // All milestones complete — merge milestone branch before stopping
399
+ if (s.currentMilestoneId) {
400
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
401
+ // Opt-in: create draft PR on milestone completion
402
+ if (prefs?.git?.auto_pr) {
403
+ try {
404
+ const { createDraftPR } = await import("./git-service.js");
405
+ 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.`);
406
+ if (prUrl) {
407
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
408
+ }
409
+ }
410
+ catch {
411
+ // Non-fatal — PR creation is best-effort
412
+ }
413
+ }
414
+ }
415
+ deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
416
+ deps.logCmuxEvent(prefs, "All milestones complete.", "success");
417
+ await deps.stopAuto(ctx, pi, "All milestones complete");
418
+ }
419
+ else if (incomplete.length === 0 && state.registry.length === 0) {
420
+ // Empty registry — no milestones visible, likely a path resolution bug
421
+ const diag = `basePath=${s.basePath}, phase=${state.phase}`;
422
+ ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
423
+ await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
424
+ }
425
+ else if (state.phase === "blocked") {
426
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
427
+ await deps.stopAuto(ctx, pi, blockerMsg);
428
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
429
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
430
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
431
+ }
432
+ else {
433
+ const ids = incomplete.map((m) => m.id).join(", ");
434
+ const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
435
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
436
+ await deps.stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
437
+ }
438
+ debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
439
+ return { action: "break", reason: "no-active-milestone" };
440
+ }
441
+ if (!midTitle) {
442
+ midTitle = mid;
443
+ ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
444
+ }
445
+ // Mid-merge safety check
446
+ if (deps.reconcileMergeState(s.basePath, ctx)) {
447
+ deps.invalidateAllCaches();
448
+ state = await deps.deriveState(s.basePath);
449
+ mid = state.activeMilestone?.id;
450
+ midTitle = state.activeMilestone?.title;
451
+ }
452
+ if (!mid || !midTitle) {
453
+ const noMilestoneReason = !mid
454
+ ? "No active milestone after merge reconciliation"
455
+ : `Milestone ${mid} has no title after reconciliation`;
456
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
457
+ debugLog("autoLoop", {
458
+ phase: "exit",
459
+ reason: "no-milestone-after-reconciliation",
460
+ });
461
+ return { action: "break", reason: "no-milestone-after-reconciliation" };
462
+ }
463
+ // Terminal: complete
464
+ if (state.phase === "complete") {
465
+ // Milestone merge on complete (before closeout so branch state is clean)
466
+ if (s.currentMilestoneId) {
467
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
468
+ // Opt-in: create draft PR on milestone completion
469
+ if (prefs?.git?.auto_pr) {
470
+ try {
471
+ const { createDraftPR } = await import("./git-service.js");
472
+ 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.`);
473
+ if (prUrl) {
474
+ ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
475
+ }
476
+ }
477
+ catch {
478
+ // Non-fatal — PR creation is best-effort
479
+ }
480
+ }
481
+ }
482
+ deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
483
+ deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success");
484
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
485
+ debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
486
+ return { action: "break", reason: "milestone-complete" };
487
+ }
488
+ // Terminal: blocked
489
+ if (state.phase === "blocked") {
490
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
491
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
492
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
493
+ deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
494
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
495
+ debugLog("autoLoop", { phase: "exit", reason: "blocked" });
496
+ return { action: "break", reason: "blocked" };
497
+ }
498
+ return { action: "next", data: { state, mid, midTitle } };
499
+ }
500
+ // ─── runDispatch ──────────────────────────────────────────────────────────────
501
+ /**
502
+ * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks.
503
+ * Returns break/continue to control the loop, or next with IterationData on success.
504
+ */
505
+ async function runDispatch(ic, preData, loopState) {
506
+ const { ctx, pi, s, deps, prefs } = ic;
507
+ const { state, mid, midTitle } = preData;
508
+ const STUCK_WINDOW_SIZE = 6;
509
+ debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration });
510
+ const dispatchResult = await deps.resolveDispatch({
511
+ basePath: s.basePath,
512
+ mid,
513
+ midTitle,
514
+ state,
515
+ prefs,
516
+ session: s,
517
+ });
518
+ if (dispatchResult.action === "stop") {
519
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
520
+ debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
521
+ return { action: "break", reason: "dispatch-stop" };
522
+ }
523
+ if (dispatchResult.action !== "dispatch") {
524
+ // Non-dispatch action (e.g. "skip") — re-derive state
525
+ await new Promise((r) => setImmediate(r));
526
+ return { action: "continue" };
527
+ }
528
+ let unitType = dispatchResult.unitType;
529
+ let unitId = dispatchResult.unitId;
530
+ let prompt = dispatchResult.prompt;
531
+ const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
532
+ // ── Sliding-window stuck detection with graduated recovery ──
533
+ const derivedKey = `${unitType}/${unitId}`;
534
+ if (!s.pendingVerificationRetry) {
535
+ loopState.recentUnits.push({ key: derivedKey });
536
+ if (loopState.recentUnits.length > STUCK_WINDOW_SIZE)
537
+ loopState.recentUnits.shift();
538
+ const stuckSignal = detectStuck(loopState.recentUnits);
539
+ if (stuckSignal) {
540
+ debugLog("autoLoop", {
541
+ phase: "stuck-check",
542
+ unitType,
543
+ unitId,
544
+ reason: stuckSignal.reason,
545
+ recoveryAttempts: loopState.stuckRecoveryAttempts,
546
+ });
547
+ if (loopState.stuckRecoveryAttempts === 0) {
548
+ // Level 1: try verifying the artifact, then cache invalidation + retry
549
+ loopState.stuckRecoveryAttempts++;
550
+ const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
551
+ if (artifactExists) {
552
+ debugLog("autoLoop", {
553
+ phase: "stuck-recovery",
554
+ level: 1,
555
+ action: "artifact-found",
556
+ });
557
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
558
+ deps.invalidateAllCaches();
559
+ return { action: "continue" };
560
+ }
561
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, "warning");
562
+ deps.invalidateAllCaches();
563
+ }
564
+ else {
565
+ // Level 2: hard stop — genuinely stuck
566
+ debugLog("autoLoop", {
567
+ phase: "stuck-detected",
568
+ unitType,
569
+ unitId,
570
+ reason: stuckSignal.reason,
571
+ });
572
+ await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
573
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, "error");
574
+ return { action: "break", reason: "stuck-detected" };
575
+ }
576
+ }
577
+ else {
578
+ // Progress detected — reset recovery counter
579
+ if (loopState.stuckRecoveryAttempts > 0) {
580
+ debugLog("autoLoop", {
581
+ phase: "stuck-counter-reset",
582
+ from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "",
583
+ to: derivedKey,
584
+ });
585
+ loopState.stuckRecoveryAttempts = 0;
586
+ }
587
+ }
588
+ }
589
+ // Pre-dispatch hooks
590
+ const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
591
+ if (preDispatchResult.firedHooks.length > 0) {
592
+ ctx.ui.notify(`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info");
593
+ }
594
+ if (preDispatchResult.action === "skip") {
595
+ ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
596
+ await new Promise((r) => setImmediate(r));
597
+ return { action: "continue" };
598
+ }
599
+ if (preDispatchResult.action === "replace") {
600
+ prompt = preDispatchResult.prompt ?? prompt;
601
+ if (preDispatchResult.unitType)
602
+ unitType = preDispatchResult.unitType;
603
+ }
604
+ else if (preDispatchResult.prompt) {
605
+ prompt = preDispatchResult.prompt;
606
+ }
607
+ const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(s.basePath, deps.getMainBranch(s.basePath), unitType, unitId);
608
+ if (priorSliceBlocker) {
609
+ await deps.stopAuto(ctx, pi, priorSliceBlocker);
610
+ debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" });
611
+ return { action: "break", reason: "prior-slice-blocker" };
612
+ }
613
+ const observabilityIssues = await deps.collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
614
+ return {
615
+ action: "next",
616
+ data: {
617
+ unitType, unitId, prompt, finalPrompt: prompt,
618
+ pauseAfterUatDispatch, observabilityIssues,
619
+ state, mid, midTitle,
620
+ isRetry: false, previousTier: undefined,
621
+ },
622
+ };
623
+ }
624
+ // ─── runGuards ────────────────────────────────────────────────────────────────
625
+ /**
626
+ * Phase 2: Guards — budget ceiling, context window, secrets re-check.
627
+ * Returns break to exit the loop, or next to proceed to dispatch.
628
+ */
629
+ async function runGuards(ic, mid) {
630
+ const { ctx, pi, s, deps, prefs } = ic;
631
+ // Budget ceiling guard
632
+ const budgetCeiling = prefs?.budget_ceiling;
633
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
634
+ const currentLedger = deps.getLedger();
635
+ const totalCost = currentLedger
636
+ ? deps.getProjectTotals(currentLedger.units).cost
637
+ : 0;
638
+ const budgetPct = totalCost / budgetCeiling;
639
+ const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
640
+ const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
641
+ const enforcement = prefs?.budget_enforcement ?? "pause";
642
+ const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
643
+ // Data-driven threshold check — loop descending, fire first match
644
+ const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
645
+ if (threshold) {
646
+ s.lastBudgetAlertLevel =
647
+ newBudgetAlertLevel;
648
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
649
+ // 100% — special enforcement logic (halt/pause/warn)
650
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
651
+ if (budgetEnforcementAction === "halt") {
652
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
653
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
654
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
655
+ return { action: "break", reason: "budget-halt" };
656
+ }
657
+ if (budgetEnforcementAction === "pause") {
658
+ ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
659
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
660
+ deps.logCmuxEvent(prefs, msg, "warning");
661
+ await deps.pauseAuto(ctx, pi);
662
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
663
+ return { action: "break", reason: "budget-pause" };
664
+ }
665
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
666
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
667
+ deps.logCmuxEvent(prefs, msg, "warning");
668
+ }
669
+ else if (threshold.pct < 100) {
670
+ // Sub-100% — simple notification
671
+ const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
672
+ ctx.ui.notify(msg, threshold.notifyLevel);
673
+ deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
674
+ deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
675
+ }
676
+ }
677
+ else if (budgetAlertLevel === 0) {
678
+ s.lastBudgetAlertLevel = 0;
679
+ }
680
+ }
681
+ else {
682
+ s.lastBudgetAlertLevel = 0;
683
+ }
684
+ // Context window guard
685
+ const contextThreshold = prefs?.context_pause_threshold ?? 0;
686
+ if (contextThreshold > 0 && s.cmdCtx) {
687
+ const contextUsage = s.cmdCtx.getContextUsage();
688
+ if (contextUsage &&
689
+ contextUsage.percent !== null &&
690
+ contextUsage.percent >= contextThreshold) {
691
+ const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
692
+ ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
693
+ deps.sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention");
694
+ await deps.pauseAuto(ctx, pi);
695
+ debugLog("autoLoop", { phase: "exit", reason: "context-window" });
696
+ return { action: "break", reason: "context-window" };
697
+ }
698
+ }
699
+ // Secrets re-check gate
700
+ try {
701
+ const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath);
702
+ if (manifestStatus && manifestStatus.pending.length > 0) {
703
+ const result = await deps.collectSecretsFromManifest(s.basePath, mid, ctx);
704
+ if (result &&
705
+ result.applied &&
706
+ result.skipped &&
707
+ result.existingSkipped) {
708
+ ctx.ui.notify(`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, "info");
709
+ }
710
+ else {
711
+ ctx.ui.notify("Secrets collection skipped.", "info");
712
+ }
713
+ }
714
+ }
715
+ catch (err) {
716
+ ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
717
+ }
718
+ return { action: "next", data: undefined };
719
+ }
720
+ // ─── runUnitPhase ─────────────────────────────────────────────────────────────
721
+ /**
722
+ * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify.
723
+ * Returns break or next with unitStartedAt for downstream phases.
724
+ */
725
+ async function runUnitPhase(ic, iterData, loopState, sidecarItem) {
726
+ const { ctx, pi, s, deps, prefs } = ic;
727
+ const { unitType, unitId, prompt, observabilityIssues, state, mid } = iterData;
728
+ debugLog("autoLoop", {
729
+ phase: "unit-execution",
730
+ iteration: ic.iteration,
731
+ unitType,
732
+ unitId,
733
+ });
734
+ // Detect retry and capture previous tier for escalation
735
+ const isRetry = !!(s.currentUnit &&
736
+ s.currentUnit.type === unitType &&
737
+ s.currentUnit.id === unitId);
738
+ const previousTier = s.currentUnitRouting?.tier;
739
+ s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
740
+ deps.captureAvailableSkills();
741
+ deps.writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
742
+ phase: "dispatched",
743
+ wrapupWarningSent: false,
744
+ timeoutAt: null,
745
+ lastProgressAt: s.currentUnit.startedAt,
746
+ progressCount: 0,
747
+ lastProgressKind: "dispatch",
748
+ });
749
+ // Status bar + progress widget
750
+ ctx.ui.setStatus("gsd-auto", "auto");
751
+ if (mid)
752
+ deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
753
+ deps.updateProgressWidget(ctx, unitType, unitId, state);
754
+ deps.ensurePreconditions(unitType, unitId, s.basePath, state);
755
+ // Prompt injection
756
+ let finalPrompt = prompt;
757
+ if (s.pendingVerificationRetry) {
758
+ const retryCtx = s.pendingVerificationRetry;
759
+ s.pendingVerificationRetry = null;
760
+ const capped = retryCtx.failureContext.length > MAX_RECOVERY_CHARS
761
+ ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
762
+ "\n\n[...failure context truncated]"
763
+ : retryCtx.failureContext;
764
+ finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`;
765
+ }
766
+ if (s.pendingCrashRecovery) {
767
+ const capped = s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
768
+ ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
769
+ "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
770
+ : s.pendingCrashRecovery;
771
+ finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
772
+ s.pendingCrashRecovery = null;
773
+ }
774
+ else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
775
+ const diagnostic = deps.getDeepDiagnostic(s.basePath);
776
+ if (diagnostic) {
777
+ const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS
778
+ ? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
779
+ "\n\n[...diagnostic truncated to prevent memory exhaustion]"
780
+ : diagnostic;
781
+ finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
782
+ }
783
+ }
784
+ const repairBlock = deps.buildObservabilityRepairBlock(observabilityIssues);
785
+ if (repairBlock) {
786
+ finalPrompt = `${finalPrompt}${repairBlock}`;
787
+ }
788
+ // Prompt char measurement
789
+ s.lastPromptCharCount = finalPrompt.length;
790
+ s.lastBaselineCharCount = undefined;
791
+ if (deps.isDbAvailable()) {
792
+ try {
793
+ const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js");
794
+ const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
795
+ inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
796
+ inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
797
+ inlineGsdRootFile(s.basePath, "project.md", "Project"),
798
+ ]);
799
+ s.lastBaselineCharCount =
800
+ (decisionsContent?.length ?? 0) +
801
+ (requirementsContent?.length ?? 0) +
802
+ (projectContent?.length ?? 0);
803
+ }
804
+ catch {
805
+ // Non-fatal
806
+ }
807
+ }
808
+ // Cache-optimize prompt section ordering
809
+ try {
810
+ finalPrompt = deps.reorderForCaching(finalPrompt);
811
+ }
812
+ catch (reorderErr) {
813
+ const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
814
+ process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
815
+ }
816
+ // Select and apply model (with tier escalation on retry — normal units only)
817
+ const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel, sidecarItem ? undefined : { isRetry, previousTier });
818
+ s.currentUnitRouting =
819
+ modelResult.routing;
820
+ // Start unit supervision
821
+ deps.clearUnitTimeout();
822
+ deps.startUnitSupervision({
823
+ s,
824
+ ctx,
825
+ pi,
826
+ unitType,
827
+ unitId,
828
+ prefs,
829
+ buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
830
+ buildRecoveryContext: () => ({}),
831
+ pauseAuto: deps.pauseAuto,
832
+ });
833
+ // Session + send + await
834
+ const sessionFile = deps.getSessionFile(ctx);
835
+ deps.updateSessionLock(deps.lockBase(), unitType, unitId, s.completedUnits.length, sessionFile);
836
+ deps.writeLock(deps.lockBase(), unitType, unitId, s.completedUnits.length, sessionFile);
837
+ debugLog("autoLoop", {
838
+ phase: "runUnit-start",
839
+ iteration: ic.iteration,
840
+ unitType,
841
+ unitId,
842
+ });
843
+ const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt);
844
+ debugLog("autoLoop", {
845
+ phase: "runUnit-end",
846
+ iteration: ic.iteration,
847
+ unitType,
848
+ unitId,
849
+ status: unitResult.status,
850
+ });
851
+ // Tag the most recent window entry with error info for stuck detection
852
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
853
+ const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
854
+ if (lastEntry) {
855
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
856
+ }
857
+ }
858
+ else if (unitResult.event?.messages?.length) {
859
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
860
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
861
+ if (/error|fail|exception/i.test(msgStr)) {
862
+ const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
863
+ if (lastEntry) {
864
+ lastEntry.error = msgStr.slice(0, 200);
865
+ }
866
+ }
867
+ }
868
+ if (unitResult.status === "cancelled") {
869
+ ctx.ui.notify(`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, "warning");
870
+ await deps.stopAuto(ctx, pi, "Session creation failed");
871
+ debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
872
+ return { action: "break", reason: "session-failed" };
873
+ }
874
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
875
+ // Run right after runUnit() returns so telemetry is never lost to a
876
+ // crash between iterations.
877
+ await deps.closeoutUnit(ctx, s.basePath, unitType, unitId, s.currentUnit.startedAt, deps.buildSnapshotOpts(unitType, unitId));
878
+ if (s.currentUnitRouting) {
879
+ deps.recordOutcome(unitType, s.currentUnitRouting.tier, true);
880
+ }
881
+ const isHookUnit = unitType.startsWith("hook/");
882
+ const artifactVerified = isHookUnit ||
883
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
884
+ if (artifactVerified) {
885
+ s.completedUnits.push({
886
+ type: unitType,
887
+ id: unitId,
888
+ startedAt: s.currentUnit.startedAt,
889
+ finishedAt: Date.now(),
890
+ });
891
+ if (s.completedUnits.length > 200) {
892
+ s.completedUnits = s.completedUnits.slice(-200);
893
+ }
894
+ // Flush completed-units to disk so the record survives crashes
895
+ try {
896
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
897
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
898
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
899
+ }
900
+ catch { /* non-fatal: disk flush failure */ }
901
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
902
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
903
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
904
+ }
905
+ return { action: "next", data: { unitStartedAt: s.currentUnit.startedAt } };
906
+ }
907
+ // ─── runFinalize ──────────────────────────────────────────────────────────────
908
+ /**
909
+ * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard.
910
+ * Returns break/continue/next to control the outer loop.
911
+ */
912
+ async function runFinalize(ic, iterData, sidecarItem) {
913
+ const { ctx, pi, s, deps } = ic;
914
+ const { pauseAfterUatDispatch } = iterData;
915
+ debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration });
916
+ // Clear unit timeout (unit completed)
917
+ deps.clearUnitTimeout();
918
+ // Post-unit context for pre/post verification
919
+ const postUnitCtx = {
920
+ s,
921
+ ctx,
922
+ pi,
923
+ buildSnapshotOpts: deps.buildSnapshotOpts,
924
+ lockBase: deps.lockBase,
925
+ stopAuto: deps.stopAuto,
926
+ pauseAuto: deps.pauseAuto,
927
+ updateProgressWidget: deps.updateProgressWidget,
928
+ };
929
+ // Pre-verification processing (commit, doctor, state rebuild, etc.)
930
+ // Sidecar items use lightweight pre-verification opts
931
+ const preVerificationOpts = sidecarItem
932
+ ? sidecarItem.kind === "hook"
933
+ ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
934
+ : { skipSettleDelay: true, skipStateRebuild: true }
935
+ : undefined;
936
+ const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
937
+ if (preResult === "dispatched") {
938
+ debugLog("autoLoop", {
939
+ phase: "exit",
940
+ reason: "pre-verification-dispatched",
941
+ });
942
+ return { action: "break", reason: "pre-verification-dispatched" };
943
+ }
944
+ if (pauseAfterUatDispatch) {
945
+ ctx.ui.notify("UAT requires human execution. Auto-mode will pause after this unit writes the result file.", "info");
946
+ await deps.pauseAuto(ctx, pi);
947
+ debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
948
+ return { action: "break", reason: "uat-pause" };
949
+ }
950
+ // Verification gate
951
+ // Hook sidecar items skip verification entirely.
952
+ // Non-hook sidecar items run verification but skip retries (just continue).
953
+ const skipVerification = sidecarItem?.kind === "hook";
954
+ if (!skipVerification) {
955
+ const verificationResult = await deps.runPostUnitVerification({ s, ctx, pi }, deps.pauseAuto);
956
+ if (verificationResult === "pause") {
957
+ debugLog("autoLoop", { phase: "exit", reason: "verification-pause" });
958
+ return { action: "break", reason: "verification-pause" };
959
+ }
960
+ if (verificationResult === "retry") {
961
+ if (sidecarItem) {
962
+ // Sidecar verification retries are skipped — just continue
963
+ debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration: ic.iteration });
964
+ }
965
+ else {
966
+ // s.pendingVerificationRetry was set by runPostUnitVerification.
967
+ // Continue the loop — next iteration will inject the retry context into the prompt.
968
+ debugLog("autoLoop", { phase: "verification-retry", iteration: ic.iteration });
969
+ return { action: "continue" };
970
+ }
971
+ }
972
+ }
973
+ // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
974
+ const postResult = await deps.postUnitPostVerification(postUnitCtx);
975
+ if (postResult === "stopped") {
976
+ debugLog("autoLoop", {
977
+ phase: "exit",
978
+ reason: "post-verification-stopped",
979
+ });
980
+ return { action: "break", reason: "post-verification-stopped" };
981
+ }
982
+ if (postResult === "step-wizard") {
983
+ // Step mode — exit the loop (caller handles wizard)
984
+ debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
985
+ return { action: "break", reason: "step-wizard" };
986
+ }
987
+ return { action: "next", data: undefined };
988
+ }
264
989
  // ─── autoLoop ────────────────────────────────────────────────────────────────
265
990
  /**
266
991
  * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
@@ -273,10 +998,7 @@ async function closeoutAndStop(ctx, pi, s, deps, reason) {
273
998
  export async function autoLoop(ctx, pi, s, deps) {
274
999
  debugLog("autoLoop", { phase: "enter" });
275
1000
  let iteration = 0;
276
- // ── Sliding-window stuck detection ──
277
- const recentUnits = [];
278
- const STUCK_WINDOW_SIZE = 6;
279
- let stuckRecoveryAttempts = 0;
1001
+ const loopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
280
1002
  let consecutiveErrors = 0;
281
1003
  while (s.active) {
282
1004
  iteration++;
@@ -327,687 +1049,53 @@ export async function autoLoop(ctx, pi, s, deps) {
327
1049
  break;
328
1050
  }
329
1051
  }
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 = [];
1052
+ const ic = { ctx, pi, s, deps, prefs, iteration };
1053
+ let iterData;
339
1054
  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" });
346
- break;
347
- }
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");
356
- }
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;
362
- }
363
- }
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
374
- state = await deps.deriveState(s.basePath);
375
- deps.syncCmuxSidebar(prefs, state);
376
- mid = state.activeMilestone?.id;
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
- }
442
- if (mid) {
443
- s.currentMilestoneId = mid;
444
- deps.setActiveMilestoneId(s.basePath, mid);
445
- }
446
- // ── Terminal conditions ──────────────────────────────────────────────
447
- if (!mid) {
448
- if (s.currentUnit) {
449
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
450
- }
451
- const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
452
- if (incomplete.length === 0 && state.registry.length > 0) {
453
- // All milestones complete — merge milestone branch before stopping
454
- if (s.currentMilestoneId) {
455
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
456
- // Opt-in: create draft PR on milestone completion
457
- if (prefs?.git?.auto_pr) {
458
- try {
459
- const { createDraftPR } = await import("./git-service.js");
460
- const prUrl = createDraftPR(s.basePath, s.currentMilestoneId, `[GSD] ${s.currentMilestoneId} complete`, `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`);
461
- if (prUrl) {
462
- ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
463
- }
464
- }
465
- catch {
466
- // Non-fatal — PR creation is best-effort
467
- }
468
- }
469
- }
470
- deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
471
- deps.logCmuxEvent(prefs, "All milestones complete.", "success");
472
- await deps.stopAuto(ctx, pi, "All milestones complete");
473
- }
474
- else if (incomplete.length === 0 && state.registry.length === 0) {
475
- // Empty registry — no milestones visible, likely a path resolution bug
476
- const diag = `basePath=${s.basePath}, phase=${state.phase}`;
477
- ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
478
- await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
479
- }
480
- else if (state.phase === "blocked") {
481
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
482
- await deps.stopAuto(ctx, pi, blockerMsg);
483
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
484
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
485
- deps.logCmuxEvent(prefs, blockerMsg, "error");
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" });
1055
+ // ── Phase 1: Pre-dispatch ─────────────────────────────────────────
1056
+ const preDispatchResult = await runPreDispatch(ic, loopState);
1057
+ if (preDispatchResult.action === "break")
494
1058
  break;
495
- }
496
- if (!midTitle) {
497
- midTitle = mid;
498
- ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
499
- }
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)
521
- if (s.currentMilestoneId) {
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
- }
536
- }
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;
542
- }
543
- // Terminal: blocked
544
- if (state.phase === "blocked") {
545
- const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
546
- await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
547
- ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
548
- deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
549
- deps.logCmuxEvent(prefs, blockerMsg, "error");
550
- debugLog("autoLoop", { phase: "exit", reason: "blocked" });
1059
+ if (preDispatchResult.action === "continue")
1060
+ continue;
1061
+ const preData = preDispatchResult.data;
1062
+ // ── Phase 2: Guards ───────────────────────────────────────────────
1063
+ const guardsResult = await runGuards(ic, preData.mid);
1064
+ if (guardsResult.action === "break")
551
1065
  break;
552
- }
553
- // ── Phase 2: Guards ─────────────────────────────────────────────────
554
- // Budget ceiling guard
555
- const budgetCeiling = prefs?.budget_ceiling;
556
- if (budgetCeiling !== undefined && budgetCeiling > 0) {
557
- const currentLedger = deps.getLedger();
558
- const totalCost = currentLedger
559
- ? deps.getProjectTotals(currentLedger.units).cost
560
- : 0;
561
- const budgetPct = totalCost / budgetCeiling;
562
- const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct);
563
- const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
564
- const enforcement = prefs?.budget_enforcement ?? "pause";
565
- const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
566
- // Data-driven threshold check — loop descending, fire first match
567
- const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
568
- if (threshold) {
569
- s.lastBudgetAlertLevel =
570
- newBudgetAlertLevel;
571
- if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
572
- // 100% — special enforcement logic (halt/pause/warn)
573
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
574
- if (budgetEnforcementAction === "halt") {
575
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
576
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
577
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
578
- break;
579
- }
580
- if (budgetEnforcementAction === "pause") {
581
- ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
582
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
583
- deps.logCmuxEvent(prefs, msg, "warning");
584
- await deps.pauseAuto(ctx, pi);
585
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
586
- break;
587
- }
588
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
589
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
590
- deps.logCmuxEvent(prefs, msg, "warning");
591
- }
592
- else if (threshold.pct < 100) {
593
- // Sub-100% — simple notification
594
- const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
595
- ctx.ui.notify(msg, threshold.notifyLevel);
596
- deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
597
- deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
598
- }
599
- }
600
- else if (budgetAlertLevel === 0) {
601
- s.lastBudgetAlertLevel = 0;
602
- }
603
- }
604
- else {
605
- s.lastBudgetAlertLevel = 0;
606
- }
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;
620
- }
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
- }
636
- }
637
- }
638
- catch (err) {
639
- ctx.ui.notify(`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, "warning");
640
- }
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,
650
- });
651
- if (dispatchResult.action === "stop") {
652
- await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
653
- debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
1066
+ // ── Phase 3: Dispatch ─────────────────────────────────────────────
1067
+ const dispatchResult = await runDispatch(ic, preData, loopState);
1068
+ if (dispatchResult.action === "break")
654
1069
  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) {
673
- debugLog("autoLoop", {
674
- phase: "stuck-check",
675
- unitType,
676
- unitId,
677
- reason: stuckSignal.reason,
678
- recoveryAttempts: stuckRecoveryAttempts,
679
- });
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
- }
720
- }
721
- }
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));
1070
+ if (dispatchResult.action === "continue")
730
1071
  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" });
744
- break;
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)
1072
+ iterData = dispatchResult.data;
749
1073
  }
750
1074
  else {
751
1075
  // ── 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;
759
- }
760
- // ── Phase 4: Unit execution ─────────────────────────────────────────
761
- debugLog("autoLoop", {
762
- phase: "unit-execution",
763
- iteration,
764
- unitType,
765
- unitId,
766
- });
767
- // Detect retry and capture previous tier for escalation
768
- const isRetry = !!(s.currentUnit &&
769
- s.currentUnit.type === unitType &&
770
- s.currentUnit.id === unitId);
771
- const previousTier = s.currentUnitRouting?.tier;
772
- s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
773
- deps.captureAvailableSkills();
774
- deps.writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
775
- phase: "dispatched",
776
- wrapupWarningSent: false,
777
- timeoutAt: null,
778
- lastProgressAt: s.currentUnit.startedAt,
779
- progressCount: 0,
780
- lastProgressKind: "dispatch",
781
- });
782
- // Status bar + progress widget
783
- ctx.ui.setStatus("gsd-auto", "auto");
784
- if (mid)
785
- deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
786
- deps.updateProgressWidget(ctx, unitType, unitId, state);
787
- deps.ensurePreconditions(unitType, unitId, s.basePath, state);
788
- // Prompt injection
789
- let finalPrompt = prompt;
790
- if (s.pendingVerificationRetry) {
791
- const retryCtx = s.pendingVerificationRetry;
792
- s.pendingVerificationRetry = null;
793
- const capped = retryCtx.failureContext.length > MAX_RECOVERY_CHARS
794
- ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
795
- "\n\n[...failure context truncated]"
796
- : retryCtx.failureContext;
797
- finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`;
798
- }
799
- if (s.pendingCrashRecovery) {
800
- const capped = s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
801
- ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
802
- "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
803
- : s.pendingCrashRecovery;
804
- finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
805
- s.pendingCrashRecovery = null;
806
- }
807
- else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
808
- const diagnostic = deps.getDeepDiagnostic(s.basePath);
809
- if (diagnostic) {
810
- const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS
811
- ? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
812
- "\n\n[...diagnostic truncated to prevent memory exhaustion]"
813
- : diagnostic;
814
- finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
815
- }
816
- }
817
- const repairBlock = deps.buildObservabilityRepairBlock(observabilityIssues);
818
- if (repairBlock) {
819
- finalPrompt = `${finalPrompt}${repairBlock}`;
820
- }
821
- // Prompt char measurement
822
- s.lastPromptCharCount = finalPrompt.length;
823
- s.lastBaselineCharCount = undefined;
824
- if (deps.isDbAvailable()) {
825
- try {
826
- const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js");
827
- const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
828
- inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
829
- inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
830
- inlineGsdRootFile(s.basePath, "project.md", "Project"),
831
- ]);
832
- s.lastBaselineCharCount =
833
- (decisionsContent?.length ?? 0) +
834
- (requirementsContent?.length ?? 0) +
835
- (projectContent?.length ?? 0);
836
- }
837
- catch {
838
- // Non-fatal
839
- }
840
- }
841
- // Cache-optimize prompt section ordering
842
- try {
843
- finalPrompt = deps.reorderForCaching(finalPrompt);
844
- }
845
- catch (reorderErr) {
846
- const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
847
- process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
848
- }
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 });
851
- s.currentUnitRouting =
852
- modelResult.routing;
853
- // Start unit supervision
854
- deps.clearUnitTimeout();
855
- deps.startUnitSupervision({
856
- s,
857
- ctx,
858
- pi,
859
- unitType,
860
- unitId,
861
- prefs,
862
- buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
863
- buildRecoveryContext: () => ({}),
864
- pauseAuto: deps.pauseAuto,
865
- });
866
- // Session + send + await
867
- const sessionFile = deps.getSessionFile(ctx);
868
- deps.updateSessionLock(deps.lockBase(), unitType, unitId, s.completedUnits.length, sessionFile);
869
- deps.writeLock(deps.lockBase(), unitType, unitId, s.completedUnits.length, sessionFile);
870
- debugLog("autoLoop", {
871
- phase: "runUnit-start",
872
- iteration,
873
- unitType,
874
- unitId,
875
- });
876
- const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt);
877
- debugLog("autoLoop", {
878
- phase: "runUnit-end",
879
- iteration,
880
- unitType,
881
- unitId,
882
- status: unitResult.status,
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
- }
1076
+ const sidecarState = await deps.deriveState(s.basePath);
1077
+ iterData = {
1078
+ unitType: sidecarItem.unitType,
1079
+ unitId: sidecarItem.unitId,
1080
+ prompt: sidecarItem.prompt,
1081
+ finalPrompt: sidecarItem.prompt,
1082
+ pauseAfterUatDispatch: false,
1083
+ observabilityIssues: [],
1084
+ state: sidecarState,
1085
+ mid: sidecarState.activeMilestone?.id,
1086
+ midTitle: sidecarState.activeMilestone?.title,
1087
+ isRetry: false, previousTier: undefined,
1088
+ };
900
1089
  }
901
- if (unitResult.status === "cancelled") {
902
- ctx.ui.notify(`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, "warning");
903
- await deps.stopAuto(ctx, pi, "Session creation failed");
904
- debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
1090
+ const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem);
1091
+ if (unitPhaseResult.action === "break")
905
1092
  break;
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
- }
938
1093
  // ── Phase 5: Finalize ───────────────────────────────────────────────
939
- debugLog("autoLoop", { phase: "finalize", iteration });
940
- // Clear unit timeout (unit completed)
941
- deps.clearUnitTimeout();
942
- // Post-unit context for pre/post verification
943
- const postUnitCtx = {
944
- s,
945
- ctx,
946
- pi,
947
- buildSnapshotOpts: deps.buildSnapshotOpts,
948
- lockBase: deps.lockBase,
949
- stopAuto: deps.stopAuto,
950
- pauseAuto: deps.pauseAuto,
951
- updateProgressWidget: deps.updateProgressWidget,
952
- };
953
- // Pre-verification processing (commit, doctor, state rebuild, etc.)
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);
961
- if (preResult === "dispatched") {
962
- debugLog("autoLoop", {
963
- phase: "exit",
964
- reason: "pre-verification-dispatched",
965
- });
966
- break;
967
- }
968
- if (pauseAfterUatDispatch) {
969
- ctx.ui.notify("UAT requires human execution. Auto-mode will pause after this unit writes the result file.", "info");
970
- await deps.pauseAuto(ctx, pi);
971
- debugLog("autoLoop", { phase: "exit", reason: "uat-pause" });
1094
+ const finalizeResult = await runFinalize(ic, iterData, sidecarItem);
1095
+ if (finalizeResult.action === "break")
972
1096
  break;
973
- }
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
- }
996
- }
997
- // Post-verification processing (DB dual-write, hooks, triage, quick-tasks)
998
- const postResult = await deps.postUnitPostVerification(postUnitCtx);
999
- if (postResult === "stopped") {
1000
- debugLog("autoLoop", {
1001
- phase: "exit",
1002
- reason: "post-verification-stopped",
1003
- });
1004
- break;
1005
- }
1006
- if (postResult === "step-wizard") {
1007
- // Step mode — exit the loop (caller handles wizard)
1008
- debugLog("autoLoop", { phase: "exit", reason: "step-wizard" });
1009
- break;
1010
- }
1097
+ if (finalizeResult.action === "continue")
1098
+ continue;
1011
1099
  consecutiveErrors = 0; // Iteration completed successfully
1012
1100
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
1013
1101
  }